mirror of
https://github.com/anten-ka/gotelegram_pro.git
synced 2026-05-19 14:36:05 +00:00
v2.5.0: redesign local admin and repair stats
This commit is contained in:
@@ -1,5 +1,263 @@
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const state = { overview: null, users: [], events: [] };
|
||||
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"];
|
||||
@@ -12,19 +270,44 @@ const fmtBytes = (value = 0) => {
|
||||
return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
};
|
||||
|
||||
const fmtDate = (epoch) => new Date(epoch * 1000).toLocaleString();
|
||||
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"), 2600);
|
||||
toast._timer = setTimeout(() => el.classList.remove("show"), 2800);
|
||||
};
|
||||
|
||||
const event = (title, detail = "") => {
|
||||
const addEvent = (title, detail = "") => {
|
||||
state.events.unshift({ title, detail, time: new Date() });
|
||||
state.events = state.events.slice(0, 8);
|
||||
state.events = state.events.slice(0, 10);
|
||||
renderEvents();
|
||||
};
|
||||
|
||||
@@ -41,6 +324,70 @@ async function api(path, options = {}) {
|
||||
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" },
|
||||
@@ -51,46 +398,163 @@ function renderServices(services = {}) {
|
||||
];
|
||||
$("#services").innerHTML = items.map((item) => {
|
||||
const status = services[item.key] || "unknown";
|
||||
return `<article class="service ${status}">
|
||||
<strong>${item.label}</strong>
|
||||
<div class="status"><span class="dot"></span><span>${status}</span></div>
|
||||
<button class="soft" data-restart="${item.api}" ${item.key === "admin" ? "disabled" : ""}>Restart</button>
|
||||
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} packets`;
|
||||
$("#metricProxyPackets").textContent = `${stats.proxy_pkts || 0} ${t("packets")}`;
|
||||
$("#metricSiteTraffic").textContent = fmtBytes(stats.site_bytes);
|
||||
$("#metricSitePackets").textContent = `${stats.site_pkts || 0} packets`;
|
||||
$("#runtimeBox").textContent = JSON.stringify(data.runtime_summary || {}, null, 2);
|
||||
$("#metricSitePackets").textContent = `${stats.site_pkts || 0} ${t("packets")}`;
|
||||
$("#lastRefresh").textContent = fmtDate(Math.floor(Date.now() / 1000));
|
||||
renderServices(data.services || {});
|
||||
renderRuntime();
|
||||
renderStats();
|
||||
renderBackups(data.backups || []);
|
||||
drawTrafficChart(data.stats_history || []);
|
||||
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">No keys yet</td></tr>`;
|
||||
tbody.innerHTML = `<tr><td colspan="4" class="empty-cell">${escapeHtml(t("noKeys"))}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = state.users.map((user) => `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(user.name)}</strong>${user.main ? " <small>main</small>" : ""}</td>
|
||||
<td><code title="${escapeHtml(user.secret)}">${escapeHtml(user.secret)}</code></td>
|
||||
<td><button class="soft" data-copy="${escapeAttr(user.link)}">Copy link</button></td>
|
||||
<td class="actions">
|
||||
<button class="soft" data-copy="${escapeAttr(user.secret)}">Copy secret</button>
|
||||
<button class="danger" data-delete="${escapeAttr(user.name)}" ${user.main ? "disabled" : ""}>Delete</button>
|
||||
<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("");
|
||||
@@ -99,22 +563,27 @@ function renderUsers() {
|
||||
function renderBackups(backups) {
|
||||
const box = $("#backupsList");
|
||||
if (!backups.length) {
|
||||
box.innerHTML = `<div class="backup-item"><strong>No backups</strong><span></span></div>`;
|
||||
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)} · ${fmtDate(item.mtime)}</span>
|
||||
<span>${escapeHtml(item.path)} · ${escapeHtml(fmtDate(item.mtime))}</span>
|
||||
</div>
|
||||
<div>${fmtBytes(item.size)}${item.encrypted ? " · encrypted" : ""}</div>
|
||||
<div>${escapeHtml(fmtBytes(item.size))}${item.encrypted ? " · encrypted" : ""}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderEvents() {
|
||||
$("#events").innerHTML = state.events.map((item) => `
|
||||
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>
|
||||
@@ -122,58 +591,21 @@ function renderEvents() {
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function drawTrafficChart(rows) {
|
||||
const canvas = $("#trafficChart");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = Math.max(320, rect.width) * ratio;
|
||||
canvas.height = 260 * ratio;
|
||||
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
const w = canvas.width / ratio;
|
||||
const h = canvas.height / ratio;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
const pad = { l: 48, r: 18, t: 20, b: 34 };
|
||||
const points = rows.length ? rows : [{ proxy_delta: 0, site_delta: 0 }, { proxy_delta: 0, site_delta: 0 }];
|
||||
const max = Math.max(1, ...points.map((p) => Math.max(p.proxy_delta || 0, p.site_delta || 0)));
|
||||
const plotW = w - pad.l - pad.r;
|
||||
const plotH = h - pad.t - pad.b;
|
||||
|
||||
ctx.strokeStyle = "#dfe6f1";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i <= 4; i += 1) {
|
||||
const y = pad.t + (plotH / 4) * i;
|
||||
ctx.moveTo(pad.l, y);
|
||||
ctx.lineTo(w - pad.r, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
const line = (key, color) => {
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2.4;
|
||||
ctx.beginPath();
|
||||
points.forEach((p, i) => {
|
||||
const x = pad.l + (plotW * i) / Math.max(1, points.length - 1);
|
||||
const y = pad.t + plotH - ((p[key] || 0) / max) * plotH;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
};
|
||||
line("proxy_delta", "#2563eb");
|
||||
line("site_delta", "#0f9f6e");
|
||||
|
||||
ctx.fillStyle = "#647087";
|
||||
ctx.font = "12px system-ui";
|
||||
ctx.fillText(`max ${fmtBytes(max)}/min`, pad.l, 14);
|
||||
ctx.fillStyle = "#2563eb";
|
||||
ctx.fillText("proxy", pad.l, h - 10);
|
||||
ctx.fillStyle = "#0f9f6e";
|
||||
ctx.fillText("site", pad.l + 58, h - 10);
|
||||
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() {
|
||||
@@ -181,11 +613,12 @@ async function refreshAll() {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
state.overview = await api("/api/overview");
|
||||
updateLanguageFromOverview(state.overview);
|
||||
state.users = await api("/api/users");
|
||||
renderOverview();
|
||||
renderUsers();
|
||||
} catch (err) {
|
||||
if (err.message !== "Unauthorized") toast(err.message);
|
||||
toast(err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
@@ -196,15 +629,15 @@ async function addUser(name) {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
event("Key created", data.name);
|
||||
toast("Key created");
|
||||
addEvent(t("keyCreated"), data.name);
|
||||
toast(t("keyCreated"));
|
||||
await refreshAll();
|
||||
}
|
||||
|
||||
async function deleteUser(name) {
|
||||
await api(`/api/users/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
event("Key deleted", name);
|
||||
toast("Key deleted");
|
||||
addEvent(t("keyDeleted"), name);
|
||||
toast(t("keyDeleted"));
|
||||
await refreshAll();
|
||||
}
|
||||
|
||||
@@ -213,8 +646,8 @@ async function createBackup() {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const data = await api("/api/backups", { method: "POST", body: "{}" });
|
||||
event("Backup created", data.path || "");
|
||||
toast("Backup created");
|
||||
addEvent(t("backupCreated"), data.path || "");
|
||||
toast(t("backupCreated"));
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
@@ -225,7 +658,7 @@ async function createBackup() {
|
||||
|
||||
async function loadLogs() {
|
||||
const service = $("#logService").value;
|
||||
$("#logsBox").textContent = "Loading...";
|
||||
$("#logsBox").textContent = t("loading");
|
||||
try {
|
||||
$("#logsBox").textContent = await api(`/api/logs?service=${encodeURIComponent(service)}`);
|
||||
} catch (err) {
|
||||
@@ -235,36 +668,81 @@ async function loadLogs() {
|
||||
|
||||
async function restartService(name) {
|
||||
await api(`/api/services/${encodeURIComponent(name)}/restart`, { method: "POST", body: "{}" });
|
||||
event("Service restarted", name);
|
||||
toast(`${name} restarted`);
|
||||
addEvent(t("serviceRestarted"), name);
|
||||
toast(`${name} ${t("serviceRestarted").toLowerCase()}`);
|
||||
await refreshAll();
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "").replace(/[&<>"']/g, (ch) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
})[ch]);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value).replace(/`/g, "`");
|
||||
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 target = eventObj.target.closest("button");
|
||||
if (!target) return;
|
||||
const nav = eventObj.target.closest("[data-nav]");
|
||||
if (nav) {
|
||||
setPage(nav.dataset.nav);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.dataset.copy) {
|
||||
await navigator.clipboard.writeText(target.dataset.copy);
|
||||
toast("Copied");
|
||||
}
|
||||
if (target.dataset.delete) {
|
||||
const name = target.dataset.delete;
|
||||
if (confirm(`Delete key ${name}?`)) deleteUser(name).catch((err) => toast(err.message));
|
||||
}
|
||||
if (target.dataset.restart) {
|
||||
const name = target.dataset.restart;
|
||||
if (confirm(`Restart ${name}?`)) restartService(name).catch((err) => toast(err.message));
|
||||
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));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -273,7 +751,7 @@ $("#addUserForm").addEventListener("submit", (eventObj) => {
|
||||
const input = $("#userName");
|
||||
const name = input.value.trim();
|
||||
if (!/^[A-Za-z0-9_.-]{1,48}$/.test(name)) {
|
||||
toast("Use latin letters, digits, _, . or -");
|
||||
toast(t("invalidUser"));
|
||||
return;
|
||||
}
|
||||
input.value = "";
|
||||
@@ -283,14 +761,12 @@ $("#addUserForm").addEventListener("submit", (eventObj) => {
|
||||
$("#refreshBtn").addEventListener("click", refreshAll);
|
||||
$("#createBackupBtn").addEventListener("click", createBackup);
|
||||
$("#loadLogsBtn").addEventListener("click", loadLogs);
|
||||
window.addEventListener("resize", () => state.overview && drawTrafficChart(state.overview.stats_history || []));
|
||||
|
||||
document.querySelectorAll("nav a").forEach((link) => {
|
||||
link.addEventListener("click", () => {
|
||||
document.querySelectorAll("nav a").forEach((item) => item.classList.remove("active"));
|
||||
link.classList.add("active");
|
||||
});
|
||||
});
|
||||
$("#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();
|
||||
|
||||
Reference in New Issue
Block a user