Files

738 lines
25 KiB
HTML
Raw Permalink 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.
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Baikal Sync Dashboard</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--transition: 0.3s ease;
--green: #00b894;
--blue: #74b9ff;
--yellow: #fdcb6e;
--red: #e17055;
--purple: #a29bfe;
}
[data-theme="dark"] {
--bg: #0f1117;
--card: #1a1d27;
--border: #2e3345;
--text: #e4e6f0;
--text-secondary: #8b90a0;
--shadow: 0 2px 12px rgba(0,0,0,0.3);
--chart-bg: #12141c;
}
[data-theme="light"] {
--bg: #f5f7fa;
--card: #ffffff;
--border: #e2e8f0;
--text: #1a1d27;
--text-secondary: #64748b;
--shadow: 0 2px 12px rgba(0,0,0,0.08);
--chart-bg: #f0f3f8;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
transition: background var(--transition), color var(--transition);
min-height: 100vh;
}
.container { max-width: 1200px; margin: 0 auto; padding: 24px 16px; }
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 28px;
}
.logo { display: flex; align-items: center; gap: 10px; font-size: 1.5rem; font-weight: 700; }
.status-dot {
width: 12px; height: 12px; border-radius: 50%; background: var(--green);
animation: pulse 2s ease-in-out infinite;
}
.status-dot.error { background: var(--red); }
.status-dot.syncing { background: var(--blue); animation-duration: 0.6s; }
.status-dot.backoff { background: var(--yellow); }
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(0,184,148,0.5); }
50% { box-shadow: 0 0 0 8px rgba(0,184,148,0); }
}
.theme-toggle {
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
width: 40px; height: 40px; cursor: pointer; display: flex;
align-items: center; justify-content: center; font-size: 1.2rem;
transition: background var(--transition), border-color var(--transition);
}
.theme-toggle:hover { border-color: var(--text-secondary); }
.card {
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
padding: 20px; transition: background var(--transition), border-color var(--transition), box-shadow var(--transition);
box-shadow: var(--shadow);
}
.status-bar { margin-bottom: 24px; }
.status-main {
display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: 12px; margin-bottom: 16px;
}
.status-text { font-size: 1.35rem; font-weight: 600; }
.status-meta { display: flex; gap: 20px; flex-wrap: wrap; color: var(--text-secondary); font-size: 0.9rem; }
.countdown-bar { margin-top: 12px; }
.countdown-label { font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 6px; display: flex; justify-content: space-between; }
.progress-track {
height: 6px; background: var(--border); border-radius: 3px; overflow: hidden;
}
.progress-fill {
height: 100%; background: var(--green); border-radius: 3px;
transition: width 1s linear;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px; margin-bottom: 24px;
}
.stat-card { padding: 18px; }
.stat-label { font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
.stat-value { font-size: 1.7rem; font-weight: 700; }
.stat-sub { font-size: 0.8rem; color: var(--text-secondary); margin-top: 4px; }
.stat-card.green .stat-value { color: var(--green); }
.stat-card.blue .stat-value { color: var(--blue); }
.stat-card.yellow .stat-value { color: var(--yellow); }
.stat-card.red .stat-value { color: var(--red); }
.stat-card.purple .stat-value { color: var(--purple); }
.section-card { margin-bottom: 24px; }
.section-title { font-size: 1.05rem; font-weight: 600; margin-bottom: 16px; }
.stacked-bar-container { margin-bottom: 20px; }
.stacked-bar {
height: 32px; border-radius: 6px; overflow: hidden; display: flex;
background: var(--border);
}
.stacked-segment { height: 100%; transition: width 0.5s ease; min-width: 0; }
.legend {
display: flex; gap: 16px; flex-wrap: wrap; margin-top: 10px;
font-size: 0.85rem; color: var(--text-secondary);
}
.legend-item { display: flex; align-items: center; gap: 6px; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
.toggle-group {
display: flex; gap: 4px; margin-bottom: 16px;
background: var(--border); border-radius: 8px; padding: 3px; width: fit-content;
}
.toggle-btn {
background: transparent; border: none; color: var(--text-secondary);
padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 0.85rem;
transition: all var(--transition);
}
.toggle-btn.active { background: var(--card); color: var(--text); font-weight: 600; }
.chart-area {
display: flex; align-items: flex-end; gap: 2px; height: 120px;
background: var(--chart-bg); border-radius: 8px; padding: 8px; overflow: hidden;
}
.chart-bar {
flex: 1; border-radius: 2px 2px 0 0; transition: height 0.3s ease, background 0.3s ease;
min-width: 3px; position: relative;
}
.chart-bar.ok { background: var(--green); }
.chart-bar.fail { background: var(--red); }
.chart-labels {
display: flex; justify-content: space-between; margin-top: 4px;
font-size: 0.7rem; color: var(--text-secondary);
}
.config-section { margin-bottom: 24px; }
.copy-btn {
background: var(--green); color: #fff; border: none; border-radius: 8px;
padding: 8px 18px; cursor: pointer; font-size: 0.9rem; font-weight: 600;
transition: opacity var(--transition); margin-bottom: 16px;
}
.copy-btn:hover { opacity: 0.85; }
.copy-btn.copied { background: var(--blue); }
.config-table { width: 100%; border-collapse: collapse; }
.config-table td {
padding: 10px 12px; border-bottom: 1px solid var(--border);
font-size: 0.9rem;
}
.config-table tr:last-child td { border-bottom: none; }
.config-table td:first-child {
color: var(--text-secondary); font-weight: 500; width: 140px;
white-space: nowrap;
}
.config-table td:last-child { word-break: break-all; }
.password-cell { display: flex; align-items: center; gap: 8px; }
.password-text { flex: 1; }
.eye-btn {
background: none; border: none; cursor: pointer; font-size: 1.1rem;
opacity: 0.6; transition: opacity var(--transition); padding: 4px;
}
.eye-btn:hover { opacity: 1; }
.history-section { margin-bottom: 24px; }
.timeline { display: flex; flex-direction: column; gap: 6px; max-height: 400px; overflow-y: auto; }
.timeline-entry {
display: flex; align-items: center; gap: 10px; padding: 10px 14px;
border-radius: 8px; background: var(--chart-bg); font-size: 0.88rem;
transition: background var(--transition);
}
.entry-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.entry-dot.ok { background: var(--green); }
.entry-dot.fail { background: var(--red); }
.entry-time { color: var(--text-secondary); font-variant-numeric: tabular-nums; min-width: 60px; }
.entry-msg { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.badge {
padding: 2px 8px; border-radius: 4px; font-size: 0.78rem; font-weight: 600;
white-space: nowrap;
}
.badge.added { background: rgba(0,184,148,0.15); color: var(--green); }
.badge.updated { background: rgba(116,185,255,0.15); color: var(--blue); }
.badge.skipped { background: rgba(139,144,160,0.15); color: var(--text-secondary); }
.badge.error { background: rgba(225,112,85,0.15); color: var(--red); }
.empty-state {
text-align: center; padding: 40px 20px; color: var(--text-secondary);
font-size: 0.95rem;
}
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.status-main { flex-direction: column; align-items: flex-start; }
.status-meta { flex-direction: column; gap: 4px; }
}
@media (max-width: 480px) {
.stats-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="container">
<header>
<div class="logo">
<span class="status-dot" id="headerDot"></span>
Baikal Sync
</div>
<button class="theme-toggle" id="themeToggle" aria-label="Toggle theme">🌙</button>
</header>
<div class="card status-bar" id="statusBar">
<div class="empty-state">Loading…</div>
</div>
<div class="stats-grid" id="statsGrid">
<div class="card stat-card green"><div class="stat-label">Total Events</div><div class="stat-value" id="statEvents"></div></div>
<div class="card stat-card blue"><div class="stat-label">Total Added</div><div class="stat-value" id="statAdded"></div></div>
<div class="card stat-card purple"><div class="stat-label">Avg Events/Sync</div><div class="stat-value" id="statAvgEvents"></div></div>
<div class="card stat-card blue"><div class="stat-label">Syncs Total</div><div class="stat-value" id="statSyncs"></div><div class="stat-sub" id="statSyncsSub"></div></div>
<div class="card stat-card yellow"><div class="stat-label">Avg Sync Time</div><div class="stat-value" id="statAvgDuration"></div></div>
<div class="card stat-card purple"><div class="stat-label">Avg Latency</div><div class="stat-value" id="statAvgLatency"></div></div>
<div class="card stat-card green"><div class="stat-label">Bandwidth Saved</div><div class="stat-value" id="statBandwidth"></div></div>
<div class="card stat-card" id="backoffCard"><div class="stat-label">Backoff</div><div class="stat-value" id="statBackoff"></div></div>
</div>
<div class="card section-card">
<div class="section-title">Session Breakdown</div>
<div class="stacked-bar-container">
<div class="stacked-bar" id="stackedBar"></div>
<div class="legend" id="stackedLegend"></div>
</div>
<div class="toggle-group" id="chartToggle">
<button class="toggle-btn active" data-metric="duration">Duration</button>
<button class="toggle-btn" data-metric="events">Events</button>
<button class="toggle-btn" data-metric="latency">Latency</button>
</div>
<div class="chart-area" id="chartArea"></div>
<div class="chart-labels" id="chartLabels"></div>
</div>
<div class="card config-section">
<div class="section-title">Configuration</div>
<button class="copy-btn" id="copyBtn">Copy .env</button>
<table class="config-table" id="configTable">
<tbody id="configBody"></tbody>
</table>
</div>
<div class="card history-section">
<div class="section-title">Sync History</div>
<div class="timeline" id="timeline">
<div class="empty-state">No sync history yet.</div>
</div>
</div>
</div>
<script>
(function () {
"use strict";
const state = {
data: null,
chartMetric: "duration",
passwordVisible: false,
pollTimer: null,
countdownTimer: null,
lastPoll: null
};
let countdownRemaining = 0;
const $ = (sel) => document.querySelector(sel);
function fmtBytes(b) {
if (b == null || b === 0) return "0 B";
if (b >= 1048576) return (b / 1048576).toFixed(2) + " MB";
return (b / 1024).toFixed(1) + " KB";
}
function fmtUptime(sec) {
if (sec == null) return "—";
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
return h + "h " + m + "m";
}
function fmtDuration(s) {
if (s == null) return "—";
if (s < 1) return (s * 1000).toFixed(0) + "ms";
return s.toFixed(2) + "s";
}
function fmtTime(iso) {
if (!iso) return "Never";
try {
const d = new Date(iso);
return d.toLocaleTimeString();
} catch { return iso; }
}
function fmtCountdown(sec) {
if (sec == null || sec < 0) return "00:00";
const m = Math.floor(sec / 60);
const s = sec % 60;
return String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
}
function truncate(str, max) {
if (!str) return "";
max = max || 60;
return str.length > max ? str.slice(0, 30) + "…" : str;
}
function maskPassword(p) {
if (!p) return "";
return "•".repeat(Math.min(p.length, 20));
}
function getStatusLabel(s) {
switch (s) {
case "syncing": return "Syncing…";
case "error": return "Error";
case "backoff": return "Backing off";
case "idle":
default: return "Up to Date";
}
}
function getSyncStatusClass(s) {
switch (s) {
case "syncing": return "syncing";
case "error": return "error";
case "backoff": return "backoff";
default: return "";
}
}
/* ── Theme ── */
function initTheme() {
const saved = localStorage.getItem("baikal-theme");
const theme = saved || "dark";
document.documentElement.setAttribute("data-theme", theme);
$("#themeToggle").textContent = theme === "dark" ? "☀️" : "🌙";
}
function toggleTheme() {
const current = document.documentElement.getAttribute("data-theme");
const next = current === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem("baikal-theme", next);
$("#themeToggle").textContent = next === "dark" ? "☀️" : "🌙";
}
/* ── Render Status Bar ── */
function renderStatusBar(d) {
const cls = getSyncStatusClass(d.status);
$("#headerDot").className = "status-dot" + (cls ? " " + cls : "");
let statusText = "";
if (d.status === "backoff" && d.next_sync_in != null) {
const mins = Math.ceil(d.next_sync_in / 60);
statusText = "Backing off — " + mins + " min remaining";
} else {
statusText = "Sync " + getStatusLabel(d.status);
}
const progressPct = d.next_sync_in != null && d.config && d.config.sync_frequency
? Math.max(0, Math.min(100, ((d.config.sync_frequency * 60 - d.next_sync_in) / (d.config.sync_frequency * 60)) * 100))
: 0;
$("#statusBar").innerHTML =
'<div class="status-main">' +
'<div class="status-text">' + statusText + "</div>" +
'<div class="status-meta">' +
'<span>Last sync: ' + fmtTime(d.last_sync) + "</span>" +
(d.duration != null ? '<span>Duration: ' + fmtDuration(d.duration) + "</span>" : "") +
(d.ics_latency_ms != null ? '<span>Latency: ' + d.ics_latency_ms + "ms</span>" : "") +
'<span>Uptime: ' + fmtUptime(d.session.uptime_sec) + "</span>" +
"</div>" +
"</div>" +
'<div class="countdown-bar">' +
'<div class="countdown-label">' +
'<span>Next sync</span><span id="countdown">' + fmtCountdown(d.next_sync_in) + "</span>" +
"</div>" +
'<div class="progress-track"><div class="progress-fill" id="progressFill" style="width:' + progressPct + '%"></div></div>' +
"</div>";
}
/* ── Render Stats ── */
function renderStats(d) {
const s = d.session;
$("#statEvents").textContent = d.event_count != null ? d.event_count : "—";
$("#statAdded").textContent = s.total_added != null ? s.total_added : "—";
const totalOps = (s.total_added || 0) + (s.total_updated || 0);
const avgEvents = s.syncs_total > 0 ? (totalOps / s.syncs_total).toFixed(2) : "—";
$("#statAvgEvents").textContent = avgEvents;
$("#statSyncs").textContent = s.syncs_total || 0;
const skipPct = s.syncs_total > 0 ? ((s.syncs_skipped || 0) / s.syncs_total * 100).toFixed(0) : "0";
$("#statSyncsSub").textContent = (s.syncs_failed || 0) + " failed · " + skipPct + "% skipped";
$("#statAvgDuration").textContent = fmtDuration(s.avg_duration);
$("#statAvgLatency").textContent = s.avg_latency_ms != null ? s.avg_latency_ms + " ms" : "—";
$("#statBandwidth").textContent = fmtBytes(s.bandwidth_saved_bytes);
const bc = $("#backoffCard");
if (d.status === "backoff") {
bc.className = "card stat-card yellow";
const mins = Math.ceil((d.next_sync_in || 0) / 60);
$("#statBackoff").textContent = mins + " min";
} else {
bc.className = "card stat-card green";
$("#statBackoff").textContent = "None";
}
}
/* ── Render Stacked Bar ── */
function renderStackedBar(d) {
const s = d.session;
const added = s.total_added || 0;
const updated = s.total_updated || 0;
const deleted = s.total_deleted || 0;
const skipped = s.syncs_skipped || 0;
const total = added + updated + deleted + skipped || 1;
const w1 = (added / total * 100).toFixed(2);
const w2 = (updated / total * 100).toFixed(2);
const w3 = (deleted / total * 100).toFixed(2);
const w4 = (skipped / total * 100).toFixed(2);
$("#stackedBar").innerHTML =
'<div class="stacked-segment" style="width:' + w1 + '%;background:var(--green)"></div>' +
'<div class="stacked-segment" style="width:' + w2 + '%;background:var(--blue)"></div>' +
'<div class="stacked-segment" style="width:' + w3 + '%;background:var(--purple)"></div>' +
'<div class="stacked-segment" style="width:' + w4 + '%;background:var(--text-secondary)"></div>';
$("#stackedLegend").innerHTML =
'<div class="legend-item"><span class="legend-dot" style="background:var(--green)"></span>Added: ' + added + "</div>" +
'<div class="legend-item"><span class="legend-dot" style="background:var(--blue)"></span>Updated: ' + updated + "</div>" +
'<div class="legend-item"><span class="legend-dot" style="background:var(--purple)"></span>Deleted: ' + deleted + "</div>" +
'<div class="legend-item"><span class="legend-dot" style="background:var(--text-secondary)"></span>Skipped: ' + skipped + "</div>";
}
/* ── Render Chart ── */
function renderChart(d) {
const history = d.session.history || [];
const metric = state.chartMetric;
const sliced = history.slice(-50);
if (!sliced.length) {
$("#chartArea").innerHTML = "";
$("#chartLabels").innerHTML = "";
return;
}
let key = "";
if (metric === "duration") key = "duration";
else if (metric === "events") {
const vals = sliced.map(function (e) { return (e.added || 0) + (e.updated || 0) + (e.deleted || 0); });
const maxVal = Math.max(1, ...vals);
const bars = sliced.map(function (e, i) {
const v = (e.added || 0) + (e.updated || 0) + (e.deleted || 0);
const h = Math.max(2, (v / maxVal) * 100);
return '<div class="chart-bar ' + (e.ok ? "ok" : "fail") + '" style="height:' + h + '%" title="' + v + ' events"></div>';
}).join("");
$("#chartArea").innerHTML = bars;
const first = sliced[0];
const last = sliced[sliced.length - 1];
$("#chartLabels").innerHTML = '<span>' + (first.time || "") + "</span><span>" + (last.time || "") + "</span>";
return;
} else { key = "latency_ms"; }
const vals = sliced.map(function (e) { return e[key] || 0; });
const maxVal = Math.max(1, ...vals);
const bars = sliced.map(function (e) {
const v = e[key] || 0;
const h = Math.max(2, (v / maxVal) * 100);
const tip = metric === "duration" ? (v * 1000).toFixed(0) + "ms" : v + "ms";
return '<div class="chart-bar ' + (e.ok ? "ok" : "fail") + '" style="height:' + h + '%" title="' + tip + '"></div>';
}).join("");
$("#chartArea").innerHTML = bars;
const first = sliced[0];
const last = sliced[sliced.length - 1];
$("#chartLabels").innerHTML = '<span>' + (first.time || "") + "</span><span>" + (last.time || "") + "</span>";
}
/* ── Render Config ── */
function renderConfig(d) {
const c = d.config || {};
const rows = [
["ICS URL", truncate(c.ics_url || "")],
["Baikal URL", truncate(c.baikal_url || "")],
["User", c.baikal_user || ""],
["Password", "password", c.baikal_pass || ""],
["Frequency", c.sync_frequency ? c.sync_frequency + " minutes" : "—"],
["Calendar ID", c.calendar_id || "—"]
];
let html = "";
rows.forEach(function (r) {
if (r[1] === "password") {
const pw = state.passwordVisible ? r[2] : maskPassword(r[2]);
html += '<tr><td>' + r[0] + "</td><td>" +
'<div class="password-cell">' +
'<span class="password-text">' + pw + "</span>" +
'<button class="eye-btn" id="eyeBtn">' + (state.passwordVisible ? "🙈" : "👁️") + "</button>" +
"</div></td></tr>";
} else {
html += '<tr><td>' + r[0] + "</td><td>" + r[1] + "</td></tr>";
}
});
$("#configBody").innerHTML = html;
if ($("#eyeBtn")) {
$("#eyeBtn").addEventListener("click", function () {
state.passwordVisible = !state.passwordVisible;
renderConfig(state.data);
});
}
}
/* ── Render History ── */
function renderHistory(d) {
const history = d.session.history || [];
const sliced = history.slice(-50).reverse();
if (!sliced.length) {
$("#timeline").innerHTML = '<div class="empty-state">No sync history yet.</div>';
return;
}
let html = "";
sliced.forEach(function (entry) {
const ok = entry.ok !== false;
const badge = getBadge(entry);
html +=
'<div class="timeline-entry">' +
'<span class="entry-dot ' + (ok ? "ok" : "fail") + '"></span>' +
'<span class="entry-time">' + (entry.time || "") + "</span>" +
'<span class="entry-msg">' + escapeHtml(entry.msg || (ok ? "OK" : "Error")) + "</span>" +
badge +
"</div>";
});
$("#timeline").innerHTML = html;
}
function getBadge(e) {
const added = e.added || 0;
const updated = e.updated || 0;
const deleted = e.deleted || 0;
if (!e.ok) return '<span class="badge error">error</span>';
if (e.skipped || (added === 0 && updated === 0 && deleted === 0)) return '<span class="badge skipped">skipped</span>';
const parts = [];
if (added) parts.push("+" + added);
if (updated) parts.push("~" + updated);
if (deleted) parts.push("" + deleted);
return '<span class="badge ' + (added ? "added" : "updated") + '">' + parts.join(" / ") + "</span>";
}
function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
/* ── Copy .env ── */
function copyEnv() {
const c = state.data.config;
if (!c) return;
const lines = [
"ICS_URL=" + (c.ics_url || ""),
"BAIKAL_URL=" + (c.baikal_url || ""),
"BAIKAL_USER=" + (c.baikal_user || ""),
"BAIKAL_PASS=" + (c.baikal_pass || ""),
"SYNC_FREQUENCY=" + (c.sync_frequency || ""),
"CALENDAR_ID=" + (c.calendar_id || "")
].join("\n");
navigator.clipboard.writeText(lines).then(function () {
const btn = $("#copyBtn");
btn.textContent = "Copied!";
btn.classList.add("copied");
setTimeout(function () {
btn.textContent = "Copy .env";
btn.classList.remove("copied");
}, 2000);
}).catch(function () {
const ta = document.createElement("textarea");
ta.value = lines;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
const btn = $("#copyBtn");
btn.textContent = "Copied!";
btn.classList.add("copied");
setTimeout(function () {
btn.textContent = "Copy .env";
btn.classList.remove("copied");
}, 2000);
});
}
/* ── Polling ── */
function updateCountdown() {
if (!state.data) return;
const el = $("#countdown");
const fill = $("#progressFill");
if (!el || !fill) return;
countdownRemaining = Math.max(0, countdownRemaining - 1);
el.textContent = fmtCountdown(countdownRemaining);
const freq = state.data.config ? state.data.config.sync_frequency * 60 : 600;
const pct = freq > 0 ? Math.max(0, Math.min(100, ((freq - countdownRemaining) / freq) * 100)) : 100;
fill.style.width = pct + "%";
}
async function poll() {
try {
const res = await fetch("/api/status");
if (!res.ok) throw new Error("HTTP " + res.status);
state.data = await res.json();
state.lastPoll = Date.now();
if (state.data.last_sync && state.data.config && state.data.config.sync_frequency) {
const syncEpoch = new Date(state.data.last_sync).getTime();
const freqSec = state.data.config.sync_frequency * 60;
const now = Date.now();
countdownRemaining = Math.max(0, Math.round(((syncEpoch + freqSec * 1000) - now) / 1000));
}
renderStatusBar(state.data);
renderStats(state.data);
renderStackedBar(state.data);
renderChart(state.data);
renderConfig(state.data);
renderHistory(state.data);
} catch (err) {
console.error("Poll error:", err);
}
}
/* ── Init ── */
function init() {
initTheme();
$("#themeToggle").addEventListener("click", toggleTheme);
$("#copyBtn").addEventListener("click", copyEnv);
document.getElementById("chartToggle").addEventListener("click", function (e) {
const btn = e.target.closest(".toggle-btn");
if (!btn) return;
state.chartMetric = btn.getAttribute("data-metric");
document.querySelectorAll("#chartToggle .toggle-btn").forEach(function (b) { b.classList.remove("active"); });
btn.classList.add("active");
if (state.data) renderChart(state.data);
});
poll();
state.pollTimer = setInterval(poll, 10000);
state.countdownTimer = setInterval(updateCountdown, 1000);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
</script>
</body>
</html>