(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 }); } // ---------- 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 envelopeDs(name, color, s) { s = s || {}; return [ { label: name, data: s.max || [], borderColor: color, backgroundColor: color + "2e", borderWidth: 1, pointRadius: 0, tension: 0, fill: false, order: 0 }, { label: name + " min", band: true, data: s.min || [], borderColor: color, backgroundColor: color, borderWidth: 1, pointRadius: 0, tension: 0, fill: false, order: 1 }, ]; } const LEGEND = { display: true, labels: { boxWidth: 10, color: "#7d8a9c", filter: (item, data) => !data.datasets[item.datasetIndex]?.band } }; function initCharts() { if (typeof Chart === "undefined") return; charts.cpu = new Chart(document.getElementById("chart-cpu"), { type: "line", data: { labels: [], datasets: [].concat(envelopeDs("CPU", "#4fc3f7"), envelopeDs("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: [].concat(envelopeDs("Memory", "#81c784"), envelopeDs("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: [].concat(envelopeDs("read", "#e57373"), envelopeDs("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 labels = hist.ts.map(fmtTime); const series = hist.series || {}; const build = (chart, pairs) => { chart.data.labels = labels; chart.data.datasets = []; pairs.forEach(([name, color, key], i) => { const ds = envelopeDs(name, color, series[key]); ds[0].fill = i * 2 + 1; chart.data.datasets.push(...ds); }); chart.update("none"); }; build(charts.cpu, [["CPU", "#4fc3f7", "cpu"], ["GPU", "#ba68c8", "gpu"]]); build(charts.mem, [["Memory", "#81c784", "mem_pct"], ["VRAM", "#ffb74d", "vram_pct"]]); build(charts.io, [["read", "#e57373", "io_read"], ["write", "#fff176", "io_write"]]); } 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"); }); // ---------- services accordion ---------- const openSvc = new Set(); function loadSvcDetail(unit, target) { htmx.ajax("GET", "/api/services/" + encodeURIComponent(unit) + "/detail", { target }); } function toggleSvcRow(row) { const detailRow = row.nextElementSibling; if (!detailRow || !detailRow.classList.contains("svc-detail-row")) return; const unit = row.dataset.unit; if (openSvc.has(unit)) { openSvc.delete(unit); row.classList.remove("open"); detailRow.hidden = true; } else { openSvc.add(unit); row.classList.add("open"); detailRow.hidden = false; loadSvcDetail(unit, detailRow.querySelector(".svc-detail")); } } document.body.addEventListener("click", (e) => { const row = e.target.closest("#services tr.svc-row"); if (!row || e.target.closest("button")) return; toggleSvcRow(row); }); document.body.addEventListener("keydown", (e) => { const el = e.target.closest(".svc-name"); if (el && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); toggleSvcRow(el.closest("tr.svc-row")); } }); document.body.addEventListener("htmx:afterSwap", (e) => { if (e.target.id !== "services" || !openSvc.size) return; for (const row of e.target.querySelectorAll("tr.svc-row")) { const unit = row.dataset.unit; if (!openSvc.has(unit)) continue; const detailRow = row.nextElementSibling; if (!detailRow || !detailRow.classList.contains("svc-detail-row")) continue; row.classList.add("open"); detailRow.hidden = false; loadSvcDetail(unit, detailRow.querySelector(".svc-detail")); } }); })();