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: "Runtime health", runtimeEyebrow: "Runtime", runtimeTitle: "telemt summary", 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: "Collect", repairStats: "Repair stats", tableTime: "Time", tableProxyDelta: "Proxy delta", tableSiteDelta: "Site delta", tableProxyTotal: "Proxy total", tableSiteTotal: "Site total", tableUser: "User", tableSecret: "Secret", tableLink: "Link", tableActions: "Actions", userPlaceholder: "client-name", addKey: "Add key", copyLink: "Copy link", copySecret: "Copy secret", delete: "Delete", main: "main", createBackup: "Create 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", noRuntime: "Runtime data is not available", 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", serviceRestarted: "Service restarted", statsRepaired: "Statistics repaired", 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", configTemplate: "Template", configVersion: "Version", 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: "Трафик proxy", metricSiteTraffic: "Трафик сайта", configuredUsers: "настроенных пользователей", packets: "пакетов", servicesEyebrow: "Сервисы", servicesTitle: "Состояние runtime", runtimeEyebrow: "Runtime", runtimeTitle: "сводка telemt", trafficEyebrow: "Трафик", trafficTitle: "История", keysEyebrow: "Доступ", keysTitle: "Ключи пользователей", backupsEyebrow: "Снимки", backupsTitle: "Бекапы", eventsEyebrow: "События", eventsTitle: "Активность", logsEyebrow: "Журнал", logsTitle: "Логи", settingsEyebrow: "Настройки", settingsTitle: "Параметры панели", configEyebrow: "Конфиг", configTitle: "Состояние установки", collector: "Сборщик", lastPoint: "Последняя точка", historyRows: "Строк истории", collectStats: "Собрать", repairStats: "Починить статистику", tableTime: "Время", tableProxyDelta: "Proxy delta", tableSiteDelta: "Site delta", tableProxyTotal: "Proxy всего", tableSiteTotal: "Site всего", tableUser: "Пользователь", tableSecret: "Secret", tableLink: "Ссылка", tableActions: "Действия", userPlaceholder: "client-name", addKey: "Добавить ключ", copyLink: "Копировать ссылку", copySecret: "Копировать secret", delete: "Удалить", main: "основной", createBackup: "Создать бекап", loadLogs: "Загрузить", panelLanguage: "Язык панели", theme: "Тема", bindAddress: "Адрес bind", dashboard: "Обзор", noKeys: "Ключей пока нет", noBackups: "Бекапов пока нет", noEvents: "Событий пока нет", noHistory: "Истории трафика пока нет", noRuntime: "Runtime-данные недоступны", badConnections: "Ошибочные подключения", connections: "Подключения", uptime: "Аптайм", users: "Пользователи", revision: "Ревизия", healthOk: "OK", healthError: "Ошибка", healthStale: "Устарело", healthStopped: "Остановлено", healthNotInstalled: "Не установлен", healthUnknown: "Неизвестно", statusRunning: "работает", statusInactive: "неактивен", statusStopped: "остановлен", statusFailed: "ошибка", statusNotInstalled: "не установлен", statusActivating: "запускается", statusDeactivating: "останавливается", statusUnknown: "неизвестно", statsMissing: "Сборщик не запущен", statsOk: "Сборщик работает", statsStale: "Snapshot устарел", statsError: "Ошибка сборщика", restart: "Рестарт", copied: "Скопировано", copyFailed: "Не удалось скопировать", keyCreated: "Ключ создан", keyDeleted: "Ключ удалён", backupCreated: "Бекап создан", serviceRestarted: "Сервис перезапущен", statsRepaired: "Статистика починена", statsCollected: "Статистика собрана", confirmDelete: "Удалить ключ", confirmRestart: "Перезапустить", invalidUser: "Используйте латиницу, цифры, _, . или -", loading: "Загрузка...", never: "никогда", lightTheme: "Светлая", darkTheme: "Тёмная", configMode: "Режим", configDomain: "Домен", configTemplate: "Шаблон", configVersion: "Версия", pageDashboardTitle: "Обзор", pageDashboardKicker: "Локальная админка", pageTrafficTitle: "Трафик", pageTrafficKicker: "Статистика", pageKeysTitle: "Ключи", pageKeysKicker: "Доступ", pageBackupsTitle: "Бекапы", pageBackupsKicker: "Переезд", pageLogsTitle: "Логи", pageLogsKicker: "Журнал", pageSettingsTitle: "Настройки", pageSettingsKicker: "Параметры", }, }; const state = { overview: null, users: [], events: [], lang: "en", page: "dashboard", theme: document.documentElement.dataset.theme || "light", }; const t = (key) => (i18n[state.lang] && i18n[state.lang][key]) || i18n.en[key] || key; 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); }); $("#themeToggle").textContent = state.theme === "dark" ? t("themeLight") : t("themeDark"); $("#languageBadge").textContent = state.lang.toUpperCase(); $("#settingsLanguage").textContent = state.lang === "ru" ? "Русский" : "English"; $("#settingsTheme").textContent = state.theme === "dark" ? t("darkTheme") : t("lightTheme"); 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) drawTrafficChart(state.overview.stats_history || []); } 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}`); } } 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 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 || "--"; $("#metricDomain").textContent = cfg.domain || cfg.mask_host || "--"; $("#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 renderStats() { const status = state.overview?.stats_status || {}; const stats = state.overview?.stats_current || {}; const historyRows = state.overview?.stats_history || []; $("#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); drawTrafficChart(historyRows); renderHistoryTable(historyRows); } function drawTrafficChart(rows) { const el = $("#trafficChart"); const points = rows.slice(-120); 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(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(""); el.innerHTML = ` ${grid} max ${escapeHtml(fmtBytes(max))}/min proxy site `; } function renderHistoryTable(rows) { const latest = rows.slice(-12).reverse(); if (!latest.length) { $("#historyTable").innerHTML = `${escapeHtml(t("noHistory"))}`; return; } $("#historyTable").innerHTML = latest.map((row) => ` ${escapeHtml(fmtDate(row.epoch))} ${escapeHtml(fmtBytes(row.proxy_delta))} ${escapeHtml(fmtBytes(row.site_delta))} ${escapeHtml(fmtBytes(row.proxy_bytes))} ${escapeHtml(fmtBytes(row.site_bytes))} `).join(""); } function renderUsers() { const tbody = $("#usersTable"); if (!state.users.length) { tbody.innerHTML = `${escapeHtml(t("noKeys"))}`; return; } tbody.innerHTML = state.users.map((user) => ` ${escapeHtml(user.name)}${user.main ? ` ${escapeHtml(t("main"))}` : ""} ${escapeHtml(user.secret)} `).join(""); } function renderBackups(backups) { const box = $("#backupsList"); 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 ? " · encrypted" : ""}
`).join(""); } 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 items = [ [t("configMode"), cfg.mode || "--"], [t("configDomain"), cfg.domain || cfg.mask_host || "--"], [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"); updateLanguageFromOverview(state.overview); state.users = await api("/api/users"); renderOverview(); renderUsers(); } catch (err) { toast(err.message); } finally { btn.disabled = false; } } 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 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 loadLogs() { const service = $("#logService").value; $("#logsBox").textContent = t("loading"); try { $("#logsBox").textContent = await api(`/api/logs?service=${encodeURIComponent(service)}`); } catch (err) { $("#logsBox").textContent = err.message; } } 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(); } 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(); } 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")); } } 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) return; if (button.id === "themeToggle") { setTheme(state.theme === "dark" ? "light" : "dark"); } else if (button.id === "menuBtn") { $("#sidebar").classList.toggle("open"); } 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)); } }); $("#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)); }); $("#refreshBtn").addEventListener("click", refreshAll); $("#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();