"use strict"; // ServerDash SPA — polls the backend and renders the dashboard. const AUTO_REFRESH_MS = 3000; let currentFilter = "all"; let containers = []; let hostData = null; const $ = (sel) => document.querySelector(sel); const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }; // ------------------------------------------------------------------ // // Fetching // ------------------------------------------------------------------ // async function fetchJSON(url) { const res = await fetch(url, { cache: "no-store" }); if (!res.ok) throw new Error(`${url} -> ${res.status}`); return res.json(); } async function refreshHost() { try { hostData = await fetchJSON("/api/v1/host"); renderHost(); setHostBadge(true); } catch (err) { setHostBadge(false, err.message); } } async function refreshContainers() { try { const data = await fetchJSON("/api/v1/containers?refresh=1"); containers = data.containers || []; renderSummary(containers); renderGrid(); updateLastUpdate(); } catch (err) { console.error(err); $("last-update").textContent = "container fetch failed"; } } async function refreshAll() { await Promise.all([refreshHost(), refreshContainers()]); } // ------------------------------------------------------------------ // // Rendering — host cards // ------------------------------------------------------------------ // function renderHost() { const h = hostData || {}; // CPU const cpu = h.cpu_percent ?? 0; $("#cpu-pct").textContent = Math.round(cpu); $("#cpu-detail").textContent = `${h.cpu_count ?? "?"} logical cores · load ${fmtLoad(h.load)}`; // Memory const mem = h.memory || {}; $("#mem-pct").textContent = mem.percent ?? "–"; $("#mem-detail").textContent = `${(mem.used_mb ?? 0).toLocaleString()} / ${(mem.total_mb ?? 0).toLocaleString()} MB`; // Disk (best-effort: show the "/" partition if present) const disk = h.disk || {}; const root = disk["/"] || Object.values(disk)[0]; if (root) { $("#disk-pct").textContent = root.percent ?? "–"; $("#disk-detail").textContent = `${(root.used_gb ?? 0).toLocaleString()} / ${(root.total_gb ?? 0).toLocaleString()} GB`; } else { $("#disk-pct").textContent = "–"; $("#disk-detail").textContent = "no disk data"; } // Load $("#load-1").textContent = (h.load && h.load["1min"]) ?? "–"; $("#load-detail").textContent = h.cpu_count ? `cores: ${h.cpu_count}` : "n/a"; // Uptime $("#uptime").textContent = fmtUptime(h.uptime_seconds ?? 0); $("#boot-detail").textContent = h.boot_time ? `booted ${new Date(h.boot_time * 1000).toLocaleDateString()}` : "n/a"; } function setHostBadge(ok, msg) { const b = $("#host-badge"); if (ok) { b.textContent = "● host ok"; b.className = "badge badge-ok"; } else { b.textContent = "✕ host error"; b.className = "badge badge-idle"; } } // ------------------------------------------------------------------ // // Rendering — summary pills + grid // ------------------------------------------------------------------ // function renderSummary(list) { const counts = { all: list.length, running: 0, starting: 0, paused: 0, stopped: 0, dead: 0 }; for (const c of list) counts[c.state_class] = (counts[c.state_class] || 0) + 1; for (const k of Object.keys(counts)) { const node = document.getElementById(`c-${k}`); if (node) node.textContent = counts[k]; } } function renderGrid() { const grid = $("#container-grid"); grid.innerHTML = ""; const filtered = currentFilter === "all" ? containers : containers.filter((c) => c.state_class === currentFilter); if (filtered.length === 0) { grid.appendChild(el("div", "empty", "No containers match this filter.")); $("#container-count").textContent = `${containers.length} containers`; return; } for (const c of filtered) grid.appendChild(renderCell(c)); $("#container-count").textContent = `${containers.length} containers`; } function renderCell(c) { const cell = el("div", "cell"); // Header const head = el("div", "cell-head"); head.appendChild(el("span", `dot ${c.state_class}`)); head.appendChild(el("span", "cell-name", c.name)); const status = el("span", `cell-status ${c.state_class}`, c.status || c.state_class); if (c.healthy === true) status.textContent += " ✓"; head.appendChild(status); cell.appendChild(head); // Image cell.appendChild(el("div", "cell-image", c.image || "unknown image")); // Resource bars (CPU / memory) const bars = el("div", "bars"); bars.appendChild(renderBar("CPU", c.cpu_percent, true)); if (c.mem_limit_mb > 0) { bars.appendChild(renderBar("Mem", c.mem_percent, true, `${c.mem_usage_mb} / ${c.mem_limit_mb} MB`)); } cell.appendChild(bars); // Meta tags const meta = el("div", "cell-meta"); for (const p of c.ports.slice(0, 3)) meta.appendChild(el("span", "tag port", p)); if (c.restart_policy && c.restart_policy !== "no") meta.appendChild(el("span", "tag", `↺ ${c.restart_policy}`)); if (c.healthy === true) meta.appendChild(el("span", "tag health-ok", "healthy")); cell.appendChild(meta); return cell; } function renderBar(label, pct, showPct, sub) { const pctNum = typeof pct === "number" ? pct : 0; const row = el("div", "bar-row"); row.appendChild(el("span", "", label)); const track = el("div", "bar-track"); const fill = el("div", `bar-fill ${barClass(pctNum)}`); fill.style.width = `${Math.max(2, Math.min(100, pctNum))}%`; track.appendChild(fill); row.appendChild(track); const val = el("span", "bar-val", showPct ? `${Math.round(pctNum)}%` : (sub || "")); row.appendChild(val); return row; } function barClass(pct) { if (pct >= 80) return "high"; if (pct >= 50) return "mid"; return "low"; } // ------------------------------------------------------------------ // // Filtering // ------------------------------------------------------------------ // function setupFilters() { document.querySelectorAll(".pill").forEach((pill) => { pill.addEventListener("click", () => { document.querySelectorAll(".pill").forEach((p) => p.classList.remove("active")); pill.classList.add("active"); currentFilter = pill.dataset.filter; renderGrid(); }); }); } // ------------------------------------------------------------------ // // Helpers // ------------------------------------------------------------------ // function fmtLoad(load) { if (!load) return "n/a"; return `${load["1min"]} / ${load["5min"]} / ${load["15min"]}`; } function fmtUptime(seconds) { const s = Math.floor(seconds); const days = Math.floor(s / 86400); const hrs = Math.floor((s % 86400) / 3600); const mins = Math.floor((s % 3600) / 60); const secs = s % 60; const parts = []; if (days) parts.push(`${days}d`); if (hrs) parts.push(`${hrs}h`); if (mins) parts.push(`${mins}m`); if (!parts.length) parts.push(`${secs}s`); return parts.slice(0, 2).join(" "); } function updateLastUpdate() { $("#last-update").textContent = `updated ${new Date().toLocaleTimeString()}`; } // ------------------------------------------------------------------ // // Boot // ------------------------------------------------------------------ // function start() { setupFilters(); // Mark the "all" pill active by default. const allPill = document.querySelector('.pill[data-filter="all"]'); if (allPill) allPill.classList.add("active"); $("#refresh-interval").textContent = `${Math.round(AUTO_REFRESH_MS / 1000)}s`; $("#refresh-btn").addEventListener("click", () => refreshAll()); refreshAll(); setInterval(refreshAll, AUTO_REFRESH_MS); } document.addEventListener("DOMContentLoaded", start);