From 4642a53d6e2ca80e019c3af80118803a3cdfa7fe Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 19 Mar 2026 01:34:02 +0100 Subject: [PATCH] fix(dashboard): enrich repo-sync page with live SBOM snapshot stats repos.json.py now fetches /sbom/snapshots/ alongside /repos/ and annotates each repo with sbom_snapshot_count, sbom_entry_count, and a last_sbom_at fallback derived from actual snapshot data. This prevents "LastSBOM=never" when the denormalized field is out of sync. repo-sync.md gains SBOM KPI tiles (ingested vs no-SBOM), color-coded SBOM age column (same green/orange/red scale as state sync), and an entry count column showing packages from the latest snapshot. Co-Authored-By: Claude Sonnet 4.6 --- state-hub/dashboard/src/data/repos.json.py | 41 ++++++++++++++++++---- state-hub/dashboard/src/repo-sync.md | 20 ++++++++--- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/state-hub/dashboard/src/data/repos.json.py b/state-hub/dashboard/src/data/repos.json.py index 963e475..613bc7a 100644 --- a/state-hub/dashboard/src/data/repos.json.py +++ b/state-hub/dashboard/src/data/repos.json.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Observable data loader: fetches /repos/ from the API.""" +"""Observable data loader: fetches /repos/ enriched with SBOM snapshot stats.""" import json import os import urllib.request @@ -7,9 +7,36 @@ import urllib.error API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") -try: - with urllib.request.urlopen(f"{API_BASE}/repos/", timeout=10) as resp: - data = json.loads(resp.read()) - print(json.dumps(data)) -except urllib.error.URLError as e: - print(json.dumps({"error": str(e), "repos": []})) + +def fetch(url): + try: + with urllib.request.urlopen(url, timeout=10) as resp: + return json.loads(resp.read()) + except urllib.error.URLError as e: + print(f"Warning: could not fetch {url}: {e}", flush=True) + return [] + + +repos = fetch(f"{API_BASE}/repos/") +snapshots = fetch(f"{API_BASE}/sbom/snapshots/") + +# Build map: repo_id → {count, latest_at, latest_entry_count} +snap_stats: dict = {} +for s in snapshots: + rid = s["repo_id"] + if rid not in snap_stats: + snap_stats[rid] = {"count": 0, "latest_at": None, "latest_entry_count": 0} + snap_stats[rid]["count"] += 1 + if snap_stats[rid]["latest_at"] is None or s["snapshot_at"] > snap_stats[rid]["latest_at"]: + snap_stats[rid]["latest_at"] = s["snapshot_at"] + snap_stats[rid]["latest_entry_count"] = s["entry_count"] + +# Enrich repos — fall back to snapshot data if denormalized field is missing +for r in repos: + stats = snap_stats.get(str(r["id"]), {}) + if not r.get("last_sbom_at") and stats.get("latest_at"): + r["last_sbom_at"] = stats["latest_at"] + r["sbom_snapshot_count"] = stats.get("count", 0) + r["sbom_entry_count"] = stats.get("latest_entry_count", 0) + +print(json.dumps(repos)) diff --git a/state-hub/dashboard/src/repo-sync.md b/state-hub/dashboard/src/repo-sync.md index 84c1b5a..08d6a72 100644 --- a/state-hub/dashboard/src/repo-sync.md +++ b/state-hub/dashboard/src/repo-sync.md @@ -43,18 +43,28 @@ function syncColor(ts) { const activeRepos = repos.filter(r => r.status === "active"); const staleCount = activeRepos.filter(r => !r.last_state_synced_at || ageMs(r.last_state_synced_at) > 86400000).length; const freshCount = activeRepos.filter(r => r.last_state_synced_at && ageMs(r.last_state_synced_at) < 3600000).length; +const sbomCount = activeRepos.filter(r => r.last_sbom_at).length; +const noSbomCount = activeRepos.length - sbomCount; ``` ```js display(html` -
+
${freshCount}
-
synced < 1h
+
state synced < 1h
${staleCount}
-
stale / never
+
state stale / never
+
+
+
${sbomCount}
+
SBOM ingested
+
+
+
${noSbomCount}
+
no SBOM yet
${activeRepos.length}
@@ -72,6 +82,7 @@ const table = html`Domain + @@ -82,7 +93,8 @@ const table = html`
Last Synced Last SBOMEntries Status
${r.slug} - + +
${r.domain_slug} ${fmtAge(r.last_state_synced_at)}${fmtAge(r.last_sbom_at)}${fmtAge(r.last_sbom_at)}${r.sbom_entry_count > 0 ? r.sbom_entry_count.toLocaleString() : "—"} ${r.status}