Files
OpenClaw Coder 8289bc85e8 Add ServerDash: self-hosted Docker & host monitoring dashboard
- 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
2026-11-11 00:15:58 +10:00

242 lines
7.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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);