dashboard/static/js/app.js

287 lines
9.7 KiB
JavaScript

(function () {
"use strict";
// ---------- tabs ----------
const tabBtns = document.querySelectorAll(".tab-btn");
const sections = document.querySelectorAll(".tab");
// per-tab poll interval (ms); htmx "every" timers cannot pause per
// element, so only the active tab polls: JS dispatches "dash-poll" on
// its section, where every polling element listens via
// hx-trigger="dash-poll from:#tab-<name>"
const TAB_INTERVALS = { overview: 2000, disks: 2000, processes: 3000, journal: 5000, services: 15000, plugins: 5000 };
let activeTab = null;
let pollTimer = null;
let histTimer = null;
function fireTabEvent(type, name) {
const sec = document.getElementById("tab-" + name);
if (sec) htmx.trigger(sec, type);
}
function setHistoryPolling(on) {
if (on && !histTimer) {
pollHistory();
histTimer = setInterval(pollHistory, 2000);
} else if (!on && histTimer) {
clearInterval(histTimer);
histTimer = null;
}
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
setHistoryPolling(false);
}
function startTabPolling(name) {
stopPolling();
activeTab = name;
// "dash-activate" is the one-shot trigger (plugins list), "dash-poll"
// the recurring one; firing both refreshes the tab immediately
fireTabEvent("dash-activate", name);
fireTabEvent("dash-poll", name);
pollTimer = setInterval(() => fireTabEvent("dash-poll", name), TAB_INTERVALS[name] || 5000);
setHistoryPolling(name === "overview");
}
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) {}
startTabPolling(name);
}
tabBtns.forEach((b) => b.addEventListener("click", () => showTab(b.dataset.tab)));
// ---------- 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();
// ---------- 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"));
}
});
// ---------- startup ----------
// pause all polling while this browser tab is hidden, resume with a
// fresh fetch when it becomes visible again
document.addEventListener("visibilitychange", () => {
if (document.hidden) stopPolling();
else if (activeTab) startTabPolling(activeTab);
});
let initial = "overview";
try {
const saved = localStorage.getItem("dash.tab");
if (saved && document.getElementById("tab-" + saved)) initial = saved;
} catch (e) {}
showTab(initial);
})();