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" }); 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 `
${item.label}
${status}
`; }).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 = `No keys yet`; return; } tbody.innerHTML = state.users.map((user) => ` ${escapeHtml(user.name)}${user.main ? " main" : ""} ${escapeHtml(user.secret)} `).join(""); } function renderBackups(backups) { const box = $("#backupsList"); if (!backups.length) { box.innerHTML = `
No backups
`; return; } box.innerHTML = backups.map((item) => `
${escapeHtml(item.name)} ${escapeHtml(item.path)} · ${fmtDate(item.mtime)}
${fmtBytes(item.size)}${item.encrypted ? " · encrypted" : ""}
`).join(""); } function renderEvents() { $("#events").innerHTML = state.events.map((item) => `
${escapeHtml(item.title)} ${escapeHtml(item.detail || item.time.toLocaleTimeString())}
`).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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[ch]); } function escapeAttr(value) { return escapeHtml(value).replace(/`/g, "`"); } 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();