Files
gotelegram_pro/admin-web/static/app.js
Виталий Литвинов 9c74a0d00f v2.5.0: guard web admin log loading race
2026-04-24 22:44:47 +03:00

780 lines
27 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: "Runtime health",
runtimeEyebrow: "Runtime",
runtimeTitle: "telemt summary",
trafficEyebrow: "Traffic",
trafficTitle: "History",
keysEyebrow: "Access",
keysTitle: "User keys",
backupsEyebrow: "Snapshots",
backupsTitle: "Backups",
eventsEyebrow: "Events",
eventsTitle: "Activity",
logsEyebrow: "Journal",
logsTitle: "Logs",
settingsEyebrow: "Settings",
settingsTitle: "Panel preferences",
configEyebrow: "Config",
configTitle: "Installation state",
collector: "Collector",
lastPoint: "Last point",
historyRows: "History rows",
collectStats: "Collect",
repairStats: "Repair stats",
tableTime: "Time",
tableProxyDelta: "Proxy delta",
tableSiteDelta: "Site delta",
tableProxyTotal: "Proxy total",
tableSiteTotal: "Site total",
tableUser: "User",
tableSecret: "Secret",
tableLink: "Link",
tableActions: "Actions",
userPlaceholder: "client-name",
addKey: "Add key",
copyLink: "Copy link",
copySecret: "Copy secret",
delete: "Delete",
main: "main",
createBackup: "Create backup",
loadLogs: "Load",
panelLanguage: "Panel language",
theme: "Theme",
bindAddress: "Bind address",
dashboard: "Dashboard",
noKeys: "No keys yet",
noBackups: "No backups yet",
noEvents: "No events yet",
noHistory: "No traffic history yet",
noRuntime: "Runtime data is not available",
badConnections: "Bad connections",
connections: "Connections",
uptime: "Uptime",
users: "Users",
revision: "Revision",
healthOk: "OK",
healthError: "Error",
healthStale: "Stale",
healthStopped: "Stopped",
healthNotInstalled: "Not installed",
healthUnknown: "Unknown",
statusRunning: "running",
statusInactive: "inactive",
statusStopped: "stopped",
statusFailed: "failed",
statusNotInstalled: "not installed",
statusActivating: "activating",
statusDeactivating: "deactivating",
statusUnknown: "unknown",
statsMissing: "Collector is not running",
statsOk: "Collector is running",
statsStale: "Snapshot is stale",
statsError: "Collector error",
restart: "Restart",
copied: "Copied",
copyFailed: "Copy failed",
keyCreated: "Key created",
keyDeleted: "Key deleted",
backupCreated: "Backup created",
serviceRestarted: "Service restarted",
statsRepaired: "Statistics repaired",
statsCollected: "Statistics collected",
confirmDelete: "Delete key",
confirmRestart: "Restart",
invalidUser: "Use latin letters, digits, _, . or -",
loading: "Loading...",
never: "never",
lightTheme: "Light",
darkTheme: "Dark",
configMode: "Mode",
configDomain: "Domain",
configTemplate: "Template",
configVersion: "Version",
pageDashboardTitle: "Dashboard",
pageDashboardKicker: "Local Admin",
pageTrafficTitle: "Traffic",
pageTrafficKicker: "Statistics",
pageKeysTitle: "Keys",
pageKeysKicker: "Access",
pageBackupsTitle: "Backups",
pageBackupsKicker: "Migration",
pageLogsTitle: "Logs",
pageLogsKicker: "Journal",
pageSettingsTitle: "Settings",
pageSettingsKicker: "Preferences",
},
ru: {
brandSubtitle: "Локальная админка",
navDashboard: "Обзор",
navTraffic: "Трафик",
navKeys: "Ключи",
navBackups: "Бекапы",
navLogs: "Логи",
navSettings: "Настройки",
refresh: "Обновить",
themeDark: "Тёмная",
themeLight: "Светлая",
metricMode: "Режим",
metricKeys: "Ключи",
metricProxyTraffic: "Трафик proxy",
metricSiteTraffic: "Трафик сайта",
configuredUsers: "настроенных пользователей",
packets: "пакетов",
servicesEyebrow: "Сервисы",
servicesTitle: "Состояние runtime",
runtimeEyebrow: "Runtime",
runtimeTitle: "сводка telemt",
trafficEyebrow: "Трафик",
trafficTitle: "История",
keysEyebrow: "Доступ",
keysTitle: "Ключи пользователей",
backupsEyebrow: "Снимки",
backupsTitle: "Бекапы",
eventsEyebrow: "События",
eventsTitle: "Активность",
logsEyebrow: "Журнал",
logsTitle: "Логи",
settingsEyebrow: "Настройки",
settingsTitle: "Параметры панели",
configEyebrow: "Конфиг",
configTitle: "Состояние установки",
collector: "Сборщик",
lastPoint: "Последняя точка",
historyRows: "Строк истории",
collectStats: "Собрать",
repairStats: "Починить статистику",
tableTime: "Время",
tableProxyDelta: "Proxy delta",
tableSiteDelta: "Site delta",
tableProxyTotal: "Proxy всего",
tableSiteTotal: "Site всего",
tableUser: "Пользователь",
tableSecret: "Secret",
tableLink: "Ссылка",
tableActions: "Действия",
userPlaceholder: "client-name",
addKey: "Добавить ключ",
copyLink: "Копировать ссылку",
copySecret: "Копировать secret",
delete: "Удалить",
main: "основной",
createBackup: "Создать бекап",
loadLogs: "Загрузить",
panelLanguage: "Язык панели",
theme: "Тема",
bindAddress: "Адрес bind",
dashboard: "Обзор",
noKeys: "Ключей пока нет",
noBackups: "Бекапов пока нет",
noEvents: "Событий пока нет",
noHistory: "Истории трафика пока нет",
noRuntime: "Runtime-данные недоступны",
badConnections: "Ошибочные подключения",
connections: "Подключения",
uptime: "Аптайм",
users: "Пользователи",
revision: "Ревизия",
healthOk: "OK",
healthError: "Ошибка",
healthStale: "Устарело",
healthStopped: "Остановлено",
healthNotInstalled: "Не установлен",
healthUnknown: "Неизвестно",
statusRunning: "работает",
statusInactive: "неактивен",
statusStopped: "остановлен",
statusFailed: "ошибка",
statusNotInstalled: "не установлен",
statusActivating: "запускается",
statusDeactivating: "останавливается",
statusUnknown: "неизвестно",
statsMissing: "Сборщик не запущен",
statsOk: "Сборщик работает",
statsStale: "Snapshot устарел",
statsError: "Ошибка сборщика",
restart: "Рестарт",
copied: "Скопировано",
copyFailed: "Не удалось скопировать",
keyCreated: "Ключ создан",
keyDeleted: "Ключ удалён",
backupCreated: "Бекап создан",
serviceRestarted: "Сервис перезапущен",
statsRepaired: "Статистика починена",
statsCollected: "Статистика собрана",
confirmDelete: "Удалить ключ",
confirmRestart: "Перезапустить",
invalidUser: "Используйте латиницу, цифры, _, . или -",
loading: "Загрузка...",
never: "никогда",
lightTheme: "Светлая",
darkTheme: "Тёмная",
configMode: "Режим",
configDomain: "Домен",
configTemplate: "Шаблон",
configVersion: "Версия",
pageDashboardTitle: "Обзор",
pageDashboardKicker: "Локальная админка",
pageTrafficTitle: "Трафик",
pageTrafficKicker: "Статистика",
pageKeysTitle: "Ключи",
pageKeysKicker: "Доступ",
pageBackupsTitle: "Бекапы",
pageBackupsKicker: "Переезд",
pageLogsTitle: "Логи",
pageLogsKicker: "Журнал",
pageSettingsTitle: "Настройки",
pageSettingsKicker: "Параметры",
},
};
const state = {
overview: null,
users: [],
events: [],
lang: "en",
page: "dashboard",
theme: document.documentElement.dataset.theme || "light",
};
const t = (key) => (i18n[state.lang] && i18n[state.lang][key]) || i18n.en[key] || key;
const fmtBytes = (value = 0) => {
const units = ["B", "KB", "MB", "GB", "TB"];
let n = Number(value) || 0;
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i += 1;
}
return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
};
const fmtDate = (epoch) => {
if (!epoch) return t("never");
return new Date(epoch * 1000).toLocaleString(state.lang === "ru" ? "ru-RU" : "en-US");
};
const fmtDuration = (seconds = 0) => {
let value = Math.max(0, Math.floor(Number(seconds) || 0));
const days = Math.floor(value / 86400);
value %= 86400;
const hours = Math.floor(value / 3600);
value %= 3600;
const minutes = Math.floor(value / 60);
if (days) return `${days}d ${hours}h`;
if (hours) return `${hours}h ${minutes}m`;
return `${minutes}m`;
};
const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (ch) => ({
"&": "&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);
});
$("#themeToggle").textContent = state.theme === "dark" ? t("themeLight") : t("themeDark");
$("#languageBadge").textContent = state.lang.toUpperCase();
$("#settingsLanguage").textContent = state.lang === "ru" ? "Русский" : "English";
$("#settingsTheme").textContent = state.theme === "dark" ? t("darkTheme") : t("lightTheme");
updatePageTitle();
}
function setTheme(theme) {
state.theme = theme === "dark" ? "dark" : "light";
document.documentElement.dataset.theme = state.theme;
localStorage.setItem("gotelegram-theme", state.theme);
applyI18n();
if (state.overview) drawTrafficChart(state.overview.stats_history || []);
}
function setPage(page, push = true) {
const next = $(`[data-page="${page}"]`) ? page : "dashboard";
state.page = next;
$$(".page-panel").forEach((panel) => panel.classList.toggle("active", panel.dataset.page === next));
$$("[data-nav]").forEach((item) => item.classList.toggle("active", item.dataset.nav === next));
$("#sidebar").classList.remove("open");
updatePageTitle();
if (push && location.hash !== `#${next}`) {
history.replaceState(null, "", `#${next}`);
}
}
function updatePageTitle() {
const cap = state.page.charAt(0).toUpperCase() + state.page.slice(1);
$("#pageTitle").textContent = t(`page${cap}Title`);
$("#pageKicker").textContent = t(`page${cap}Kicker`);
}
function updateLanguageFromOverview(data) {
const lang = String(data.language || data.config?.language || "en").toLowerCase();
state.lang = lang === "ru" ? "ru" : "en";
applyI18n();
}
function statusLabel(status) {
const key = `status${String(status || "unknown").replace(/(^|_)([a-z])/g, (_, __, ch) => ch.toUpperCase())}`;
const label = t(key);
return label === key ? (status || t("healthUnknown")) : label;
}
function healthLabel(health) {
const labels = {
ok: t("healthOk"),
error: t("healthError"),
stale: t("healthStale"),
stopped: t("healthStopped"),
not_installed: t("healthNotInstalled"),
};
return labels[health] || t("healthUnknown");
}
function renderServices(services = {}) {
const items = [
{ key: "telemt", label: "telemt", api: "telemt" },
{ key: "nginx", label: "nginx", api: "nginx" },
{ key: "bot", label: "bot", api: "gotelegram-bot" },
{ key: "stats", label: "stats", api: "gotelegram-stats" },
{ key: "admin", label: "admin", api: "gotelegram-admin" },
];
$("#services").innerHTML = items.map((item) => {
const status = services[item.key] || "unknown";
const disabled = item.key === "admin" || status === "not_installed";
return `<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 renderOverview() {
const data = state.overview;
if (!data) return;
const cfg = data.config || {};
const stats = data.stats_current || {};
const bind = data.admin_bind || {};
$("#sidebarVersion").textContent = `v${data.version || "--"}`;
$("#sidebarBind").textContent = `${bind.host || "127.0.0.1"}:${bind.port || 1984}`;
$("#settingsBind").textContent = `${bind.host || "127.0.0.1"}:${bind.port || 1984}`;
$("#metricMode").textContent = cfg.mode || "--";
$("#metricDomain").textContent = cfg.domain || cfg.mask_host || "--";
$("#metricUsers").textContent = data.users_count ?? 0;
$("#metricProxyTraffic").textContent = fmtBytes(stats.proxy_bytes);
$("#metricProxyPackets").textContent = `${stats.proxy_pkts || 0} ${t("packets")}`;
$("#metricSiteTraffic").textContent = fmtBytes(stats.site_bytes);
$("#metricSitePackets").textContent = `${stats.site_pkts || 0} ${t("packets")}`;
$("#lastRefresh").textContent = fmtDate(Math.floor(Date.now() / 1000));
renderServices(data.services || {});
renderRuntime();
renderStats();
renderBackups(data.backups || []);
renderConfig();
}
function renderStats() {
const status = state.overview?.stats_status || {};
const stats = state.overview?.stats_current || {};
const historyRows = state.overview?.stats_history || [];
$("#statsHealth").className = `status-pill health-${escapeAttr(status.health || "unknown")}`;
$("#statsHealth").textContent = healthLabel(status.health);
$("#collectorState").textContent = status.service ? statusLabel(status.service) : "--";
$("#lastStatsPoint").textContent = status.last_ts ? fmtDate(status.last_ts) : t("never");
$("#historyRows").textContent = status.history_rows ?? historyRows.length;
$("#repairStatsBtn").classList.toggle("attention", status.health !== "ok");
$("#collectStatsBtn").disabled = status.service === "not_installed";
$("#metricProxyTraffic").textContent = fmtBytes(stats.proxy_bytes);
$("#metricSiteTraffic").textContent = fmtBytes(stats.site_bytes);
drawTrafficChart(historyRows);
renderHistoryTable(historyRows);
}
function drawTrafficChart(rows) {
const el = $("#trafficChart");
const points = rows.slice(-120);
const proxyColor = getComputedStyle(document.documentElement).getPropertyValue("--blue").trim() || "#2563eb";
const siteColor = getComputedStyle(document.documentElement).getPropertyValue("--green").trim() || "#0f9f6e";
if (points.length < 2) {
el.innerHTML = `<div class="empty-chart">
<strong>${escapeHtml(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("");
el.innerHTML = `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Traffic history">
<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">max ${escapeHtml(fmtBytes(max))}/min</text>
<text x="${pad.l}" y="${height - 12}" class="legend" fill="${proxyColor}">proxy</text>
<text x="${pad.l + 74}" y="${height - 12}" class="legend" fill="${siteColor}">site</text>
</svg>`;
}
function renderHistoryTable(rows) {
const latest = rows.slice(-12).reverse();
if (!latest.length) {
$("#historyTable").innerHTML = `<tr><td colspan="5" class="empty-cell">${escapeHtml(t("noHistory"))}</td></tr>`;
return;
}
$("#historyTable").innerHTML = latest.map((row) => `
<tr>
<td data-label="${escapeAttr(t("tableTime"))}">${escapeHtml(fmtDate(row.epoch))}</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_bytes))}</td>
<td data-label="${escapeAttr(t("tableSiteTotal"))}">${escapeHtml(fmtBytes(row.site_bytes))}</td>
</tr>
`).join("");
}
function renderUsers() {
const tbody = $("#usersTable");
if (!state.users.length) {
tbody.innerHTML = `<tr><td colspan="4" class="empty-cell">${escapeHtml(t("noKeys"))}</td></tr>`;
return;
}
tbody.innerHTML = state.users.map((user) => `
<tr>
<td data-label="${escapeAttr(t("tableUser"))}">
<strong>${escapeHtml(user.name)}</strong>${user.main ? ` <small>${escapeHtml(t("main"))}</small>` : ""}
</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)}">${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 ? " · 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 items = [
[t("configMode"), cfg.mode || "--"],
[t("configDomain"), cfg.domain || cfg.mask_host || "--"],
[t("configTemplate"), cfg.template_id || cfg.template || "--"],
[t("configVersion"), state.overview?.version || "--"],
[t("bindAddress"), `${state.overview?.admin_bind?.host || "127.0.0.1"}:${state.overview?.admin_bind?.port || 1984}`],
];
$("#configList").innerHTML = items.map(([label, value]) => `
<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");
renderOverview();
renderUsers();
} catch (err) {
toast(err.message);
} finally {
btn.disabled = false;
}
}
async function addUser(name) {
const data = await api("/api/users", {
method: "POST",
body: JSON.stringify({ name }),
});
addEvent(t("keyCreated"), data.name);
toast(t("keyCreated"));
await refreshAll();
}
async function deleteUser(name) {
await api(`/api/users/${encodeURIComponent(name)}`, { method: "DELETE" });
addEvent(t("keyDeleted"), name);
toast(t("keyDeleted"));
await refreshAll();
}
async function createBackup() {
const btn = $("#createBackupBtn");
btn.disabled = true;
try {
const data = await api("/api/backups", { method: "POST", body: "{}" });
addEvent(t("backupCreated"), data.path || "");
toast(t("backupCreated"));
await refreshAll();
} catch (err) {
toast(err.message);
} finally {
btn.disabled = false;
}
}
async function loadLogs() {
const service = $("#logService").value;
const btn = $("#loadLogsBtn");
btn.disabled = true;
$("#logsBox").textContent = t("loading");
try {
const logs = await api(`/api/logs?service=${encodeURIComponent(service)}`);
if ($("#logService").value === service) {
$("#logsBox").textContent = logs;
}
} catch (err) {
$("#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();
} catch (err) {
toast(err.message);
} finally {
btn.disabled = false;
}
}
async function collectStats() {
const btn = $("#collectStatsBtn");
btn.disabled = true;
try {
await api("/api/stats/collect", { method: "POST", body: "{}" });
addEvent(t("statsCollected"));
toast(t("statsCollected"));
await refreshAll();
} catch (err) {
toast(err.message);
} finally {
btn.disabled = false;
}
}
async function copyText(value) {
try {
await navigator.clipboard.writeText(value);
toast(t("copied"));
} catch (_) {
const area = document.createElement("textarea");
area.value = value;
area.setAttribute("readonly", "");
area.style.position = "fixed";
area.style.opacity = "0";
document.body.appendChild(area);
area.select();
const ok = document.execCommand("copy");
area.remove();
toast(ok ? t("copied") : t("copyFailed"));
}
}
document.addEventListener("click", async (eventObj) => {
const nav = eventObj.target.closest("[data-nav]");
if (nav) {
setPage(nav.dataset.nav);
return;
}
const button = eventObj.target.closest("button");
if (!button) return;
if (button.id === "themeToggle") {
setTheme(state.theme === "dark" ? "light" : "dark");
} else if (button.id === "menuBtn") {
$("#sidebar").classList.toggle("open");
} else if (button.dataset.copy) {
await copyText(button.dataset.copy);
} else if (button.dataset.delete) {
const name = button.dataset.delete;
if (confirm(`${t("confirmDelete")} ${name}?`)) deleteUser(name).catch((err) => toast(err.message));
} else if (button.dataset.restart) {
const name = button.dataset.restart;
if (confirm(`${t("confirmRestart")} ${name}?`)) restartService(name).catch((err) => toast(err.message));
}
});
$("#addUserForm").addEventListener("submit", (eventObj) => {
eventObj.preventDefault();
const input = $("#userName");
const name = input.value.trim();
if (!/^[A-Za-z0-9_.-]{1,48}$/.test(name)) {
toast(t("invalidUser"));
return;
}
input.value = "";
addUser(name).catch((err) => toast(err.message));
});
$("#refreshBtn").addEventListener("click", refreshAll);
$("#createBackupBtn").addEventListener("click", createBackup);
$("#loadLogsBtn").addEventListener("click", loadLogs);
$("#repairStatsBtn").addEventListener("click", repairStats);
$("#collectStatsBtn").addEventListener("click", collectStats);
window.addEventListener("hashchange", () => setPage((location.hash || "#dashboard").slice(1), false));
setPage((location.hash || "#dashboard").slice(1), false);
setTheme(state.theme);
renderEvents();
refreshAll();
loadLogs();