Files
gotelegram_pro/admin-web/static/app.js
2026-04-25 12:01:31 +03:00

1185 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
tableActions: "Actions",
userPlaceholder: "client-name",
addKey: "Add key",
copyLink: "Copy link",
copySecret: "Copy secret",
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",
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",
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: "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",
visualTitle: "Port 443 listeners",
visualText: "Actual TCP/UDP listeners on public port 443: telemt, website, Xray/3x-ui, AmneziaWG or another service.",
port443Checked: "checked",
port443NoListeners: "No 443 listeners found",
port443Listeners: "listeners",
port443Error: "Port check failed",
roleMtproxy: "MTProxy",
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",
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: "Ссылка",
tableActions: "Действия",
userPlaceholder: "client-name",
addKey: "Добавить ключ",
copyLink: "Копировать ссылку",
copySecret: "Копировать секрет",
delete: "Удалить",
enabled: "Включён",
disabled: "Отключён",
applying: "Применяется...",
changesApplyInBackground: "Изменения применяются в фоне",
disableKey: "Отключить ключ",
enableKey: "Включить ключ",
main: "основной",
createBackup: "Создать бекап",
loadLogs: "Загрузить",
panelLanguage: "Язык панели",
theme: "Тема",
bindAddress: "Адрес привязки",
dashboard: "Обзор",
noKeys: "Ключей пока нет",
noBackups: "Бекапов пока нет",
noEvents: "Событий пока нет",
noHistory: "Истории трафика пока нет",
noTrafficForRange: "За этот период данных пока нет",
noRuntime: "Данные среды выполнения недоступны",
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: "Бекап создан",
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: "Ключ отключён",
visualTitle: "Кто слушает порт 443",
visualText: "Реальные TCP/UDP-процессы на публичном 443: telemt, сайт, Xray/3x-ui, AmneziaWG или другой сервис.",
port443Checked: "проверено",
port443NoListeners: "Слушателей 443 не найдено",
port443Listeners: "слушателей",
port443Error: "Проверка порта не удалась",
roleMtproxy: "MTProxy",
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: "Чаевые",
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",
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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
})[ch]);
const escapeAttr = (value) => escapeHtml(value).replace(/`/g, "&#096;");
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();
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();
}
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}`);
}
if (next === "traffic") {
refreshStats().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 `<article class="service status-${escapeAttr(status)}">
<div>
<strong>${escapeHtml(item.label)}</strong>
<span><i></i>${escapeHtml(statusLabel(status))}</span>
</div>
<button class="soft" data-restart="${escapeAttr(item.api)}" ${disabled ? "disabled" : ""}>${escapeHtml(t("restart"))}</button>
</article>`;
}).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 = `<div class="empty">${escapeHtml(t("noRuntime"))}</div>`;
$("#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]) => `
<article>
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(value)}</strong>
</article>
`).join("");
const bad = Array.isArray(data.connections_bad_by_class) ? data.connections_bad_by_class : [];
$("#runtimeIssues").innerHTML = bad.length ? bad.map((item) => `
<div class="issue">
<span>${escapeHtml(item.class || "unknown")}</span>
<strong>${escapeHtml(item.total ?? 0)}</strong>
</div>
`).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 summary = $("#port443Summary");
const list = $("#port443List");
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")}`;
summary.className = "port-status ok";
}
if (!listeners.length) {
list.innerHTML = `<div class="port-empty">${escapeHtml(payload.error || t("port443NoListeners"))}</div>`;
return;
}
list.innerHTML = listeners.map((item) => {
const title = `${item.proto || ""} ${item.address || ""} · ${item.process || "unknown"}${item.pid ? ` · pid ${item.pid}` : ""}`;
return `<article class="port-listener role-${escapeAttr(item.role || "other")}" title="${escapeAttr(title)}">
<div>
<strong>${escapeHtml(roleLabel(item.role))}</strong>
<span>${escapeHtml(item.process || "unknown")}${item.pid ? ` · pid ${escapeHtml(item.pid)}` : ""}</span>
</div>
<small>${escapeHtml(item.proto || "--")} · ${escapeHtml(item.address || "--")}</small>
</article>`;
}).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 || "--";
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 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: Math.max(0, (Number(last.proxy_bytes) || 0) - (Number(first.proxy_bytes) || 0)),
site_delta: Math.max(0, (Number(last.site_bytes) || 0) - (Number(first.site_bytes) || 0)),
proxy_total: Number(last.proxy_bytes) || 0,
site_total: Number(last.site_bytes) || 0,
};
});
}
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();
$("#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 = `<div class="empty-chart">
<strong>${escapeHtml(points.length ? t("noTrafficForRange") : t("noHistory"))}</strong>
<span>${escapeHtml(state.overview?.stats_status?.health === "ok" ? t("statsOk") : t("statsMissing"))}</span>
</div>`;
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 `<line x1="${pad.l}" y1="${y}" x2="${width - pad.r}" y2="${y}"></line>`;
}).join("");
const axis = t("chartMax").replace("{value}", fmtBytes(max));
el.innerHTML = `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeAttr(t("ariaTrafficHistory"))}">
<g class="grid">${grid}</g>
<path class="area proxy-area" d="${pathFor("proxy_delta")} L${width - pad.r},${height - pad.b} L${pad.l},${height - pad.b} Z"></path>
<path class="line proxy-line" d="${pathFor("proxy_delta")}"></path>
<path class="line site-line" d="${pathFor("site_delta")}"></path>
<text x="${pad.l}" y="17" class="axis">${escapeHtml(axis)}</text>
<text x="${pad.l}" y="${height - 12}" class="legend" fill="${proxyColor}">${escapeHtml(t("chartProxy"))}</text>
<text x="${pad.l + 86}" y="${height - 12}" class="legend" fill="${siteColor}">${escapeHtml(t("chartSite"))}</text>
</svg>`;
}
function renderHistoryTable(rows) {
if (!rows.length) {
$("#historyTable").innerHTML = `<tr><td colspan="5" class="empty-cell">${escapeHtml(t("noHistory"))}</td></tr>`;
return;
}
$("#historyTable").innerHTML = rows.map((row) => `
<tr>
<td data-label="${escapeAttr(t("tablePeriod"))}"><strong>${escapeHtml(trafficRangeLabel(row.range))}</strong><small>${escapeHtml(row.points ? `${row.points} ${t("historyRows").toLowerCase()}` : t("noTrafficForRange"))}</small></td>
<td data-label="${escapeAttr(t("tableProxyDelta"))}">${escapeHtml(fmtBytes(row.proxy_delta))}</td>
<td data-label="${escapeAttr(t("tableSiteDelta"))}">${escapeHtml(fmtBytes(row.site_delta))}</td>
<td data-label="${escapeAttr(t("tableProxyTotal"))}">${escapeHtml(fmtBytes(row.proxy_total))}</td>
<td data-label="${escapeAttr(t("tableSiteTotal"))}">${escapeHtml(fmtBytes(row.site_total))}</td>
</tr>
`).join("");
}
function renderUsers() {
const tbody = $("#usersTable");
if (!state.users.length) {
tbody.innerHTML = `<tr><td colspan="5" class="empty-cell">${escapeHtml(t("noKeys"))}</td></tr>`;
return;
}
tbody.innerHTML = state.users.map((user) => {
const pending = state.pendingUsers.has(user.name);
return `
<tr class="${user.enabled ? "" : "disabled-row"} ${pending ? "pending-row" : ""}">
<td data-label="${escapeAttr(t("tableUser"))}">
<strong>${escapeHtml(user.name)}</strong>${user.main ? ` <small>${escapeHtml(t("main"))}</small>` : ""}
</td>
<td data-label="${escapeAttr(t("tableStatus"))}">
<div class="status-control">
<label class="switch" title="${escapeAttr(user.main ? t("main") : (user.enabled ? t("disableKey") : t("enableKey")))}">
<input type="checkbox" data-toggle-user="${escapeAttr(user.name)}" ${user.enabled ? "checked" : ""} ${user.main || pending ? "disabled" : ""}>
<span></span>
</label>
<strong class="${user.enabled ? "state-on" : "state-off"}">${escapeHtml(pending ? t("applying") : (user.enabled ? t("enabled") : t("disabled")))}</strong>
</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("tableActions"))}" class="actions">
<button class="soft" data-copy="${escapeAttr(user.secret)}">${escapeHtml(t("copySecret"))}</button>
<button class="danger" data-delete="${escapeAttr(user.name)}" ${user.main ? "disabled" : ""}>${escapeHtml(t("delete"))}</button>
</td>
</tr>
`; }).join("");
}
function renderBackups(backups) {
const box = $("#backupsList");
if (!backups.length) {
box.innerHTML = `<div class="empty">${escapeHtml(t("noBackups"))}</div>`;
return;
}
box.innerHTML = backups.map((item) => `
<div class="backup-item">
<div>
<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>
`).join("");
}
function renderEvents() {
const box = $("#events");
if (!state.events.length) {
box.innerHTML = `<div class="empty">${escapeHtml(t("noEvents"))}</div>`;
return;
}
box.innerHTML = state.events.map((item) => `
<div class="event">
<strong>${escapeHtml(item.title)}</strong>
<small>${escapeHtml(item.detail || item.time.toLocaleTimeString())}</small>
</div>
`).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]) => `
<div>
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(value)}</strong>
</div>
`).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");
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();
} catch (err) {
toast(err.message);
} finally {
btn.disabled = false;
}
}
async function refreshStats() {
const data = await api(`/api/stats?range=${encodeURIComponent(state.trafficRange)}`);
state.stats = data;
renderStats();
}
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"));
setTimeout(() => refreshAll().catch((err) => toast(err.message)), 1200);
} catch (err) {
state.users = previousUsers;
toast(err.message);
} finally {
setTimeout(() => {
state.pendingUsers.delete(name);
renderUsers();
}, 700);
}
}
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;
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) return;
if (button.id === "themeToggle") {
setTheme(state.theme === "dark" ? "light" : "dark");
} else if (button.id === "menuBtn") {
$("#sidebar").classList.toggle("open");
} else if (button.dataset.trafficRange) {
state.trafficRange = trafficRanges.includes(button.dataset.trafficRange) ? button.dataset.trafficRange : "1h";
updateTrafficControls();
renderStats();
refreshStats().catch((err) => toast(err.message));
} else if (button.dataset.trafficView) {
state.trafficView = button.dataset.trafficView === "table" ? "table" : "chart";
renderStats();
} 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));
}
});
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));
});
$("#refreshBtn").addEventListener("click", refreshAll);
$("#languageSelect").addEventListener("change", (eventObj) => setLanguage(eventObj.target.value));
$("#promoClose").addEventListener("click", () => {
$("#promoModal").hidden = true;
});
$("#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();