Files
state-hub/dashboard/src/repo-sync.md
tegwick 7bf3cf583a 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>
2026-03-19 01:34:02 +01:00

172 lines
6.4 KiB
Markdown
Raw 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.
---
title: Repo Sync Health
---
# Repo Sync Health
```js
const repoData = await FileAttachment("data/repos.json").json();
const inventory = await FileAttachment("data/gitea-inventory.json").json();
const repos = Array.isArray(repoData) ? repoData : (repoData.repos ?? []);
```
```js
// Helpers
function ageMs(ts) {
if (!ts) return Infinity;
return Date.now() - new Date(ts).getTime();
}
function fmtAge(ts) {
if (!ts) return "never";
const ms = ageMs(ts);
const m = Math.floor(ms / 60000);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
function syncColor(ts) {
if (!ts) return "var(--theme-red)";
const h = ageMs(ts) / 3600000;
if (h < 1) return "var(--theme-green)";
if (h < 24) return "var(--theme-orange)";
return "var(--theme-red)";
}
```
## Registered Repos — Sync Status
```js
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`
<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="font-size:2rem;font-weight:700;color:var(--theme-green)">${freshCount}</div>
<div style="font-size:0.8rem;color:#666">state synced &lt; 1h</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-red)">${staleCount}</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 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:0.8rem;color:#666">total active</div>
</div>
</div>
`);
```
```js
const table = html`<table style="width:100%;border-collapse:collapse;font-size:0.9rem">
<thead>
<tr style="border-bottom:2px solid #ddd">
<th style="text-align:left;padding:6px 8px">Repo</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 SBOM</th>
<th style="text-align:right;padding:6px 8px">Entries</th>
<th style="text-align:left;padding:6px 8px">Status</th>
</tr>
</thead>
<tbody>
${activeRepos
.sort((a, b) => ageMs(a.last_state_synced_at) - ageMs(b.last_state_synced_at))
.map(r => html`<tr style="border-bottom:1px solid #eee">
<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:${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_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">
<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>
</tr>`)
}
</tbody>
</table>`;
display(table);
```
---
## Gitea Inventory — Unregistered Repos
_Repos on Gitea (`coulomb` org) not yet tracked by the state-hub._
```js
const unregistered = inventory.unregistered ?? [];
```
```js
if (unregistered.length === 0) {
display(html`<p style="color:var(--theme-green);font-weight:500">🎉 All Gitea repos are registered!</p>`);
} else {
display(html`
<table style="width:100%;border-collapse:collapse;font-size:0.9rem">
<thead>
<tr style="border-bottom:2px solid #ddd">
<th style="text-align:left;padding:6px 8px">Repo</th>
<th style="text-align:left;padding:6px 8px">Language</th>
<th style="text-align:left;padding:6px 8px">Description</th>
<th style="text-align:left;padding:6px 8px">Onboard</th>
</tr>
</thead>
<tbody>
${unregistered.map(r => html`<tr style="border-bottom:1px solid #eee">
<td style="padding:6px 8px;font-weight:500">
<a href="${r.gitea_url}" target="_blank" style="color:inherit">${r.gitea_name}</a>
</td>
<td style="padding:6px 8px;color:#777">${r.language || "—"}</td>
<td style="padding:6px 8px;color:#555">${r.description || "—"}</td>
<td style="padding:6px 8px;font-size:0.75rem;color:#999">
make register-project DOMAIN=? PROJECT_PATH=/home/worsch/${r.gitea_name}
</td>
</tr>`)}
</tbody>
</table>
`);
}
```
---
## Hub-Only Repos
_Registered in the state-hub but no matching Gitea repo found._
```js
const hubOnly = inventory.hub_only ?? [];
```
```js
if (hubOnly.length === 0) {
display(html`<p style="color:#666">None — all hub repos have a Gitea counterpart.</p>`);
} else {
display(html`<ul>${hubOnly.map(r => html`<li><code>${r.slug}</code> — domain: ${r.domain}, status: ${r.status}</li>`)}</ul>`);
}
```
---
_Sync legend: 🟢 &lt; 1h &nbsp; 🟠 124h &nbsp; 🔴 &gt; 24h or never_
_Gitea token required for full inventory — set <code>GITEA_TOKEN</code> in <code>state-hub/.env</code>._