v2.5.0: refine admin traffic and port status

This commit is contained in:
Виталий Литвинов
2026-04-25 12:01:31 +03:00
parent d8ec62eb07
commit d74b05ccf8
7 changed files with 758 additions and 136 deletions

View File

@@ -15,14 +15,16 @@ const i18n = {
themeLight: "Light",
metricMode: "Mode",
metricKeys: "Keys",
metricProxyTraffic: "Proxy Traffic",
metricSiteTraffic: "Site Traffic",
metricProxyTraffic: "Proxy traffic",
metricSiteTraffic: "Site traffic",
configuredUsers: "configured users",
packets: "packets",
servicesEyebrow: "Services",
servicesTitle: "Runtime health",
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",
@@ -40,9 +42,12 @@ const i18n = {
collector: "Collector",
lastPoint: "Last point",
historyRows: "History rows",
collectStats: "Collect",
repairStats: "Repair stats",
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",
@@ -59,6 +64,8 @@ const i18n = {
delete: "Delete",
enabled: "Enabled",
disabled: "Disabled",
applying: "Applying...",
changesApplyInBackground: "Changes are being applied in the background",
disableKey: "Disable key",
enableKey: "Enable key",
main: "main",
@@ -72,6 +79,7 @@ const i18n = {
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",
@@ -103,7 +111,7 @@ const i18n = {
keyDeleted: "Key deleted",
backupCreated: "Backup created",
serviceRestarted: "Service restarted",
statsRepaired: "Statistics repaired",
statsRepaired: "Collector restarted",
statsCollected: "Statistics collected",
confirmDelete: "Delete key",
confirmRestart: "Restart",
@@ -128,8 +136,34 @@ const i18n = {
languageSaved: "Language saved",
keyEnabled: "Key enabled",
keyDisabled: "Key disabled",
visualTitle: "443 shared edge",
visualText: "Website, MTProxy and local admin status in one operational view.",
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",
@@ -161,14 +195,16 @@ const i18n = {
themeLight: "Светлая",
metricMode: "Режим",
metricKeys: "Ключи",
metricProxyTraffic: "Трафик proxy",
metricProxyTraffic: "Трафик прокси",
metricSiteTraffic: "Трафик сайта",
configuredUsers: "настроенных пользователей",
packets: "пакетов",
servicesEyebrow: "Сервисы",
servicesTitle: "Состояние runtime",
runtimeEyebrow: "Runtime",
runtimeTitle: "сводка telemt",
servicesTitle: "Состояние служб",
servicesHelp: "Статус systemd-служб: telemt, nginx, бот, сборщик трафика и локальная админка.",
runtimeEyebrow: "Среда выполнения",
runtimeTitle: "Сводка telemt",
runtimeHelp: "Данные среды выполнения берутся из локального API telemt и показывают, что ядро прокси видит прямо сейчас.",
trafficEyebrow: "Трафик",
trafficTitle: "История",
keysEyebrow: "Доступ",
@@ -186,25 +222,30 @@ const i18n = {
collector: "Сборщик",
lastPoint: "Последняя точка",
historyRows: "Строк истории",
collectStats: "Собрать",
repairStats: "Починить статистику",
collectStats: "Обновить статистику",
collectStatsHelp: "Запустить один сбор трафика прямо сейчас.",
repairStats: "Перезапустить сборщик",
repairStatsHelp: "Переустановить и перезапустить фоновую службу, которая пишет историю трафика.",
tableTime: "Время",
tablePeriod: "Период",
tableStatus: "Статус",
tableProxyDelta: "Proxy delta",
tableSiteDelta: "Site delta",
tableProxyTotal: "Proxy всего",
tableSiteTotal: "Site всего",
tableProxyDelta: "Прирост прокси",
tableSiteDelta: "Прирост сайта",
tableProxyTotal: "Всего прокси",
tableSiteTotal: "Всего по сайту",
tableUser: "Пользователь",
tableSecret: "Secret",
tableSecret: "Секрет",
tableLink: "Ссылка",
tableActions: "Действия",
userPlaceholder: "client-name",
addKey: "Добавить ключ",
copyLink: "Копировать ссылку",
copySecret: "Копировать secret",
copySecret: "Копировать секрет",
delete: "Удалить",
enabled: "Включён",
disabled: "Отключён",
applying: "Применяется...",
changesApplyInBackground: "Изменения применяются в фоне",
disableKey: "Отключить ключ",
enableKey: "Включить ключ",
main: "основной",
@@ -212,13 +253,14 @@ const i18n = {
loadLogs: "Загрузить",
panelLanguage: "Язык панели",
theme: "Тема",
bindAddress: "Адрес bind",
bindAddress: "Адрес привязки",
dashboard: "Обзор",
noKeys: "Ключей пока нет",
noBackups: "Бекапов пока нет",
noEvents: "Событий пока нет",
noHistory: "Истории трафика пока нет",
noRuntime: "Runtime-данные недоступны",
noTrafficForRange: "За этот период данных пока нет",
noRuntime: "Данные среды выполнения недоступны",
badConnections: "Ошибочные подключения",
connections: "Подключения",
uptime: "Аптайм",
@@ -240,16 +282,16 @@ const i18n = {
statusUnknown: "неизвестно",
statsMissing: "Сборщик не запущен",
statsOk: "Сборщик работает",
statsStale: "Snapshot устарел",
statsStale: "Снимок устарел",
statsError: "Ошибка сборщика",
restart: "Рестарт",
restart: "Перезапустить",
copied: "Скопировано",
copyFailed: "Не удалось скопировать",
keyCreated: "Ключ создан",
keyDeleted: "Ключ удалён",
backupCreated: "Бекап создан",
serviceRestarted: "Сервис перезапущен",
statsRepaired: "Статистика починена",
statsRepaired: "Сборщик перезапущен",
statsCollected: "Статистика собрана",
confirmDelete: "Удалить ключ",
confirmRestart: "Перезапустить",
@@ -274,8 +316,34 @@ const i18n = {
languageSaved: "Язык сохранён",
keyEnabled: "Ключ включён",
keyDisabled: "Ключ отключён",
visualTitle: "Единый 443 edge",
visualText: "Сайт, MTProxy и локальная админка в одном рабочем обзоре.",
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",
@@ -298,15 +366,21 @@ const i18n = {
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;
@@ -380,12 +454,19 @@ function applyI18n() {
$$("[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();
}
@@ -394,7 +475,7 @@ function setTheme(theme) {
document.documentElement.dataset.theme = state.theme;
localStorage.setItem("gotelegram-theme", state.theme);
applyI18n();
if (state.overview) drawTrafficChart(state.overview.stats_history || []);
if (state.overview) renderStats();
}
async function setLanguage(lang) {
@@ -427,6 +508,9 @@ function setPage(page, push = true) {
if (push && location.hash !== `#${next}`) {
history.replaceState(null, "", `#${next}`);
}
if (next === "traffic") {
refreshStats().catch((err) => toast(err.message));
}
}
function updatePageTitle() {
@@ -540,6 +624,42 @@ function renderSiteStatus() {
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;
@@ -551,6 +671,7 @@ function renderOverview() {
$("#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")}`;
@@ -564,10 +685,95 @@ function renderOverview() {
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 status = state.overview?.stats_status || {};
const stats = state.overview?.stats_current || {};
const historyRows = state.overview?.stats_history || [];
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) : "--";
@@ -577,18 +783,21 @@ function renderStats() {
$("#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(historyRows);
renderHistoryTable(summaryRows);
}
function drawTrafficChart(rows) {
const el = $("#trafficChart");
const points = rows.slice(-120);
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(t("noHistory"))}</strong>
<strong>${escapeHtml(points.length ? t("noTrafficForRange") : t("noHistory"))}</strong>
<span>${escapeHtml(state.overview?.stats_status?.health === "ok" ? t("statsOk") : t("statsMissing"))}</span>
</div>`;
return;
@@ -606,30 +815,30 @@ function drawTrafficChart(rows) {
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">
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">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>
<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) {
const latest = rows.slice(-12).reverse();
if (!latest.length) {
if (!rows.length) {
$("#historyTable").innerHTML = `<tr><td colspan="5" class="empty-cell">${escapeHtml(t("noHistory"))}</td></tr>`;
return;
}
$("#historyTable").innerHTML = latest.map((row) => `
$("#historyTable").innerHTML = rows.map((row) => `
<tr>
<td data-label="${escapeAttr(t("tableTime"))}">${escapeHtml(fmtDate(row.epoch))}</td>
<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_bytes))}</td>
<td data-label="${escapeAttr(t("tableSiteTotal"))}">${escapeHtml(fmtBytes(row.site_bytes))}</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("");
}
@@ -640,18 +849,20 @@ function renderUsers() {
tbody.innerHTML = `<tr><td colspan="5" class="empty-cell">${escapeHtml(t("noKeys"))}</td></tr>`;
return;
}
tbody.innerHTML = state.users.map((user) => `
<tr class="${user.enabled ? "" : "disabled-row"}">
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 ? "disabled" : ""}>
<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(user.enabled ? t("enabled") : t("disabled"))}</strong>
<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>
@@ -661,7 +872,7 @@ function renderUsers() {
<button class="danger" data-delete="${escapeAttr(user.name)}" ${user.main ? "disabled" : ""}>${escapeHtml(t("delete"))}</button>
</td>
</tr>
`).join("");
`; }).join("");
}
function renderBackups(backups) {
@@ -676,7 +887,7 @@ function renderBackups(backups) {
<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>${escapeHtml(fmtBytes(item.size))}${item.encrypted ? ` · ${escapeHtml(t("encrypted"))}` : ""}</div>
</div>
`).join("");
}
@@ -721,6 +932,20 @@ async function refreshAll() {
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) {
@@ -730,6 +955,12 @@ async function refreshAll() {
}
}
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",
@@ -748,14 +979,29 @@ async function deleteUser(name) {
}
async function setUserEnabled(name, enabled) {
const data = await api(`/api/users/${encodeURIComponent(name)}/enabled`, {
method: "POST",
body: JSON.stringify({ enabled }),
});
const message = data.enabled ? t("keyEnabled") : t("keyDisabled");
addEvent(message, name);
toast(message);
await refreshAll();
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() {
@@ -812,6 +1058,7 @@ async function repairStats() {
addEvent(t("statsRepaired"));
toast(t("statsRepaired"));
await refreshAll();
await refreshStats();
} catch (err) {
toast(err.message);
} finally {
@@ -827,6 +1074,7 @@ async function collectStats() {
addEvent(t("statsCollected"));
toast(t("statsCollected"));
await refreshAll();
await refreshStats();
} catch (err) {
toast(err.message);
} finally {
@@ -875,6 +1123,14 @@ document.addEventListener("click", async (eventObj) => {
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) {