(function () { "use strict"; // ---------- tabs ---------- const tabBtns = document.querySelectorAll(".tab-btn"); const sections = document.querySelectorAll(".tab"); function showTab(name) { tabBtns.forEach((b) => b.classList.toggle("active", b.dataset.tab === name)); sections.forEach((s) => s.classList.toggle("hidden", s.id !== "tab-" + name)); try { localStorage.setItem("dash.tab", name); } catch (e) {} } tabBtns.forEach((b) => b.addEventListener("click", () => showTab(b.dataset.tab))); try { const saved = localStorage.getItem("dash.tab"); if (saved && document.getElementById("tab-" + saved)) showTab(saved); } catch (e) {} // ---------- helpers ---------- function fmtBytes(n, digits) { if (n == null || isNaN(n)) return "—"; if (digits == null) digits = 1; const u = ["B", "KiB", "MiB", "GiB", "TiB"]; let i = 0; while (Math.abs(n) >= 1024 && i < u.length - 1) { n /= 1024; i++; } return n.toFixed(digits) + " " + u[i]; } function fmtTime(ts) { const d = new Date(ts * 1000); return d.toLocaleTimeString([], { hour12: false }); } function downsampleIdx(len, max) { if (len <= max) return null; const step = Math.ceil(len / max); const idx = []; for (let i = 0; i < len; i += step) idx.push(i); if (idx[idx.length - 1] !== len - 1) idx.push(len - 1); return idx; } // ---------- charts ---------- const charts = {}; function baseOpts(extra) { const o = { animation: false, responsive: true, maintainAspectRatio: false, interaction: { mode: "index", intersect: false }, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 7, maxRotation: 0, color: "#7d8a9c" }, grid: { display: false } }, y: { beginAtZero: true, ticks: { color: "#7d8a9c" }, grid: { color: "rgba(42,51,66,.5)" } }, }, }; if (extra) Object.assign(o.scales.y, extra); return o; } function newDs(label, color, extra) { return Object.assign( { label, data: [], borderColor: color, backgroundColor: color, borderWidth: 1.5, pointRadius: 0, tension: 0.25, fill: false }, extra || {} ); } const LEGEND = { display: true, labels: { boxWidth: 10, color: "#7d8a9c" } }; function initCharts() { if (typeof Chart === "undefined") return; charts.cpu = new Chart(document.getElementById("chart-cpu"), { type: "line", data: { labels: [], datasets: [newDs("CPU", "#4fc3f7"), newDs("GPU", "#ba68c8")], }, options: baseOpts({ max: 100 }), }); charts.cpu.options.plugins.legend = LEGEND; charts.mem = new Chart(document.getElementById("chart-mem"), { type: "line", data: { labels: [], datasets: [newDs("Memory", "#81c784"), newDs("VRAM", "#ffb74d")], }, options: baseOpts({ max: 100 }), }); charts.mem.options.plugins.legend = LEGEND; charts.io = new Chart(document.getElementById("chart-io"), { type: "line", data: { labels: [], datasets: [newDs("read", "#e57373"), newDs("write", "#fff176")], }, options: baseOpts({ ticks: { color: "#7d8a9c", callback: (v) => fmtBytes(v, 0) }, beginAtZero: true, }), }); charts.io.options.plugins.legend = LEGEND; } function updateCharts(hist) { if (!hist || !hist.ts || !hist.ts.length) return; const ts = hist.ts; const idx = downsampleIdx(ts.length, 400); const pick = (arr) => (arr && idx ? idx.map((i) => (i < arr.length ? arr[i] : null)) : arr); const labels = (idx ? idx.map((i) => ts[i]) : ts).map(fmtTime); const set2 = (chart, keys) => { chart.data.labels = labels; keys.forEach((k, i) => { chart.data.datasets[i].data = pick(hist.series[k]) || []; }); chart.update("none"); }; set2(charts.cpu, ["cpu", "gpu"]); set2(charts.mem, ["mem_pct", "vram_pct"]); charts.io.data.labels = labels; charts.io.data.datasets[0].data = pick(hist.series.io_read) || []; charts.io.data.datasets[1].data = pick(hist.series.io_write) || []; charts.io.update("none"); } function pollHistory() { fetch("/api/history") .then((r) => r.json()) .then(updateCharts) .catch(() => {}); } initCharts(); pollHistory(); setInterval(pollHistory, 2000); // ---------- journal ---------- const journalLog = document.getElementById("journal-log"); const journalCursor = document.getElementById("journal-cursor"); const journalStatus = document.getElementById("journal-status"); const MAX_LINES = 500; document.body.addEventListener("htmx:afterSwap", (e) => { if (e.target.id !== "journal-log") return; // surface one-shot errors in the status bar instead of the log document.querySelectorAll("#journal-log .j-error-once").forEach((el) => { journalStatus.textContent = el.textContent.trim(); el.remove(); }); // track cursor from the most recent line const lines = journalLog.querySelectorAll(".jline"); const last = lines[lines.length - 1]; if (last && last.dataset.cursor) journalCursor.value = last.dataset.cursor; // trim const extra = lines.length - MAX_LINES; if (extra > 0) { for (let i = 0; i < extra; i++) lines[i].remove(); } // auto-scroll if user is at the bottom (within 40px) const wrap = document.getElementById("journal-logwrap"); const atBottom = wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight < 40; if (atBottom) wrap.scrollTop = wrap.scrollHeight; }); // reset cursor when the filter form is submitted const journalFilters = document.getElementById("journal-filters"); journalFilters.addEventListener("submit", () => { journalCursor.value = ""; journalStatus.textContent = ""; journalLog.innerHTML = ""; }); // ---------- table sorting (processes, services) ---------- document.body.addEventListener("click", (e) => { const link = e.target.closest(".sortlink"); if (!link) return; e.preventDefault(); const formId = link.dataset.form || "proc-controls"; const form = document.getElementById(formId); if (!form) return; const sortSel = form.querySelector("select[name=sort]"); const orderSel = form.querySelector("select[name=order]"); if (sortSel) sortSel.value = link.dataset.sort; if (orderSel) orderSel.value = link.dataset.order; htmx.trigger(form, "submit"); }); // keyboard: Enter/Space on service names document.body.addEventListener("keydown", (e) => { const el = e.target.closest(".svc-name"); if (el && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); htmx.trigger(el, "click"); } }); })();