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",
port443Public: "public",
port443Configured: "telemt: {port}",
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: "Проверка порта не удалась",
port443Public: "публичный",
port443Configured: "telemt: {port}",
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",
trafficLoading: false,
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();
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 `
${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 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")}`;
summary.className = "port-status ok";
}
if (!listeners.length) {
list.innerHTML = `${escapeHtml(payload.error || t("port443NoListeners"))}
`;
return;
}
list.innerHTML = 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("");
}
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: 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 = ``;
}
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 renderUsers() {
const tbody = $("#usersTable");
if (!state.users.length) {
tbody.innerHTML = `| ${escapeHtml(t("noKeys"))} |
`;
return;
}
tbody.innerHTML = state.users.map((user) => {
const pending = state.pendingUsers.has(user.name);
return `
|
${escapeHtml(user.name)}${user.main ? ` ${escapeHtml(t("main"))}` : ""}
|
${escapeHtml(pending ? t("applying") : (user.enabled ? t("enabled") : t("disabled")))}
|
${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 ? ` · ${escapeHtml(t("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 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");
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();
if (state.page === "traffic") {
await refreshStats();
}
} 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 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 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 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) {
changeTrafficRange(button.dataset.trafficRange);
} 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();