const $ = (sel) => document.querySelector(sel); const $$ = (sel) => Array.from(document.querySelectorAll(sel)); const i18n = { en: { brandSubtitle: "Local Admin", navDashboard: "Dashboard", navTraffic: "Traffic", navKeys: "Keys", navBackups: "Backups", navLogs: "Logs", navSettings: "Settings", refresh: "Refresh", themeDark: "Dark", themeLight: "Light", metricMode: "Mode", metricKeys: "Keys", metricProxyTraffic: "Proxy traffic", metricSiteTraffic: "Site traffic", configuredUsers: "configured users", packets: "packets", servicesEyebrow: "Services", servicesTitle: "Service health", servicesHelp: "Systemd service status for telemt, nginx, the bot, the traffic collector and the local admin.", runtimeEyebrow: "Runtime", runtimeTitle: "telemt summary", runtimeHelp: "Runtime data comes from the local telemt API and shows what the proxy engine sees right now.", trafficEyebrow: "Traffic", trafficTitle: "History", keysEyebrow: "Access", keysTitle: "User keys", backupsEyebrow: "Snapshots", backupsTitle: "Backups", eventsEyebrow: "Events", eventsTitle: "Activity", logsEyebrow: "Journal", logsTitle: "Logs", settingsEyebrow: "Settings", settingsTitle: "Panel preferences", configEyebrow: "Config", configTitle: "Installation state", collector: "Collector", lastPoint: "Last point", historyRows: "History rows", collectStats: "Update stats", collectStatsHelp: "Run one traffic collection now.", repairStats: "Restart collector", repairStatsHelp: "Reinstall and restart the background service that writes traffic history.", tableTime: "Time", tablePeriod: "Period", tableStatus: "Status", tableProxyDelta: "Proxy delta", tableSiteDelta: "Site delta", tableProxyTotal: "Proxy total", tableSiteTotal: "Site total", tableUser: "User", tableSecret: "Secret", tableLink: "Link", tableTraffic: "Traffic", ipLimit: "IP limit", ipLimitHint: "0 = unlimited", saveIpLimit: "OK", tableTrafficDelta: "Traffic delta", tableTrafficTotal: "Total", tableActions: "Actions", userPlaceholder: "client-name", addKey: "Add key", copyLink: "Copy link", copySecret: "Copy secret", showQr: "QR", delete: "Delete", enabled: "Enabled", disabled: "Disabled", applying: "Applying...", changesApplyInBackground: "Changes are being applied in the background", disableKey: "Disable key", enableKey: "Enable key", main: "main", createBackup: "Create backup", restoreBackup: "Restore", encryptedRestoreCli: "Encrypted backups are restored from CLI", 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", bindAddress: "Bind address", dashboard: "Dashboard", noKeys: "No keys yet", noBackups: "No backups yet", noEvents: "No events yet", noHistory: "No traffic history yet", noTrafficForRange: "No data for this range yet", noRuntime: "Runtime data is not available", userTrafficEyebrow: "Per user", userTrafficTitle: "User traffic", selectUserTraffic: "Select a key to see its traffic history", openStats: "Stats", trafficTotal: "Total", currentConnections: "Connections", activeIps: "Active IPs", recentIps: "Recent IPs", trafficRuntimeUnavailable: "Runtime unavailable", badConnections: "Bad connections", connections: "Connections", uptime: "Uptime", users: "Users", revision: "Revision", healthOk: "OK", healthError: "Error", healthStale: "Stale", healthStopped: "Stopped", healthNotInstalled: "Not installed", healthUnknown: "Unknown", statusRunning: "running", statusInactive: "inactive", statusStopped: "stopped", statusFailed: "failed", statusNotInstalled: "not installed", statusActivating: "activating", statusDeactivating: "deactivating", statusUnknown: "unknown", statsMissing: "Collector is not running", statsOk: "Collector is running", statsStale: "Snapshot is stale", statsError: "Collector error", restart: "Restart", copied: "Copied", copyFailed: "Copy failed", keyCreated: "Key created", keyDeleted: "Key deleted", backupCreated: "Backup created", qrUnavailable: "QR code is unavailable", serviceRestarted: "Service restarted", statsRepaired: "Collector restarted", statsCollected: "Statistics collected", confirmDelete: "Delete key", confirmRestart: "Restart", invalidUser: "Use latin letters, digits, _, . or -", loading: "Loading...", never: "never", lightTheme: "Light", darkTheme: "Dark", configMode: "Mode", configDomain: "Domain", configSiteStatus: "Site check", configTemplate: "Template", configVersion: "Version", siteOk: "Site 200 OK", siteHttp: "Site HTTP", siteMissing: "Domain is not configured", siteInvalid: "Invalid domain", siteError: "Site check failed", siteNotChecked: "Site check pending", logsLines: "lines", logsNoData: "No log lines", languageSaved: "Language saved", keyEnabled: "Key enabled", keyDisabled: "Key disabled", ipLimitSaved: "IP limit saved", visualTitle: "Port 443 map", visualText: "Shows the public 443 listener and services routed behind it, including the website on local nginx.", port443Checked: "checked", port443NoListeners: "No 443 listeners found", port443Listeners: "listeners", port443Routes: "routed", port443Error: "Port check failed", port443Public: "public", port443Configured: "telemt: {port}", port443PublicSection: "Public 443", port443BehindSection: "Behind 443", port443NoRoutes: "No routed services detected", port443Via: "via {value}", roleMtproxy: "MTProxy", roleEdge: "443 Edge", roleSite: "Website", roleXray: "Xray / 3x-ui", roleAmneziawg: "AmneziaWG", roleOther: "Other", range15m: "15 min", range1h: "1 hour", range24h: "24 hours", rangeMonth: "Month", viewChart: "Chart", viewRows: "Rows", chartMax: "max {value} per interval", chartProxy: "proxy", chartSite: "site", encrypted: "encrypted", ariaAdminSections: "Admin sections", ariaMenu: "Open menu", ariaLanguage: "Language", ariaClose: "Close", ariaTrafficHistory: "Traffic history", ariaTrafficRange: "Traffic range", ariaTrafficView: "Traffic view", promoEyebrow: "Promo", promoTitle: "Support goTelegram Pro", promoHosting1: "Hosting #1", promoHosting2: "Hosting #2", promoTips: "Tips", qrEyebrow: "QR import", qrTitle: "Scan Telegram proxy", pageDashboardTitle: "Dashboard", pageDashboardKicker: "Local Admin", pageTrafficTitle: "Traffic", pageTrafficKicker: "Statistics", pageKeysTitle: "Keys", pageKeysKicker: "Access", pageBackupsTitle: "Backups", pageBackupsKicker: "Migration", pageLogsTitle: "Logs", pageLogsKicker: "Journal", pageSettingsTitle: "Settings", pageSettingsKicker: "Preferences", }, ru: { brandSubtitle: "Локальная админка", navDashboard: "Обзор", navTraffic: "Трафик", navKeys: "Ключи", navBackups: "Бекапы", navLogs: "Логи", navSettings: "Настройки", refresh: "Обновить", themeDark: "Тёмная", themeLight: "Светлая", metricMode: "Режим", metricKeys: "Ключи", metricProxyTraffic: "Трафик прокси", metricSiteTraffic: "Трафик сайта", configuredUsers: "настроенных пользователей", packets: "пакетов", servicesEyebrow: "Сервисы", servicesTitle: "Состояние служб", servicesHelp: "Статус systemd-служб: telemt, nginx, бот, сборщик трафика и локальная админка.", runtimeEyebrow: "Среда выполнения", runtimeTitle: "Сводка telemt", runtimeHelp: "Данные среды выполнения берутся из локального API telemt и показывают, что ядро прокси видит прямо сейчас.", trafficEyebrow: "Трафик", trafficTitle: "История", keysEyebrow: "Доступ", keysTitle: "Ключи пользователей", backupsEyebrow: "Снимки", backupsTitle: "Бекапы", eventsEyebrow: "События", eventsTitle: "Активность", logsEyebrow: "Журнал", logsTitle: "Логи", settingsEyebrow: "Настройки", settingsTitle: "Параметры панели", configEyebrow: "Конфиг", configTitle: "Состояние установки", collector: "Сборщик", lastPoint: "Последняя точка", historyRows: "Строк истории", collectStats: "Обновить статистику", collectStatsHelp: "Запустить один сбор трафика прямо сейчас.", repairStats: "Перезапустить сборщик", repairStatsHelp: "Переустановить и перезапустить фоновую службу, которая пишет историю трафика.", tableTime: "Время", tablePeriod: "Период", tableStatus: "Статус", tableProxyDelta: "Прирост прокси", tableSiteDelta: "Прирост сайта", tableProxyTotal: "Всего прокси", tableSiteTotal: "Всего по сайту", tableUser: "Пользователь", tableSecret: "Секрет", tableLink: "Ссылка", tableTraffic: "Трафик", ipLimit: "Лимит IP", ipLimitHint: "0 = безлимит", saveIpLimit: "OK", tableTrafficDelta: "Прирост трафика", tableTrafficTotal: "Всего", tableActions: "Действия", userPlaceholder: "client-name", addKey: "Добавить ключ", copyLink: "Копировать ссылку", copySecret: "Копировать секрет", showQr: "QR", delete: "Удалить", enabled: "Включён", disabled: "Отключён", applying: "Применяется...", changesApplyInBackground: "Изменения применяются в фоне", disableKey: "Отключить ключ", enableKey: "Включить ключ", main: "основной", createBackup: "Создать бекап", restoreBackup: "Восстановить", encryptedRestoreCli: "Зашифрованные бекапы восстанавливаются через CLI", backupScheduleTitle: "Автобекапы", backupScheduleLoading: "Загрузка расписания...", backupIncludesTitle: "Что входит в бекап", backupIncludesText: "конфиг telemt, настройки goTelegram, ключи, отключённые ключи, сайт, шаблоны, SSL-сертификаты, бот, админка и история трафика.", scheduleOff: "Выкл", scheduleDaily: "Каждый день", scheduleWeekly: "Каждую неделю", scheduleMonthly: "Каждый месяц", scheduleSaved: "Расписание сохранено", scheduleNext: "Следующий запуск: {value}", scheduleDisabled: "Автобекапы отключены", backupRestoreStarted: "Восстановление запущено", confirmRestoreBackup: "Восстановить бекап", loadLogs: "Загрузить", panelLanguage: "Язык панели", theme: "Тема", bindAddress: "Адрес привязки", dashboard: "Обзор", noKeys: "Ключей пока нет", noBackups: "Бекапов пока нет", noEvents: "Событий пока нет", noHistory: "Истории трафика пока нет", noTrafficForRange: "За этот период данных пока нет", noRuntime: "Данные среды выполнения недоступны", userTrafficEyebrow: "По пользователю", userTrafficTitle: "Трафик ключа", selectUserTraffic: "Выберите ключ, чтобы увидеть историю трафика", openStats: "Статистика", trafficTotal: "Всего", currentConnections: "Подключения", activeIps: "Активные IP", recentIps: "Недавние IP", trafficRuntimeUnavailable: "Runtime недоступен", badConnections: "Ошибочные подключения", connections: "Подключения", uptime: "Аптайм", users: "Пользователи", revision: "Ревизия", healthOk: "OK", healthError: "Ошибка", healthStale: "Устарело", healthStopped: "Остановлено", healthNotInstalled: "Не установлен", healthUnknown: "Неизвестно", statusRunning: "работает", statusInactive: "неактивен", statusStopped: "остановлен", statusFailed: "ошибка", statusNotInstalled: "не установлен", statusActivating: "запускается", statusDeactivating: "останавливается", statusUnknown: "неизвестно", statsMissing: "Сборщик не запущен", statsOk: "Сборщик работает", statsStale: "Снимок устарел", statsError: "Ошибка сборщика", restart: "Перезапустить", copied: "Скопировано", copyFailed: "Не удалось скопировать", keyCreated: "Ключ создан", keyDeleted: "Ключ удалён", backupCreated: "Бекап создан", qrUnavailable: "QR-код недоступен", serviceRestarted: "Сервис перезапущен", statsRepaired: "Сборщик перезапущен", statsCollected: "Статистика собрана", confirmDelete: "Удалить ключ", confirmRestart: "Перезапустить", invalidUser: "Используйте латиницу, цифры, _, . или -", loading: "Загрузка...", never: "никогда", lightTheme: "Светлая", darkTheme: "Тёмная", configMode: "Режим", configDomain: "Домен", configSiteStatus: "Проверка сайта", configTemplate: "Шаблон", configVersion: "Версия", siteOk: "Сайт 200 OK", siteHttp: "Сайт HTTP", siteMissing: "Домен не настроен", siteInvalid: "Некорректный домен", siteError: "Проверка сайта не прошла", siteNotChecked: "Проверка сайта ожидает", logsLines: "строк", logsNoData: "Строк логов нет", languageSaved: "Язык сохранён", keyEnabled: "Ключ включён", keyDisabled: "Ключ отключён", ipLimitSaved: "Лимит IP сохранён", visualTitle: "Карта порта 443", visualText: "Показывает публичного слушателя 443 и сервисы, которые живут за ним, включая сайт на локальном nginx.", port443Checked: "проверено", port443NoListeners: "Слушателей 443 не найдено", port443Listeners: "слушателей", port443Routes: "за 443", port443Error: "Проверка порта не удалась", port443Public: "публичный", port443Configured: "telemt: {port}", port443PublicSection: "Публичный 443", port443BehindSection: "За портом 443", port443NoRoutes: "Маршрутизируемых сервисов не найдено", port443Via: "через {value}", roleMtproxy: "MTProxy", roleEdge: "443 Edge", roleSite: "Сайт", roleXray: "Xray / 3x-ui", roleAmneziawg: "AmneziaWG", roleOther: "Другое", range15m: "15 мин", range1h: "1 час", range24h: "24 часа", rangeMonth: "Месяц", viewChart: "График", viewRows: "Строки", chartMax: "макс. {value} за интервал", chartProxy: "прокси", chartSite: "сайт", encrypted: "зашифровано", ariaAdminSections: "Разделы админки", ariaMenu: "Открыть меню", ariaLanguage: "Язык", ariaClose: "Закрыть", ariaTrafficHistory: "История трафика", ariaTrafficRange: "Период трафика", ariaTrafficView: "Вид трафика", promoEyebrow: "Промо", promoTitle: "Поддержать goTelegram Pro", promoHosting1: "Хостинг #1", promoHosting2: "Хостинг #2", promoTips: "Чаевые", qrEyebrow: "QR-импорт", qrTitle: "Сканирование прокси Telegram", pageDashboardTitle: "Обзор", pageDashboardKicker: "Локальная админка", pageTrafficTitle: "Трафик", pageTrafficKicker: "Статистика", pageKeysTitle: "Ключи", pageKeysKicker: "Доступ", pageBackupsTitle: "Бекапы", pageBackupsKicker: "Переезд", pageLogsTitle: "Логи", pageLogsKicker: "Журнал", pageSettingsTitle: "Настройки", pageSettingsKicker: "Параметры", }, }; const state = { overview: null, stats: null, users: [], events: [], lang: "en", page: "dashboard", theme: document.documentElement.dataset.theme || "light", trafficRange: "1h", trafficView: "chart", trafficLoading: false, userTrafficUser: "", userTrafficRange: "1h", userTrafficView: "chart", userTraffic: null, userTrafficLoading: false, backupSchedule: null, qrLink: "", pendingUsers: new Set(), }; const t = (key) => (i18n[state.lang] && i18n[state.lang][key]) || i18n.en[key] || key; const trafficRanges = ["15m", "1h", "24h", "month"]; const fmtBytes = (value = 0) => { const units = ["B", "KB", "MB", "GB", "TB"]; let n = Number(value) || 0; let i = 0; while (n >= 1024 && i < units.length - 1) { n /= 1024; i += 1; } return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; }; const fmtDate = (epoch) => { if (!epoch) return t("never"); return new Date(epoch * 1000).toLocaleString(state.lang === "ru" ? "ru-RU" : "en-US"); }; const fmtDuration = (seconds = 0) => { let value = Math.max(0, Math.floor(Number(seconds) || 0)); const days = Math.floor(value / 86400); value %= 86400; const hours = Math.floor(value / 3600); value %= 3600; const minutes = Math.floor(value / 60); if (days) return `${days}d ${hours}h`; if (hours) return `${hours}h ${minutes}m`; return `${minutes}m`; }; const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[ch]); const escapeAttr = (value) => escapeHtml(value).replace(/`/g, "`"); const toast = (message) => { const el = $("#toast"); el.textContent = message; el.classList.add("show"); clearTimeout(toast._timer); toast._timer = setTimeout(() => el.classList.remove("show"), 2800); }; const addEvent = (title, detail = "") => { state.events.unshift({ title, detail, time: new Date() }); state.events = state.events.slice(0, 10); renderEvents(); }; async function api(path, options = {}) { const headers = { "Accept": "application/json", "X-GoTelegram-Admin": "1", ...(options.headers || {}), }; if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json"; const res = await fetch(path, { ...options, headers, credentials: "same-origin" }); const data = await res.json().catch(() => ({})); if (!res.ok || data.ok === false) throw new Error(data.error || `HTTP ${res.status}`); return data.data ?? data; } function applyI18n() { document.documentElement.lang = state.lang; $$("[data-i18n]").forEach((el) => { el.textContent = t(el.dataset.i18n); }); $$("[data-i18n-placeholder]").forEach((el) => { el.placeholder = t(el.dataset.i18nPlaceholder); }); $$("[data-i18n-title]").forEach((el) => { el.title = t(el.dataset.i18nTitle); }); $$("[data-i18n-aria-label]").forEach((el) => { el.setAttribute("aria-label", t(el.dataset.i18nAriaLabel)); }); $("#themeToggle").textContent = state.theme === "dark" ? t("themeLight") : t("themeDark"); $("#languageSelect").value = state.lang; $("#settingsLanguage").textContent = state.lang === "ru" ? "Русский" : "English"; $("#settingsTheme").textContent = state.theme === "dark" ? t("darkTheme") : t("lightTheme"); $("#visualTitle").textContent = t("visualTitle"); $("#visualText").textContent = t("visualText"); updateTrafficControls(); updateUserTrafficControls(); renderBackupSchedule(); updatePageTitle(); } function setTheme(theme) { state.theme = theme === "dark" ? "dark" : "light"; document.documentElement.dataset.theme = state.theme; localStorage.setItem("gotelegram-theme", state.theme); applyI18n(); if (state.overview) renderStats(); if (state.userTraffic) renderUserTraffic(); } async function setLanguage(lang) { const previous = state.lang; state.lang = lang === "ru" ? "ru" : "en"; applyI18n(); try { const data = await api("/api/settings/language", { method: "POST", body: JSON.stringify({ language: state.lang }), }); state.lang = data.language === "ru" ? "ru" : "en"; applyI18n(); toast(t("languageSaved")); await refreshAll(); } catch (err) { state.lang = previous; applyI18n(); toast(err.message); } } function setPage(page, push = true) { const next = $(`[data-page="${page}"]`) ? page : "dashboard"; state.page = next; $$(".page-panel").forEach((panel) => panel.classList.toggle("active", panel.dataset.page === next)); $$("[data-nav]").forEach((item) => item.classList.toggle("active", item.dataset.nav === next)); $("#sidebar").classList.remove("open"); updatePageTitle(); if (push && location.hash !== `#${next}`) { history.replaceState(null, "", `#${next}`); } requestAnimationFrame(() => { window.scrollTo({ top: 0, behavior: push ? "smooth" : "auto" }); }); if (next === "traffic") { refreshStats().catch((err) => toast(err.message)); } else if (next === "keys") { ensureUserTrafficSelection(); renderUserTraffic(); if (state.userTrafficUser) refreshUserTraffic().catch((err) => toast(err.message)); } } function updatePageTitle() { const cap = state.page.charAt(0).toUpperCase() + state.page.slice(1); $("#pageTitle").textContent = t(`page${cap}Title`); $("#pageKicker").textContent = t(`page${cap}Kicker`); } function updateLanguageFromOverview(data) { const lang = String(data.language || data.config?.language || "en").toLowerCase(); state.lang = lang === "ru" ? "ru" : "en"; applyI18n(); } function statusLabel(status) { const key = `status${String(status || "unknown").replace(/(^|_)([a-z])/g, (_, __, ch) => ch.toUpperCase())}`; const label = t(key); return label === key ? (status || t("healthUnknown")) : label; } function healthLabel(health) { const labels = { ok: t("healthOk"), error: t("healthError"), stale: t("healthStale"), stopped: t("healthStopped"), not_installed: t("healthNotInstalled"), }; return labels[health] || t("healthUnknown"); } function renderServices(services = {}) { const items = [ { key: "telemt", label: "telemt", api: "telemt" }, { key: "nginx", label: "nginx", api: "nginx" }, { key: "bot", label: "bot", api: "gotelegram-bot" }, { key: "stats", label: "stats", api: "gotelegram-stats" }, { key: "admin", label: "admin", api: "gotelegram-admin" }, ]; $("#services").innerHTML = items.map((item) => { const status = services[item.key] || "unknown"; const disabled = item.key === "admin" || status === "not_installed"; return `
${escapeHtml(item.label)} ${escapeHtml(statusLabel(status))}
`; }).join(""); } function runtimeData() { const raw = state.overview?.runtime_summary; if (!raw || typeof raw !== "object") return null; return raw.data && typeof raw.data === "object" ? raw.data : raw; } function renderRuntime() { const data = runtimeData(); if (!data) { $("#runtimeCards").innerHTML = `
${escapeHtml(t("noRuntime"))}
`; $("#runtimeIssues").innerHTML = ""; return; } const revision = String(data.revision || state.overview?.runtime_summary?.revision || "--"); const cards = [ [t("uptime"), fmtDuration(data.uptime_seconds)], [t("connections"), data.connections_total ?? 0], [t("badConnections"), data.connections_bad_total ?? 0], [t("users"), data.configured_users ?? state.overview?.users_count ?? 0], [t("revision"), revision.slice(0, 10)], ]; $("#runtimeCards").innerHTML = cards.map(([label, value]) => `
${escapeHtml(label)} ${escapeHtml(value)}
`).join(""); const bad = Array.isArray(data.connections_bad_by_class) ? data.connections_bad_by_class : []; $("#runtimeIssues").innerHTML = bad.length ? bad.map((item) => `
${escapeHtml(item.class || "unknown")} ${escapeHtml(item.total ?? 0)}
`).join("") : ""; } function siteStatusText(site = {}) { if (!site.host) return t("siteMissing"); if (site.error === "invalid_domain") return t("siteInvalid"); if (site.ok) return t("siteOk"); if (site.checked && site.http_code) return `${t("siteHttp")} ${site.http_code}`; if (site.error) return t("siteError"); return t("siteNotChecked"); } function siteStatusClass(site = {}) { if (site.ok) return "ok"; if (!site.host || !site.checked) return "warn"; return "error"; } function renderSiteStatus() { const cfg = state.overview?.config || {}; const site = state.overview?.site_status || {}; $("#metricDomain").textContent = site.host || cfg.domain || cfg.mask_host || "--"; const statusEl = $("#siteStatus"); statusEl.textContent = siteStatusText(site); statusEl.className = `metric-status ${siteStatusClass(site)}`; statusEl.title = site.url || ""; } function roleLabel(role) { const key = `role${String(role || "other").replace(/(^|_)([a-z])/g, (_, __, ch) => ch.toUpperCase())}`; const label = t(key); return label === key ? t("roleOther") : label; } function renderPort443(payload = {}) { const listeners = Array.isArray(payload.listeners) ? payload.listeners : []; const routes = Array.isArray(payload.routes) ? payload.routes : []; const summary = $("#port443Summary"); const list = $("#port443List"); const configuredPort = Number(payload.configured_port) || 443; $("#port443Number").textContent = "443"; $("#port443Configured").textContent = configuredPort === 443 ? t("port443Public") : t("port443Configured").replace("{port}", configuredPort); if (payload.error) { summary.textContent = t("port443Error"); summary.className = "port-status error"; } else if (!listeners.length) { summary.textContent = t("port443NoListeners"); summary.className = "port-status warn"; } else { summary.textContent = `${listeners.length} ${t("port443Listeners")}${routes.length ? ` · ${routes.length} ${t("port443Routes")}` : ""}`; summary.className = "port-status ok"; } const listenerHtml = listeners.length ? listeners.map((item) => { const title = `${item.proto || ""} ${item.address || ""} · ${item.process || "unknown"}${item.pid ? ` · pid ${item.pid}` : ""}`; return `
${escapeHtml(roleLabel(item.role))} ${escapeHtml(item.process || "unknown")}${item.pid ? ` · pid ${escapeHtml(item.pid)}` : ""}
${escapeHtml(item.proto || "--")} · ${escapeHtml(item.address || "--")}
`; }).join("") : `
${escapeHtml(payload.error || t("port443NoListeners"))}
`; const routeHtml = routes.length ? routes.map((item) => { const via = item.via ? t("port443Via").replace("{value}", item.via) : ""; const title = `${item.public || ""} → ${item.target || ""} · ${item.process || ""}`; return `
${escapeHtml(roleLabel(item.role))} ${escapeHtml(item.process || "unknown")}${item.status ? ` · ${escapeHtml(statusLabel(item.status))}` : ""}
${escapeHtml(item.public || "--")} → ${escapeHtml(item.target || "--")}${via ? ` · ${escapeHtml(via)}` : ""}
`; }).join("") : `
${escapeHtml(t("port443NoRoutes"))}
`; list.innerHTML = `
${escapeHtml(t("port443PublicSection"))}
${listenerHtml}
${escapeHtml(t("port443BehindSection"))}
${routeHtml} `; } function renderOverview() { const data = state.overview; if (!data) return; const cfg = data.config || {}; const stats = data.stats_current || {}; const bind = data.admin_bind || {}; $("#sidebarVersion").textContent = `v${data.version || "--"}`; $("#sidebarBind").textContent = `${bind.host || "127.0.0.1"}:${bind.port || 1984}`; $("#settingsBind").textContent = `${bind.host || "127.0.0.1"}:${bind.port || 1984}`; $("#metricMode").textContent = cfg.mode || "--"; renderSiteStatus(); renderPort443(data.port_443 || {}); $("#metricUsers").textContent = data.users_count ?? 0; $("#metricProxyTraffic").textContent = fmtBytes(stats.proxy_bytes); $("#metricProxyPackets").textContent = `${stats.proxy_pkts || 0} ${t("packets")}`; $("#metricSiteTraffic").textContent = fmtBytes(stats.site_bytes); $("#metricSitePackets").textContent = `${stats.site_pkts || 0} ${t("packets")}`; $("#lastRefresh").textContent = fmtDate(Math.floor(Date.now() / 1000)); renderServices(data.services || {}); renderRuntime(); renderStats(); renderBackups(data.backups || []); renderConfig(); } function statsPayload() { if (state.stats) return state.stats; return { current: state.overview?.stats_current || {}, history: state.overview?.stats_history || [], status: state.overview?.stats_status || {}, summary_rows: [], }; } function updateTrafficControls() { $$("[data-traffic-range]").forEach((btn) => { btn.classList.toggle("active", btn.dataset.trafficRange === state.trafficRange); }); $$("[data-traffic-view]").forEach((btn) => { btn.classList.toggle("active", btn.dataset.trafficView === state.trafficView); }); } function updateUserTrafficControls() { $$("[data-user-traffic-range]").forEach((btn) => { btn.classList.toggle("active", btn.dataset.userTrafficRange === state.userTrafficRange); }); $$("[data-user-traffic-view]").forEach((btn) => { btn.classList.toggle("active", btn.dataset.userTrafficView === state.userTrafficView); }); } function trafficRangeLabel(range) { const labels = { "15m": t("range15m"), "1h": t("range1h"), "24h": t("range24h"), month: t("rangeMonth"), }; return labels[range] || range; } function rangeSeconds(range) { return { "15m": 15 * 60, "1h": 60 * 60, "24h": 24 * 60 * 60, month: 30 * 24 * 60 * 60, }[range] || 60 * 60; } function filterTrafficRows(rows, range = state.trafficRange) { if (!Array.isArray(rows) || !rows.length) return []; const latest = Math.max(...rows.map((row) => Number(row.epoch) || 0)); const cutoff = latest - rangeSeconds(range); return rows.filter((row) => (Number(row.epoch) || 0) >= cutoff); } function bucketTrafficRows(rows) { const filtered = filterTrafficRows(rows); if (filtered.length <= 140) return filtered; const chunk = Math.ceil(filtered.length / 120); const buckets = []; for (let i = 0; i < filtered.length; i += chunk) { const slice = filtered.slice(i, i + chunk); const last = slice[slice.length - 1]; buckets.push({ epoch: last.epoch, proxy_delta: slice.reduce((sum, item) => sum + (Number(item.proxy_delta) || 0), 0), site_delta: slice.reduce((sum, item) => sum + (Number(item.site_delta) || 0), 0), proxy_bytes: last.proxy_bytes, site_bytes: last.site_bytes, }); } return buckets; } function fallbackTrafficSummaries(rows) { return trafficRanges.map((range) => { const windowRows = filterTrafficRows(rows, range); if (!windowRows.length) { return { range, points: 0, proxy_delta: 0, site_delta: 0, proxy_total: 0, site_total: 0 }; } const first = windowRows[0]; const last = windowRows[windowRows.length - 1]; return { range, points: windowRows.length, proxy_delta: windowRows.reduce((sum, item) => sum + Math.max(0, Number(item.proxy_delta) || 0), 0), site_delta: windowRows.reduce((sum, item) => sum + Math.max(0, Number(item.site_delta) || 0), 0), proxy_total: Number(last.proxy_bytes) || 0, site_total: Number(last.site_bytes) || 0, }; }); } function renderTrafficLoading() { $("#trafficChart").classList.toggle("is-hidden", state.trafficView !== "chart"); $("#trafficTableWrap").classList.toggle("is-hidden", state.trafficView !== "table"); $("#trafficChart").innerHTML = `
${escapeHtml(t("loading"))}
`; $("#historyTable").innerHTML = `${escapeHtml(t("loading"))}`; } function renderStats() { const payload = statsPayload(); const status = payload.status || {}; const stats = payload.current || {}; const historyRows = payload.history || []; const summaryRows = payload.summary_rows?.length ? payload.summary_rows : fallbackTrafficSummaries(historyRows); $("#statsHealth").className = `status-pill health-${escapeAttr(status.health || "unknown")}`; $("#statsHealth").textContent = healthLabel(status.health); $("#collectorState").textContent = status.service ? statusLabel(status.service) : "--"; $("#lastStatsPoint").textContent = status.last_ts ? fmtDate(status.last_ts) : t("never"); $("#historyRows").textContent = status.history_rows ?? historyRows.length; $("#repairStatsBtn").classList.toggle("attention", status.health !== "ok"); $("#collectStatsBtn").disabled = status.service === "not_installed"; $("#metricProxyTraffic").textContent = fmtBytes(stats.proxy_bytes); $("#metricSiteTraffic").textContent = fmtBytes(stats.site_bytes); updateTrafficControls(); if (state.trafficLoading) { renderTrafficLoading(); return; } $("#trafficChart").classList.toggle("is-hidden", state.trafficView !== "chart"); $("#trafficTableWrap").classList.toggle("is-hidden", state.trafficView !== "table"); drawTrafficChart(historyRows); renderHistoryTable(summaryRows); } function drawTrafficChart(rows) { const el = $("#trafficChart"); const points = bucketTrafficRows(rows); const proxyColor = getComputedStyle(document.documentElement).getPropertyValue("--blue").trim() || "#2563eb"; const siteColor = getComputedStyle(document.documentElement).getPropertyValue("--green").trim() || "#0f9f6e"; if (points.length < 2) { el.innerHTML = `
${escapeHtml(points.length ? t("noTrafficForRange") : t("noHistory"))} ${escapeHtml(state.overview?.stats_status?.health === "ok" ? t("statsOk") : t("statsMissing"))}
`; return; } const width = 900; const height = 300; const pad = { l: 54, r: 22, t: 24, b: 42 }; const max = Math.max(1, ...points.map((p) => Math.max(p.proxy_delta || 0, p.site_delta || 0))); const plotW = width - pad.l - pad.r; const plotH = height - pad.t - pad.b; const toX = (i) => pad.l + (plotW * i) / Math.max(1, points.length - 1); const toY = (v) => pad.t + plotH - ((v || 0) / max) * plotH; const pathFor = (key) => points.map((p, i) => `${i === 0 ? "M" : "L"}${toX(i).toFixed(1)},${toY(p[key]).toFixed(1)}`).join(" "); const grid = Array.from({ length: 5 }, (_, i) => { const y = pad.t + (plotH / 4) * i; return ``; }).join(""); const axis = t("chartMax").replace("{value}", fmtBytes(max)); el.innerHTML = ` ${grid} ${escapeHtml(axis)} ${escapeHtml(t("chartProxy"))} ${escapeHtml(t("chartSite"))} `; } function renderHistoryTable(rows) { if (!rows.length) { $("#historyTable").innerHTML = `${escapeHtml(t("noHistory"))}`; return; } $("#historyTable").innerHTML = rows.map((row) => ` ${escapeHtml(trafficRangeLabel(row.range))}${escapeHtml(row.points ? `${row.points} ${t("historyRows").toLowerCase()}` : t("noTrafficForRange"))} ${escapeHtml(fmtBytes(row.proxy_delta))} ${escapeHtml(fmtBytes(row.site_delta))} ${escapeHtml(fmtBytes(row.proxy_total))} ${escapeHtml(fmtBytes(row.site_total))} `).join(""); } function ensureUserTrafficSelection() { if (state.userTrafficUser && state.users.some((user) => user.name === state.userTrafficUser)) return; state.userTrafficUser = state.users[0]?.name || ""; } async function selectUserTraffic(name, options = {}) { const next = String(name || ""); if (!next || !state.users.some((user) => user.name === next)) return; const changed = state.userTrafficUser !== next; state.userTrafficUser = next; if (changed) { state.userTraffic = null; } renderUsers(); renderUserTraffic(); if (options.scroll) { $("#userTrafficPanel")?.scrollIntoView({ behavior: "smooth", block: "start" }); } try { await refreshUserTraffic({ showLoading: true }); } catch (err) { toast(err.message); } } function userTrafficRows() { return state.userTraffic?.history || []; } function bucketUserTrafficRows(rows) { const filtered = filterTrafficRows(rows, state.userTrafficRange); if (filtered.length <= 140) return filtered; const chunk = Math.ceil(filtered.length / 120); const buckets = []; for (let i = 0; i < filtered.length; i += chunk) { const slice = filtered.slice(i, i + chunk); const last = slice[slice.length - 1]; buckets.push({ epoch: last.epoch, total_delta: slice.reduce((sum, item) => sum + (Number(item.total_delta) || 0), 0), total_octets: last.total_octets, current_connections: last.current_connections, active_unique_ips: last.active_unique_ips, }); } return buckets; } function fallbackUserTrafficSummaries(rows) { return trafficRanges.map((range) => { const windowRows = filterTrafficRows(rows, range); if (!windowRows.length) { return { range, points: 0, total_delta: 0, total_octets: 0 }; } const last = windowRows[windowRows.length - 1]; return { range, points: windowRows.length, total_delta: windowRows.reduce((sum, item) => sum + Math.max(0, Number(item.total_delta) || 0), 0), total_octets: Number(last.total_octets) || 0, }; }); } function renderUserTrafficLoading() { $("#userTrafficChart").classList.toggle("is-hidden", state.userTrafficView !== "chart"); $("#userTrafficTableWrap").classList.toggle("is-hidden", state.userTrafficView !== "table"); $("#userTrafficChart").innerHTML = `
${escapeHtml(t("loading"))}
`; $("#userTrafficTable").innerHTML = `${escapeHtml(t("loading"))}`; } function drawUserTrafficChart(rows) { const el = $("#userTrafficChart"); const points = bucketUserTrafficRows(rows); const color = getComputedStyle(document.documentElement).getPropertyValue("--blue").trim() || "#2563eb"; if (points.length < 2) { el.innerHTML = `
${escapeHtml(state.userTrafficUser ? t("noTrafficForRange") : t("selectUserTraffic"))} ${escapeHtml(state.userTraffic?.status?.runtime_ok ? t("statsOk") : t("trafficRuntimeUnavailable"))}
`; return; } const width = 900; const height = 260; const pad = { l: 54, r: 22, t: 24, b: 42 }; const max = Math.max(1, ...points.map((p) => Number(p.total_delta) || 0)); const plotW = width - pad.l - pad.r; const plotH = height - pad.t - pad.b; const toX = (i) => pad.l + (plotW * i) / Math.max(1, points.length - 1); const toY = (v) => pad.t + plotH - ((v || 0) / max) * plotH; const path = points.map((p, i) => `${i === 0 ? "M" : "L"}${toX(i).toFixed(1)},${toY(p.total_delta).toFixed(1)}`).join(" "); const grid = Array.from({ length: 5 }, (_, i) => { const y = pad.t + (plotH / 4) * i; return ``; }).join(""); const axis = t("chartMax").replace("{value}", fmtBytes(max)); el.innerHTML = ` ${grid} ${escapeHtml(axis)} ${escapeHtml(state.userTrafficUser || t("users"))} `; } function renderUserTrafficTable(rows) { if (!rows.length) { $("#userTrafficTable").innerHTML = `${escapeHtml(t("noHistory"))}`; return; } $("#userTrafficTable").innerHTML = rows.map((row) => ` ${escapeHtml(trafficRangeLabel(row.range))}${escapeHtml(row.points ? `${row.points} ${t("historyRows").toLowerCase()}` : t("noTrafficForRange"))} ${escapeHtml(fmtBytes(row.total_delta))} ${escapeHtml(fmtBytes(row.total_octets))} `).join(""); } function renderUserTraffic() { updateUserTrafficControls(); if (!state.userTrafficUser) { $("#userTrafficTitle").textContent = t("userTrafficTitle"); $("#userTrafficHealth").className = "status-pill health-unknown"; $("#userTrafficHealth").textContent = "--"; $("#userTrafficTotal").textContent = "--"; $("#userTrafficConnections").textContent = "--"; $("#userTrafficIps").textContent = "--"; $("#userTrafficChart").innerHTML = `
${escapeHtml(t("selectUserTraffic"))}
`; $("#userTrafficTable").innerHTML = `${escapeHtml(t("selectUserTraffic"))}`; return; } $("#userTrafficTitle").textContent = `${t("userTrafficTitle")}: ${state.userTrafficUser}`; if (state.userTrafficLoading) { renderUserTrafficLoading(); return; } const payload = state.userTraffic || {}; const current = payload.current || {}; const rows = userTrafficRows(); const last = rows[rows.length - 1] || {}; const total = Number(current.total_octets) || Number(last.total_octets) || 0; $("#userTrafficHealth").className = `status-pill ${current.enabled === false ? "health-stopped" : (current.ok ? "health-ok" : "health-stale")}`; $("#userTrafficHealth").textContent = current.enabled === false ? t("disabled") : (current.ok ? t("healthOk") : t("trafficRuntimeUnavailable")); $("#userTrafficTotal").textContent = fmtBytes(total); $("#userTrafficConnections").textContent = current.current_connections ?? last.current_connections ?? 0; $("#userTrafficIps").textContent = current.active_unique_ips ?? last.active_unique_ips ?? 0; $("#userTrafficChart").classList.toggle("is-hidden", state.userTrafficView !== "chart"); $("#userTrafficTableWrap").classList.toggle("is-hidden", state.userTrafficView !== "table"); drawUserTrafficChart(rows); renderUserTrafficTable(payload.summary_rows?.length ? payload.summary_rows : fallbackUserTrafficSummaries(rows)); } function renderUsers() { const container = $("#usersTable"); if (!state.users.length) { container.innerHTML = `
${escapeHtml(t("noKeys"))}
`; return; } container.innerHTML = state.users.map((user) => { const pending = state.pendingUsers.has(user.name); const selected = user.name === state.userTrafficUser; const traffic = user.traffic || {}; const trafficTotal = Number(traffic.total_octets) ? fmtBytes(traffic.total_octets) : "--"; const activeIps = Number(traffic.active_unique_ips) || 0; const maxUniqueIps = Number.isFinite(Number(user.max_unique_ips)) ? Math.max(0, Number(user.max_unique_ips)) : 0; return `
${escapeHtml(t("tableUser"))}
${escapeHtml(pending ? t("applying") : (user.enabled ? t("enabled") : t("disabled")))}
${escapeHtml(t("tableSecret"))} ${escapeHtml(user.secret)}
${escapeHtml(t("tableTraffic"))}
${escapeHtml(trafficTotal)} ${escapeHtml(activeIps ? `${activeIps} ${t("activeIps")}` : fmtDate(traffic.epoch))}
${escapeHtml(t("ipLimit"))}
${escapeHtml(t("tableActions"))}
`; }).join(""); } function renderBackups(backups) { const box = $("#backupsList"); renderBackupSchedule(); if (!backups.length) { box.innerHTML = `
${escapeHtml(t("noBackups"))}
`; return; } box.innerHTML = backups.map((item) => `
${escapeHtml(item.name)} ${escapeHtml(item.path)} · ${escapeHtml(fmtDate(item.mtime))}
${escapeHtml(fmtBytes(item.size))}${item.encrypted ? ` · ${escapeHtml(t("encrypted"))}` : ""}
`).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) { box.innerHTML = `
${escapeHtml(t("noEvents"))}
`; return; } box.innerHTML = state.events.map((item) => `
${escapeHtml(item.title)} ${escapeHtml(item.detail || item.time.toLocaleTimeString())}
`).join(""); } function renderConfig() { const cfg = state.overview?.config || {}; const site = state.overview?.site_status || {}; const items = [ [t("configMode"), cfg.mode || "--"], [t("configDomain"), cfg.domain || cfg.mask_host || "--"], [t("configSiteStatus"), siteStatusText(site)], [t("configTemplate"), cfg.template_id || cfg.template || "--"], [t("configVersion"), state.overview?.version || "--"], [t("bindAddress"), `${state.overview?.admin_bind?.host || "127.0.0.1"}:${state.overview?.admin_bind?.port || 1984}`], ]; $("#configList").innerHTML = items.map(([label, value]) => `
${escapeHtml(label)} ${escapeHtml(value)}
`).join(""); } async function refreshAll() { const btn = $("#refreshBtn"); 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"); ensureUserTrafficSelection(); if (!state.stats) { state.stats = { current: state.overview.stats_current || {}, history: state.overview.stats_history || [], status: state.overview.stats_status || {}, summary_rows: [], }; } else { state.stats = { ...state.stats, current: state.overview.stats_current || state.stats.current || {}, status: state.overview.stats_status || state.stats.status || {}, }; } renderOverview(); renderUsers(); if (state.page === "traffic") { await refreshStats(); } else if (state.page === "keys") { ensureUserTrafficSelection(); await refreshUserTraffic(); } } catch (err) { toast(err.message); } finally { btn.disabled = false; } } async function refreshUsers() { state.users = await api("/api/users"); renderUsers(); } async function refreshStats(options = {}) { if (options.showLoading) { state.trafficLoading = true; renderStats(); } try { const data = await api(`/api/stats?range=${encodeURIComponent(state.trafficRange)}`); state.stats = data; return data; } finally { state.trafficLoading = false; renderStats(); } } async function refreshUserTraffic(options = {}) { ensureUserTrafficSelection(); if (!state.userTrafficUser) { renderUserTraffic(); return null; } if (options.showLoading) { state.userTrafficLoading = true; renderUserTraffic(); } try { const data = await api(`/api/users/${encodeURIComponent(state.userTrafficUser)}/traffic?range=${encodeURIComponent(state.userTrafficRange)}`); state.userTraffic = data; return data; } finally { state.userTrafficLoading = false; renderUserTraffic(); } } async function changeTrafficRange(range) { const next = trafficRanges.includes(range) ? range : "1h"; if (next === state.trafficRange && state.stats?.range === next) return; const previous = state.trafficRange; state.trafficRange = next; try { await refreshStats({ showLoading: true }); } catch (err) { state.trafficRange = previous; state.trafficLoading = false; renderStats(); toast(err.message); } } async function changeUserTrafficRange(range) { const next = trafficRanges.includes(range) ? range : "1h"; if (next === state.userTrafficRange && state.userTraffic?.range === next) return; const previous = state.userTrafficRange; state.userTrafficRange = next; try { await refreshUserTraffic({ showLoading: true }); } catch (err) { state.userTrafficRange = previous; state.userTrafficLoading = false; renderUserTraffic(); toast(err.message); } } async function addUser(name) { const data = await api("/api/users", { method: "POST", body: JSON.stringify({ name }), }); addEvent(t("keyCreated"), data.name); toast(t("keyCreated")); await refreshAll(); } async function deleteUser(name) { await api(`/api/users/${encodeURIComponent(name)}`, { method: "DELETE" }); addEvent(t("keyDeleted"), name); toast(t("keyDeleted")); await refreshAll(); } async function setUserEnabled(name, enabled) { const previousUsers = state.users.map((user) => ({ ...user })); state.pendingUsers.add(name); state.users = state.users.map((user) => user.name === name ? { ...user, enabled } : user); renderUsers(); try { const data = await api(`/api/users/${encodeURIComponent(name)}/enabled`, { method: "POST", body: JSON.stringify({ enabled }), }); state.users = state.users.map((user) => user.name === name ? { ...user, enabled: data.enabled } : user); const message = data.enabled ? t("keyEnabled") : t("keyDisabled"); addEvent(message, name); toast(t("changesApplyInBackground")); try { await refreshUsers(); } catch (refreshErr) { toast(refreshErr.message); } setTimeout(() => refreshAll().catch((err) => toast(err.message)), 1400); } catch (err) { state.users = previousUsers; toast(err.message); } finally { state.pendingUsers.delete(name); renderUsers(); } } async function setUserMaxUniqueIps(name, value) { const limit = Number.parseInt(value, 10); if (!Number.isFinite(limit) || limit < 0 || limit > 1000000) { toast(t("ipLimitHint")); return; } const form = $$("[data-ip-limit-form]").find((item) => item.dataset.ipLimitForm === name); const controls = form ? Array.from(form.querySelectorAll("input, button")) : []; controls.forEach((control) => { control.disabled = true; }); try { const data = await api(`/api/users/${encodeURIComponent(name)}/max-ips`, { method: "POST", body: JSON.stringify({ max_unique_ips: limit }), }); state.users = state.users.map((user) => user.name === name ? { ...user, max_unique_ips: data.max_unique_ips } : user); renderUsers(); addEvent(t("ipLimitSaved"), `${name}: ${data.max_unique_ips}`); toast(t("changesApplyInBackground")); setTimeout(() => refreshAll().catch((err) => toast(err.message)), 1400); } catch (err) { toast(err.message); } finally { controls.forEach((control) => { control.disabled = false; }); } } async function createBackup() { const btn = $("#createBackupBtn"); btn.disabled = true; try { const data = await api("/api/backups", { method: "POST", body: "{}" }); addEvent(t("backupCreated"), data.path || ""); toast(t("backupCreated")); await refreshAll(); } catch (err) { toast(err.message); } finally { btn.disabled = false; } } 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"); btn.disabled = true; $("#logsMeta").textContent = ""; $("#logsBox").textContent = t("loading"); try { const payload = await api(`/api/logs?service=${encodeURIComponent(service)}`); if ($("#logService").value === service) { const structured = payload && typeof payload === "object"; const text = typeof payload === "string" ? payload : (payload?.text || ""); const lines = structured ? (payload.line_count ?? text.split("\n").filter(Boolean).length) : text.split("\n").filter(Boolean).length; const stateText = structured ? (payload.ok ? "OK" : `exit ${payload.exit_code ?? "?"}`) : "OK"; $("#logsMeta").textContent = `${service} · ${lines} ${t("logsLines")} · ${stateText}`; $("#logsBox").textContent = text || t("logsNoData"); } } catch (err) { $("#logsMeta").textContent = ""; $("#logsBox").textContent = err.message; } finally { btn.disabled = false; } } async function restartService(name) { await api(`/api/services/${encodeURIComponent(name)}/restart`, { method: "POST", body: "{}" }); addEvent(t("serviceRestarted"), name); toast(`${name} ${t("serviceRestarted").toLowerCase()}`); await refreshAll(); } async function repairStats() { const btn = $("#repairStatsBtn"); btn.disabled = true; try { await api("/api/stats/repair", { method: "POST", body: "{}" }); addEvent(t("statsRepaired")); toast(t("statsRepaired")); await refreshAll(); await refreshStats(); } catch (err) { toast(err.message); } finally { btn.disabled = false; } } async function collectStats() { const btn = $("#collectStatsBtn"); btn.disabled = true; try { await api("/api/stats/collect", { method: "POST", body: "{}" }); addEvent(t("statsCollected")); toast(t("statsCollected")); await refreshAll(); await refreshStats(); } catch (err) { toast(err.message); } finally { btn.disabled = false; } } async function copyText(value) { try { await navigator.clipboard.writeText(value); toast(t("copied")); } catch (_) { const area = document.createElement("textarea"); area.value = value; area.setAttribute("readonly", ""); area.style.position = "fixed"; area.style.opacity = "0"; document.body.appendChild(area); area.select(); const ok = document.execCommand("copy"); area.remove(); toast(ok ? t("copied") : t("copyFailed")); } } function maybeShowPromo() { const key = "gotelegram-promo-last"; const now = Math.floor(Date.now() / 1000); const last = Number(localStorage.getItem(key) || 0); if (now - last < 86400) return; localStorage.setItem(key, String(now)); $("#promoModal").hidden = false; } document.addEventListener("click", async (eventObj) => { const nav = eventObj.target.closest("[data-nav]"); if (nav) { setPage(nav.dataset.nav); return; } const button = eventObj.target.closest("button"); if (button) { if (button.id === "themeToggle") { setTheme(state.theme === "dark" ? "light" : "dark"); } else if (button.id === "menuBtn") { $("#sidebar").classList.toggle("open"); } else if (button.dataset.trafficRange) { changeTrafficRange(button.dataset.trafficRange); } else if (button.dataset.trafficView) { state.trafficView = button.dataset.trafficView === "table" ? "table" : "chart"; renderStats(); } else if (button.dataset.userTraffic) { selectUserTraffic(button.dataset.userTraffic, { scroll: true }); } 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) { const name = button.dataset.delete; if (confirm(`${t("confirmDelete")} ${name}?`)) deleteUser(name).catch((err) => toast(err.message)); } else if (button.dataset.restart) { const name = button.dataset.restart; if (confirm(`${t("confirmRestart")} ${name}?`)) restartService(name).catch((err) => toast(err.message)); } return; } if (eventObj.target.closest("input, select, textarea, label, form")) return; const row = eventObj.target.closest("[data-select-user-traffic]"); if (!row) return; selectUserTraffic(row.dataset.selectUserTraffic, { scroll: true }); }); document.addEventListener("change", (eventObj) => { const input = eventObj.target.closest("[data-toggle-user]"); if (!input) return; input.disabled = true; setUserEnabled(input.dataset.toggleUser, input.checked).catch((err) => { input.checked = !input.checked; input.disabled = false; toast(err.message); }); }); $("#addUserForm").addEventListener("submit", (eventObj) => { eventObj.preventDefault(); const input = $("#userName"); const name = input.value.trim(); if (!/^[A-Za-z0-9_.-]{1,48}$/.test(name)) { toast(t("invalidUser")); return; } input.value = ""; addUser(name).catch((err) => toast(err.message)); }); document.addEventListener("submit", (eventObj) => { const form = eventObj.target.closest("[data-ip-limit-form]"); if (!form) return; eventObj.preventDefault(); const input = form.querySelector("[data-ip-limit-input]"); setUserMaxUniqueIps(form.dataset.ipLimitForm, input?.value || "0"); }); $("#refreshBtn").addEventListener("click", refreshAll); $("#languageSelect").addEventListener("change", (eventObj) => setLanguage(eventObj.target.value)); $("#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); $("#collectStatsBtn").addEventListener("click", collectStats); window.addEventListener("hashchange", () => setPage((location.hash || "#dashboard").slice(1), false)); setPage((location.hash || "#dashboard").slice(1), false); setTheme(state.theme); renderEvents(); refreshAll(); loadLogs(); maybeShowPromo();