v2.5.0: add QR import and backup scheduling

This commit is contained in:
Виталий Литвинов
2026-04-25 14:39:56 +03:00
parent c7540a97f7
commit b2ab0dca57
10 changed files with 854 additions and 97 deletions

View File

@@ -64,6 +64,7 @@ const i18n = {
addKey: "Add key",
copyLink: "Copy link",
copySecret: "Copy secret",
showQr: "QR",
delete: "Delete",
enabled: "Enabled",
disabled: "Disabled",
@@ -73,6 +74,20 @@ const i18n = {
enableKey: "Enable key",
main: "main",
createBackup: "Create backup",
restoreBackup: "Restore",
backupScheduleTitle: "Automatic backups",
backupScheduleLoading: "Loading schedule...",
backupIncludesTitle: "Backup contents",
backupIncludesText: "telemt config, goTelegram settings, keys, disabled keys, site, templates, SSL certificates, bot, admin panel and traffic history.",
scheduleOff: "Off",
scheduleDaily: "Daily",
scheduleWeekly: "Weekly",
scheduleMonthly: "Monthly",
scheduleSaved: "Schedule saved",
scheduleNext: "Next run: {value}",
scheduleDisabled: "Automatic backups are disabled",
backupRestoreStarted: "Restore started",
confirmRestoreBackup: "Restore backup",
loadLogs: "Load",
panelLanguage: "Panel language",
theme: "Theme",
@@ -122,6 +137,7 @@ const i18n = {
keyCreated: "Key created",
keyDeleted: "Key deleted",
backupCreated: "Backup created",
qrUnavailable: "QR code is unavailable",
serviceRestarted: "Service restarted",
statsRepaired: "Collector restarted",
statsCollected: "Statistics collected",
@@ -189,6 +205,8 @@ const i18n = {
promoHosting1: "Hosting #1",
promoHosting2: "Hosting #2",
promoTips: "Tips",
qrEyebrow: "QR import",
qrTitle: "Scan Telegram proxy",
pageDashboardTitle: "Dashboard",
pageDashboardKicker: "Local Admin",
pageTrafficTitle: "Traffic",
@@ -264,6 +282,7 @@ const i18n = {
addKey: "Добавить ключ",
copyLink: "Копировать ссылку",
copySecret: "Копировать секрет",
showQr: "QR",
delete: "Удалить",
enabled: "Включён",
disabled: "Отключён",
@@ -273,6 +292,20 @@ const i18n = {
enableKey: "Включить ключ",
main: "основной",
createBackup: "Создать бекап",
restoreBackup: "Восстановить",
backupScheduleTitle: "Автобекапы",
backupScheduleLoading: "Загрузка расписания...",
backupIncludesTitle: "Что входит в бекап",
backupIncludesText: "конфиг telemt, настройки goTelegram, ключи, отключённые ключи, сайт, шаблоны, SSL-сертификаты, бот, админка и история трафика.",
scheduleOff: "Выкл",
scheduleDaily: "Каждый день",
scheduleWeekly: "Каждую неделю",
scheduleMonthly: "Каждый месяц",
scheduleSaved: "Расписание сохранено",
scheduleNext: "Следующий запуск: {value}",
scheduleDisabled: "Автобекапы отключены",
backupRestoreStarted: "Восстановление запущено",
confirmRestoreBackup: "Восстановить бекап",
loadLogs: "Загрузить",
panelLanguage: "Язык панели",
theme: "Тема",
@@ -322,6 +355,7 @@ const i18n = {
keyCreated: "Ключ создан",
keyDeleted: "Ключ удалён",
backupCreated: "Бекап создан",
qrUnavailable: "QR-код недоступен",
serviceRestarted: "Сервис перезапущен",
statsRepaired: "Сборщик перезапущен",
statsCollected: "Статистика собрана",
@@ -389,6 +423,8 @@ const i18n = {
promoHosting1: "Хостинг #1",
promoHosting2: "Хостинг #2",
promoTips: "Чаевые",
qrEyebrow: "QR-импорт",
qrTitle: "Сканирование прокси Telegram",
pageDashboardTitle: "Обзор",
pageDashboardKicker: "Локальная админка",
pageTrafficTitle: "Трафик",
@@ -420,6 +456,8 @@ const state = {
userTrafficView: "chart",
userTraffic: null,
userTrafficLoading: false,
backupSchedule: null,
qrLink: "",
pendingUsers: new Set(),
};
@@ -514,6 +552,7 @@ function applyI18n() {
$("#visualText").textContent = t("visualText");
updateTrafficControls();
updateUserTrafficControls();
renderBackupSchedule();
updatePageTitle();
}
@@ -1091,7 +1130,12 @@ function renderUsers() {
</div>
</td>
<td data-label="${escapeAttr(t("tableSecret"))}"><code title="${escapeAttr(user.secret)}">${escapeHtml(user.secret)}</code></td>
<td data-label="${escapeAttr(t("tableLink"))}"><button class="soft" data-copy="${escapeAttr(user.link)}" ${user.enabled ? "" : "disabled"}>${escapeHtml(t("copyLink"))}</button></td>
<td data-label="${escapeAttr(t("tableLink"))}">
<div class="mini-actions">
<button class="soft" data-copy="${escapeAttr(user.link)}" ${user.enabled ? "" : "disabled"}>${escapeHtml(t("copyLink"))}</button>
<button class="soft" data-user-qr="${escapeAttr(user.name)}">${escapeHtml(t("showQr"))}</button>
</div>
</td>
<td data-label="${escapeAttr(t("tableTraffic"))}">
<div class="traffic-cell">
<strong>${escapeHtml(trafficTotal)}</strong>
@@ -1109,6 +1153,7 @@ function renderUsers() {
function renderBackups(backups) {
const box = $("#backupsList");
renderBackupSchedule();
if (!backups.length) {
box.innerHTML = `<div class="empty">${escapeHtml(t("noBackups"))}</div>`;
return;
@@ -1119,11 +1164,26 @@ function renderBackups(backups) {
<strong>${escapeHtml(item.name)}</strong>
<span>${escapeHtml(item.path)} · ${escapeHtml(fmtDate(item.mtime))}</span>
</div>
<div>${escapeHtml(fmtBytes(item.size))}${item.encrypted ? ` · ${escapeHtml(t("encrypted"))}` : ""}</div>
<div class="backup-actions">
<span>${escapeHtml(fmtBytes(item.size))}${item.encrypted ? ` · ${escapeHtml(t("encrypted"))}` : ""}</span>
<button class="soft" data-restore-backup="${escapeAttr(item.name)}">${escapeHtml(t("restoreBackup"))}</button>
</div>
</div>
`).join("");
}
function renderBackupSchedule() {
const schedule = state.backupSchedule || state.overview?.backup_schedule || { frequency: "off" };
const frequency = schedule.frequency || "off";
$$("[data-backup-schedule]").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.backupSchedule === frequency);
});
const next = schedule.next && schedule.next !== "n/a" ? schedule.next : "";
$("#backupScheduleMeta").textContent = frequency === "off"
? t("scheduleDisabled")
: t("scheduleNext").replace("{value}", next || (schedule.calendar || "--"));
}
function renderEvents() {
const box = $("#events");
if (!state.events.length) {
@@ -1162,6 +1222,7 @@ async function refreshAll() {
btn.disabled = true;
try {
state.overview = await api("/api/overview");
state.backupSchedule = state.overview.backup_schedule || state.backupSchedule;
updateLanguageFromOverview(state.overview);
state.users = await api("/api/users");
if (!state.stats) {
@@ -1324,6 +1385,53 @@ async function createBackup() {
}
}
async function setBackupSchedule(frequency) {
$$("[data-backup-schedule]").forEach((btn) => { btn.disabled = true; });
try {
const data = await api("/api/backups/schedule", {
method: "POST",
body: JSON.stringify({ frequency }),
});
state.backupSchedule = data.schedule || data;
renderBackupSchedule();
addEvent(t("scheduleSaved"), frequency);
toast(t("scheduleSaved"));
} catch (err) {
toast(err.message);
} finally {
$$("[data-backup-schedule]").forEach((btn) => { btn.disabled = false; });
}
}
async function restoreBackup(name) {
const data = await api("/api/backups/restore", {
method: "POST",
body: JSON.stringify({ name }),
});
addEvent(t("backupRestoreStarted"), data.name || name);
toast(t("backupRestoreStarted"));
setTimeout(() => refreshAll().catch((err) => toast(err.message)), 4000);
}
function showUserQr(name) {
const user = state.users.find((item) => item.name === name);
if (!user) {
toast(t("qrUnavailable"));
return;
}
state.qrLink = user.link || "";
$("#qrTitle").textContent = `${t("qrTitle")} · ${user.name}`;
$("#qrMeta").textContent = user.link || "";
const img = $("#qrImage");
img.alt = `${user.name} Telegram proxy QR`;
img.onerror = () => {
img.removeAttribute("src");
toast(t("qrUnavailable"));
};
img.src = `/api/users/${encodeURIComponent(user.name)}/qr?ts=${Date.now()}`;
$("#qrModal").hidden = false;
}
async function loadLogs() {
const service = $("#logService").value;
const btn = $("#loadLogsBtn");
@@ -1436,11 +1544,18 @@ document.addEventListener("click", async (eventObj) => {
} else if (button.dataset.userTraffic) {
state.userTrafficUser = button.dataset.userTraffic;
refreshUserTraffic({ showLoading: true }).catch((err) => toast(err.message));
} else if (button.dataset.userQr) {
showUserQr(button.dataset.userQr);
} else if (button.dataset.userTrafficRange) {
changeUserTrafficRange(button.dataset.userTrafficRange);
} else if (button.dataset.userTrafficView) {
state.userTrafficView = button.dataset.userTrafficView === "table" ? "table" : "chart";
renderUserTraffic();
} else if (button.dataset.backupSchedule) {
setBackupSchedule(button.dataset.backupSchedule);
} else if (button.dataset.restoreBackup) {
const name = button.dataset.restoreBackup;
if (confirm(`${t("confirmRestoreBackup")} ${name}?`)) restoreBackup(name).catch((err) => toast(err.message));
} else if (button.dataset.copy) {
await copyText(button.dataset.copy);
} else if (button.dataset.delete) {
@@ -1480,6 +1595,12 @@ $("#languageSelect").addEventListener("change", (eventObj) => setLanguage(eventO
$("#promoClose").addEventListener("click", () => {
$("#promoModal").hidden = true;
});
$("#qrClose").addEventListener("click", () => {
$("#qrModal").hidden = true;
});
$("#qrCopyBtn").addEventListener("click", () => {
if (state.qrLink) copyText(state.qrLink);
});
$("#createBackupBtn").addEventListener("click", createBackup);
$("#loadLogsBtn").addEventListener("click", loadLogs);
$("#repairStatsBtn").addEventListener("click", repairStats);