mirror of
https://github.com/anten-ka/gotelegram_pro.git
synced 2026-05-19 11:26:03 +00:00
1185 lines
43 KiB
JavaScript
1185 lines
43 KiB
JavaScript
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) => ({
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
'"': """,
|
||
"'": "'",
|
||
})[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 `<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();
|