v2.5.0: add local web admin dashboard

This commit is contained in:
Виталий Литвинов
2026-04-24 19:19:12 +03:00
parent ed9073f28f
commit 20103ccac8
15 changed files with 1668 additions and 12 deletions

300
admin-web/static/app.js Normal file
View File

@@ -0,0 +1,300 @@
const $ = (sel) => document.querySelector(sel);
const state = { overview: null, users: [], events: [] };
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) => new Date(epoch * 1000).toLocaleString();
const toast = (message) => {
const el = $("#toast");
el.textContent = message;
el.classList.add("show");
clearTimeout(toast._timer);
toast._timer = setTimeout(() => el.classList.remove("show"), 2600);
};
const event = (title, detail = "") => {
state.events.unshift({ title, detail, time: new Date() });
state.events = state.events.slice(0, 8);
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" });
if (res.status === 401) {
$("#authLock").classList.remove("hidden");
throw new Error("Unauthorized");
}
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 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";
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>
</article>`;
}).join("");
}
function renderOverview() {
const data = state.overview;
if (!data) return;
const cfg = data.config || {};
const stats = data.stats_current || {};
$("#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`;
$("#metricSiteTraffic").textContent = fmtBytes(stats.site_bytes);
$("#metricSitePackets").textContent = `${stats.site_pkts || 0} packets`;
$("#runtimeBox").textContent = JSON.stringify(data.runtime_summary || {}, null, 2);
renderServices(data.services || {});
renderBackups(data.backups || []);
drawTrafficChart(data.stats_history || []);
}
function renderUsers() {
const tbody = $("#usersTable");
if (!state.users.length) {
tbody.innerHTML = `<tr><td colspan="4">No keys yet</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>
</tr>
`).join("");
}
function renderBackups(backups) {
const box = $("#backupsList");
if (!backups.length) {
box.innerHTML = `<div class="backup-item"><strong>No backups</strong><span></span></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>
</div>
<div>${fmtBytes(item.size)}${item.encrypted ? " · encrypted" : ""}</div>
</div>
`).join("");
}
function renderEvents() {
$("#events").innerHTML = state.events.map((item) => `
<div class="event">
<strong>${escapeHtml(item.title)}</strong>
<small>${escapeHtml(item.detail || item.time.toLocaleTimeString())}</small>
</div>
`).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);
}
async function refreshAll() {
const btn = $("#refreshBtn");
btn.disabled = true;
try {
state.overview = await api("/api/overview");
state.users = await api("/api/users");
renderOverview();
renderUsers();
} catch (err) {
if (err.message !== "Unauthorized") toast(err.message);
} finally {
btn.disabled = false;
}
}
async function addUser(name) {
const data = await api("/api/users", {
method: "POST",
body: JSON.stringify({ name }),
});
event("Key created", data.name);
toast("Key created");
await refreshAll();
}
async function deleteUser(name) {
await api(`/api/users/${encodeURIComponent(name)}`, { method: "DELETE" });
event("Key deleted", name);
toast("Key deleted");
await refreshAll();
}
async function createBackup() {
const btn = $("#createBackupBtn");
btn.disabled = true;
try {
const data = await api("/api/backups", { method: "POST", body: "{}" });
event("Backup created", data.path || "");
toast("Backup created");
await refreshAll();
} catch (err) {
toast(err.message);
} finally {
btn.disabled = false;
}
}
async function loadLogs() {
const service = $("#logService").value;
$("#logsBox").textContent = "Loading...";
try {
$("#logsBox").textContent = await api(`/api/logs?service=${encodeURIComponent(service)}`);
} catch (err) {
$("#logsBox").textContent = err.message;
}
}
async function restartService(name) {
await api(`/api/services/${encodeURIComponent(name)}/restart`, { method: "POST", body: "{}" });
event("Service restarted", name);
toast(`${name} restarted`);
await refreshAll();
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (ch) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;",
})[ch]);
}
function escapeAttr(value) {
return escapeHtml(value).replace(/`/g, "&#096;");
}
document.addEventListener("click", async (eventObj) => {
const target = eventObj.target.closest("button");
if (!target) 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));
}
});
$("#addUserForm").addEventListener("submit", (eventObj) => {
eventObj.preventDefault();
const input = $("#userName");
const name = input.value.trim();
if (!/^[A-Za-z0-9_.-]{1,48}$/.test(name)) {
toast("Use latin letters, digits, _, . or -");
return;
}
input.value = "";
addUser(name).catch((err) => toast(err.message));
});
$("#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");
});
});
refreshAll();
loadLogs();