- Flask backend + static SPA (HTML/CSS/JS) - Docker container discovery via unix socket (stdlib only, no docker SDK) - Host stats (CPU/mem/disk/load/uptime) via psutil - Live-updating UI with per-state filters and resource gauges - Dockerfile for containerized deployment
242 lines
7.6 KiB
JavaScript
242 lines
7.6 KiB
JavaScript
"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);
|