dashboard/static/js/app.js

203 lines
6.6 KiB
JavaScript

(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");
}
});
})();