generated from coulomb/repo-seed
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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 json
|
||||||
import os
|
import os
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -7,9 +7,36 @@ import urllib.error
|
|||||||
|
|
||||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
|
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:
|
def fetch(url):
|
||||||
data = json.loads(resp.read())
|
try:
|
||||||
print(json.dumps(data))
|
with urllib.request.urlopen(url, timeout=10) as resp:
|
||||||
except urllib.error.URLError as e:
|
return json.loads(resp.read())
|
||||||
print(json.dumps({"error": str(e), "repos": []}))
|
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))
|
||||||
|
|||||||
@@ -43,18 +43,28 @@ function syncColor(ts) {
|
|||||||
const activeRepos = repos.filter(r => r.status === "active");
|
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 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 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
|
```js
|
||||||
display(html`
|
display(html`
|
||||||
<div style="display:flex;gap:1.5rem;margin-bottom:1.5rem">
|
<div style="display:flex;gap:1.5rem;margin-bottom:1.5rem;flex-wrap:wrap">
|
||||||
<div style="padding:1rem 1.5rem;border-radius:8px;background:#f5f5f5;min-width:100px;text-align:center">
|
<div style="padding:1rem 1.5rem;border-radius:8px;background:#f5f5f5;min-width:100px;text-align:center">
|
||||||
<div style="font-size:2rem;font-weight:700;color:var(--theme-green)">${freshCount}</div>
|
<div style="font-size:2rem;font-weight:700;color:var(--theme-green)">${freshCount}</div>
|
||||||
<div style="font-size:0.8rem;color:#666">synced < 1h</div>
|
<div style="font-size:0.8rem;color:#666">state synced < 1h</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:1rem 1.5rem;border-radius:8px;background:#f5f5f5;min-width:100px;text-align:center">
|
<div style="padding:1rem 1.5rem;border-radius:8px;background:#f5f5f5;min-width:100px;text-align:center">
|
||||||
<div style="font-size:2rem;font-weight:700;color:var(--theme-red)">${staleCount}</div>
|
<div style="font-size:2rem;font-weight:700;color:var(--theme-red)">${staleCount}</div>
|
||||||
<div style="font-size:0.8rem;color:#666">stale / never</div>
|
<div style="font-size:0.8rem;color:#666">state stale / never</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:1rem 1.5rem;border-radius:8px;background:#f5f5f5;min-width:100px;text-align:center">
|
||||||
|
<div style="font-size:2rem;font-weight:700;color:var(--theme-green)">${sbomCount}</div>
|
||||||
|
<div style="font-size:0.8rem;color:#666">SBOM ingested</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:1rem 1.5rem;border-radius:8px;background:#f5f5f5;min-width:100px;text-align:center">
|
||||||
|
<div style="font-size:2rem;font-weight:700;color:${noSbomCount > 0 ? 'var(--theme-orange)' : 'var(--theme-green)'}">${noSbomCount}</div>
|
||||||
|
<div style="font-size:0.8rem;color:#666">no SBOM yet</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:1rem 1.5rem;border-radius:8px;background:#f5f5f5;min-width:100px;text-align:center">
|
<div style="padding:1rem 1.5rem;border-radius:8px;background:#f5f5f5;min-width:100px;text-align:center">
|
||||||
<div style="font-size:2rem;font-weight:700">${activeRepos.length}</div>
|
<div style="font-size:2rem;font-weight:700">${activeRepos.length}</div>
|
||||||
@@ -72,6 +82,7 @@ const table = html`<table style="width:100%;border-collapse:collapse;font-size:0
|
|||||||
<th style="text-align:left;padding:6px 8px">Domain</th>
|
<th style="text-align:left;padding:6px 8px">Domain</th>
|
||||||
<th style="text-align:left;padding:6px 8px">Last Synced</th>
|
<th style="text-align:left;padding:6px 8px">Last Synced</th>
|
||||||
<th style="text-align:left;padding:6px 8px">Last SBOM</th>
|
<th style="text-align:left;padding:6px 8px">Last SBOM</th>
|
||||||
|
<th style="text-align:right;padding:6px 8px">Entries</th>
|
||||||
<th style="text-align:left;padding:6px 8px">Status</th>
|
<th style="text-align:left;padding:6px 8px">Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -82,7 +93,8 @@ const table = html`<table style="width:100%;border-collapse:collapse;font-size:0
|
|||||||
<td style="padding:6px 8px;font-weight:500">${r.slug}</td>
|
<td style="padding:6px 8px;font-weight:500">${r.slug}</td>
|
||||||
<td style="padding:6px 8px;color:#555">${r.domain_slug}</td>
|
<td style="padding:6px 8px;color:#555">${r.domain_slug}</td>
|
||||||
<td style="padding:6px 8px;color:${syncColor(r.last_state_synced_at)};font-weight:500">${fmtAge(r.last_state_synced_at)}</td>
|
<td style="padding:6px 8px;color:${syncColor(r.last_state_synced_at)};font-weight:500">${fmtAge(r.last_state_synced_at)}</td>
|
||||||
<td style="padding:6px 8px;color:#777">${fmtAge(r.last_sbom_at)}</td>
|
<td style="padding:6px 8px;color:${syncColor(r.last_sbom_at)};font-weight:500">${fmtAge(r.last_sbom_at)}</td>
|
||||||
|
<td style="padding:6px 8px;text-align:right;color:#555">${r.sbom_entry_count > 0 ? r.sbom_entry_count.toLocaleString() : "—"}</td>
|
||||||
<td style="padding:6px 8px">
|
<td style="padding:6px 8px">
|
||||||
<span style="padding:2px 8px;border-radius:12px;font-size:0.75rem;background:${r.status === 'active' ? '#e8f5e9' : '#f5f5f5'};color:${r.status === 'active' ? '#2e7d32' : '#666'}">${r.status}</span>
|
<span style="padding:2px 8px;border-radius:12px;font-size:0.75rem;background:${r.status === 'active' ? '#e8f5e9' : '#f5f5f5'};color:${r.status === 'active' ? '#2e7d32' : '#666'}">${r.status}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user