aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-09 19:17:29 +0100
committerDanilo M. <danix@danix.xyz>2026-11-09 19:17:29 +0100
commit67e64e964a85562300b463378f0e260d6978867f (patch)
treecb706f2142823ca436d301d8b7a2af27e88231c1
parent796407233383e7bf4825f671d84fbe71279488a6 (diff)
downloaddeck-it-67e64e964a85562300b463378f0e260d6978867f.tar.gz
deck-it-67e64e964a85562300b463378f0e260d6978867f.zip
feat: build VM card shows the real stats dashboard
-rw-r--r--deckit/modules/sbbuilder.py18
-rw-r--r--static/deck.css6
-rw-r--r--static/modules/sbbuilder.js96
-rw-r--r--tests/test_sbbuilder.py6
4 files changed, 74 insertions, 52 deletions
diff --git a/deckit/modules/sbbuilder.py b/deckit/modules/sbbuilder.py
index 41f3a14..13552a1 100644
--- a/deckit/modules/sbbuilder.py
+++ b/deckit/modules/sbbuilder.py
@@ -1,7 +1,8 @@
-"""sb-builder build progress, read-only over ssh.
+"""sb-builder build state, read-only over ssh.
-The build VM's `sbb-remote jobs` already returns JSON; this module runs it over
-the configured ssh host alias and relays it. Nothing here writes to the VM.
+`sbb-remote stats` carries the dashboard: VM load, uptime, memory, disk, package
+counts, orphans, slackrepo results and overlay pulls. `jobs` adds the recent
+build list. Both run over one ssh connection and arrive as two JSON objects.
"""
import json
@@ -10,7 +11,7 @@ import subprocess
from ..module import Module
-def _run(cmd, timeout=5):
+def _run(cmd, timeout=8):
try:
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout).stdout
except (OSError, subprocess.TimeoutExpired):
@@ -24,13 +25,16 @@ def make(cfg: dict) -> Module:
def state():
try:
out = run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=4",
- host, "sbb-remote", "jobs"])
+ host, "sbb-remote stats; sbb-remote jobs"]).lstrip()
except OSError:
return {"offline": True}
try:
- data = json.loads(out)
+ decoder = json.JSONDecoder()
+ stats, end = decoder.raw_decode(out)
+ jobs, _ = decoder.raw_decode(out[end:].lstrip())
except (ValueError, TypeError):
return {"offline": True}
- return {"offline": False, "jobs": data.get("jobs", [])}
+ return {"offline": False, "stats": stats,
+ "jobs": jobs.get("jobs", []) if isinstance(jobs, dict) else []}
return Module(name="sbbuilder", title="Build VM", state=state)
diff --git a/static/deck.css b/static/deck.css
index 40d6ac8..8b2416c 100644
--- a/static/deck.css
+++ b/static/deck.css
@@ -131,8 +131,10 @@ body {
.music .time { font-size: 12px; color: var(--udt-subtext0); white-space: nowrap; }
.music .controls { display: flex; flex-wrap: wrap; gap: 8px; }
-.sbb .sbb-summary { margin-bottom: 8px; font-size: 12px; }
-.sbb .row.job .kind { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.sbb .sbb-now { margin-bottom: 8px; font-size: 12px; font-weight: 600; }
+.sbb .metric { display: flex; justify-content: space-between; gap: 12px; padding: 3px 0; font-size: 13px; }
+.sbb .metric .k { color: var(--udt-subtext0); flex: none; }
+.sbb .metric .v { text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.grid-stack-item .ui-resizable-se { width: 22px; height: 22px; right: 2px; bottom: 2px; opacity: .45; }
.grid-stack-item:hover .ui-resizable-se { opacity: 1; }
diff --git a/static/modules/sbbuilder.js b/static/modules/sbbuilder.js
index f1c574d..02493af 100644
--- a/static/modules/sbbuilder.js
+++ b/static/modules/sbbuilder.js
@@ -1,58 +1,72 @@
-function chipClass(status) {
- if (status === "ok") return "good";
- if (status === "failed" || status === "lost" || status === "killed") return "bad";
- if (status === "running" || status === "busy" || status === "starting") return "warn";
- return "muted";
-}
+const gib = (b) => (b == null ? "?" : (b / 1073741824).toFixed(1));
+const pct = (n) => (n == null ? "?" : `${Math.round(n)}%`);
+const up = (s) => {
+ if (s == null) return "?";
+ const d = Math.floor(s / 86400);
+ const h = Math.floor((s % 86400) / 3600);
+ return d ? `${d}d ${h}h` : `${h}h`;
+};
+const ago = (ts) => {
+ if (!ts) return "—";
+ const d = Math.max(0, Math.floor(Date.now() / 1000 - ts));
+ if (d < 3600) return `${Math.floor(d / 60)}m`;
+ if (d < 86400) return `${Math.floor(d / 3600)}h`;
+ return `${Math.floor(d / 86400)}d`;
+};
-function when(ts) {
- if (!ts) return "";
- return new Date(ts * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
-}
+const ACTIVE = ["running", "busy", "starting"];
-function summary(jobs) {
- const running = jobs.filter((j) => ["running", "busy", "starting"].includes(j.status)).length;
- const failed = jobs.filter((j) => ["failed", "lost", "killed"].includes(j.status)).length;
- const parts = [];
- if (running) parts.push(`${running} running`);
- if (failed) parts.push(`${failed} failed`);
- if (!parts.length) parts.push(jobs.length ? `${jobs.length} recent` : "idle");
- return parts.join(" · ");
+function metric(label, value) {
+ const row = document.createElement("div");
+ row.className = "metric";
+ const k = document.createElement("span");
+ k.className = "k";
+ k.textContent = label;
+ const v = document.createElement("span");
+ v.className = "v num";
+ v.textContent = value;
+ row.append(k, v);
+ return row;
}
export default {
title: "Build VM",
- interval: 5,
+ interval: 10,
mount(el) {
- el.innerHTML = `<div class="sbb"><div class="sbb-summary muted"></div><div class="sbb-list"></div></div>`;
+ el.innerHTML = `<div class="sbb"></div>`;
},
update(state, el) {
- const sum = el.querySelector(".sbb-summary");
- const list = el.querySelector(".sbb-list");
+ const box = el.querySelector(".sbb");
+ box.textContent = "";
if (state.offline) {
- sum.textContent = "";
- list.textContent = "build VM unreachable";
+ box.textContent = "build VM unreachable";
return;
}
+ const s = state.stats ?? {};
+ const p = s.packages ?? {};
+ const sr = s.slackrepo ?? {};
const jobs = state.jobs ?? [];
- sum.textContent = summary(jobs);
- list.textContent = "";
- for (const job of jobs.slice(0, 8)) {
- const row = document.createElement("div");
- row.className = "row job";
- const kind = document.createElement("span");
- kind.className = "kind";
- kind.textContent = job.kind || "job";
- const time = document.createElement("span");
- time.className = "muted num";
- time.textContent = when(job.created);
- const status = document.createElement("span");
- status.className = chipClass(job.status);
- status.textContent = job.status;
- row.append(kind, time, status);
- list.append(row);
- }
+
+ const running = jobs.find((j) => ACTIVE.includes(j.status));
+ const now = document.createElement("div");
+ now.className = "sbb-now";
+ now.textContent = running ? `● ${running.kind || "job"} running` : "idle";
+ now.classList.toggle("warn", Boolean(running));
+ box.append(now);
+
+ const overlays = Object.entries(p).filter(([k]) => k !== "total" && k !== "shadowed");
+ const results = Object.entries(sr.results ?? {});
+ box.append(
+ metric("CPU · load", `${pct(s.cpu)} · ${s.load ? s.load[0].toFixed(2) : "?"}`),
+ metric("Uptime", up(s.uptime)),
+ metric("Memory", `${gib((s.mem?.total ?? 0) - (s.mem?.available ?? 0))} / ${gib(s.mem?.total)} GiB`),
+ metric("Disk free", `${gib(s.disk?.free)} / ${gib(s.disk?.total)} GiB`),
+ metric("Packages", `${p.total ?? "?"}${overlays.length ? " · " + overlays.map(([k, v]) => `${k} ${v}`).join(" · ") : ""}`),
+ metric("Shadowed · orphans", `${p.shadowed ?? "?"} · ${s.orphans ?? "?"}`),
+ metric("Built · results", `${sr.built ?? "?"}${results.length ? " · " + results.map(([k, v]) => `${v} ${k}`).join(" · ") : ""}`),
+ metric("Overlay pulls", Object.entries(s.sync ?? {}).map(([k, v]) => `${k} ${ago(v)}`).join(" · ") || "—"),
+ );
},
};
diff --git a/tests/test_sbbuilder.py b/tests/test_sbbuilder.py
index 5c5438e..f71e0f9 100644
--- a/tests/test_sbbuilder.py
+++ b/tests/test_sbbuilder.py
@@ -17,11 +17,13 @@ class Fail(unittest.TestCase):
class Ok(unittest.TestCase):
- def test_parses_jobs(self):
- out = json.dumps({"jobs": [{"id": "20261109-101010-build", "status": "running"}]})
+ def test_parses_stats_and_jobs(self):
+ out = (json.dumps({"cpu": 5, "packages": {"total": 100}})
+ + json.dumps({"jobs": [{"id": "20261109-101010-build", "status": "running"}]}))
st = sbb.make({"sbbuilder": {"host": "buildsystem"},
"run": lambda cmd, timeout=5: out}).state()
self.assertFalse(st["offline"])
+ self.assertEqual(st["stats"]["packages"]["total"], 100)
self.assertEqual(st["jobs"][0]["status"], "running")