generated from coulomb/repo-seed
feat(dashboard): Repos page with coverage map; expose last_sbom_at on RepoRead
- RepoRead schema: add last_sbom_at + sbom_source fields (already in model, now surfaced in API response) - repos.md: new dashboard page — KPI row (total/domains/ingested/gaps), domain-grouped coverage map with SBOM/EP/TD chips, per-repo table with gap highlighting, domain filter + gap-only toggle, ingest how-to section - observablehq.config.js: add Repos after Domains in nav Coverage state: 3 repos registered (custodian×1, railiance×2); 2 ingested (the-custodian + railiance-hosts), 1 gap (railiance-bootstrap — infra-only, no lockfile, expected) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,5 +33,7 @@ class RepoRead(BaseModel):
|
||||
description: str | None = None
|
||||
status: str
|
||||
topic_id: uuid.UUID | None = None
|
||||
sbom_source: str | None = None
|
||||
last_sbom_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -8,6 +8,7 @@ export default {
|
||||
{ name: "Decisions", path: "/decisions" },
|
||||
{ name: "Progress", path: "/progress" },
|
||||
{ name: "Domains", path: "/domains" },
|
||||
{ name: "Repos", path: "/repos" },
|
||||
{ name: "Extension Points", path: "/extensions" },
|
||||
{ name: "Technical Debt", path: "/techdept" },
|
||||
{ name: "Contributions", path: "/contributions" },
|
||||
|
||||
255
dashboard/src/repos.md
Normal file
255
dashboard/src/repos.md
Normal file
@@ -0,0 +1,255 @@
|
||||
---
|
||||
title: Repos
|
||||
---
|
||||
|
||||
```js
|
||||
const API = "http://127.0.0.1:8000";
|
||||
```
|
||||
|
||||
```js
|
||||
let _repos = [], _domains = [], _sbom = [], _eps = [], _tds = [], _contribs = [];
|
||||
try {
|
||||
[_repos, _domains, _sbom, _eps, _tds, _contribs] = await Promise.all([
|
||||
fetch(`${API}/repos/`).then(r => r.ok ? r.json() : []),
|
||||
fetch(`${API}/domains/`).then(r => r.ok ? r.json() : []),
|
||||
fetch(`${API}/sbom/`).then(r => r.ok ? r.json() : []),
|
||||
fetch(`${API}/extension-points/`).then(r => r.ok ? r.json() : []),
|
||||
fetch(`${API}/technical-debt/`).then(r => r.ok ? r.json() : []),
|
||||
fetch(`${API}/contributions/`).then(r => r.ok ? r.json() : []),
|
||||
]);
|
||||
} catch {}
|
||||
```
|
||||
|
||||
```js
|
||||
const repos = _repos ?? [];
|
||||
const domains = _domains ?? [];
|
||||
const sbom = _sbom ?? [];
|
||||
const eps = _eps ?? [];
|
||||
const tds = _tds ?? [];
|
||||
const contribs = _contribs ?? [];
|
||||
|
||||
// Lookups
|
||||
const domainById = Object.fromEntries(domains.map(d => [d.id, d]));
|
||||
const domainBySlug = Object.fromEntries(domains.map(d => [d.slug, d]));
|
||||
|
||||
// Per-repo SBOM stats (from sbom entries)
|
||||
const sbomByRepo = {};
|
||||
for (const e of sbom) {
|
||||
if (!sbomByRepo[e.repo_id]) sbomByRepo[e.repo_id] = { count: 0, snapshot_at: e.snapshot_at };
|
||||
sbomByRepo[e.repo_id].count++;
|
||||
}
|
||||
|
||||
// Per-domain counts
|
||||
const epByDomain = {};
|
||||
const tdByDomain = {};
|
||||
const contribByDomain = {};
|
||||
|
||||
// EPs are domain-scoped
|
||||
for (const ep of eps) {
|
||||
if (!ep.status || ep.status === "open" || ep.status === "in_progress") {
|
||||
epByDomain[ep.domain] = (epByDomain[ep.domain] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
for (const td of tds) {
|
||||
if (!td.status || td.status === "open" || td.status === "in_progress") {
|
||||
tdByDomain[td.domain] = (tdByDomain[td.domain] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
// Contributions: try to map via workstream → topic → domain (not available here; skip for now)
|
||||
// Use domain slug from contributions' related_workstream if available — fallback: count by type only
|
||||
|
||||
// Build enriched repo rows
|
||||
const repoRows = repos
|
||||
.filter(r => r.status === "active")
|
||||
.map(r => {
|
||||
const domain = domainById[r.domain_id];
|
||||
const domSlug = domain?.slug ?? "—";
|
||||
const domName = domain?.name ?? "—";
|
||||
const sbomData = sbomByRepo[r.id];
|
||||
const hasSbom = !!sbomData || !!r.last_sbom_at;
|
||||
const pkgCount = sbomData?.count ?? 0;
|
||||
const lastScan = r.last_sbom_at
|
||||
? new Date(r.last_sbom_at).toLocaleDateString()
|
||||
: (sbomData?.snapshot_at ? new Date(sbomData.snapshot_at).toLocaleDateString() : null);
|
||||
return {
|
||||
_id: r.id,
|
||||
_domSlug: domSlug,
|
||||
_hasSbom: hasSbom,
|
||||
repo: r.slug,
|
||||
domain: domName,
|
||||
path: r.local_path ?? "—",
|
||||
sbom: hasSbom ? `✓ ${lastScan}` : "⚠ not ingested",
|
||||
pkgs: pkgCount || (hasSbom ? "—" : 0),
|
||||
eps: epByDomain[domSlug] ?? 0,
|
||||
tds: tdByDomain[domSlug] ?? 0,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a._domSlug.localeCompare(b._domSlug) || a.repo.localeCompare(b.repo));
|
||||
|
||||
const gapCount = repoRows.filter(r => !r._hasSbom).length;
|
||||
const coveredCount = repoRows.filter(r => r._hasSbom).length;
|
||||
```
|
||||
|
||||
# Repos
|
||||
|
||||
```js
|
||||
// Summary KPIs
|
||||
display(html`<div class="kpi-row">
|
||||
<div class="card">
|
||||
<h3>Registered Repos</h3>
|
||||
<p class="big-num">${repoRows.length}</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Domains</h3>
|
||||
<p class="big-num">${new Set(repoRows.map(r => r._domSlug)).size}</p>
|
||||
</div>
|
||||
<div class="card ${coveredCount < repoRows.length ? '' : ''}">
|
||||
<h3>SBOM Ingested</h3>
|
||||
<p class="big-num">${coveredCount} / ${repoRows.length}</p>
|
||||
</div>
|
||||
<div class="card ${gapCount > 0 ? 'card-warn' : 'card-ok'}">
|
||||
<h3>SBOM Gaps</h3>
|
||||
<p class="big-num">${gapCount}</p>
|
||||
<small>${gapCount === 0 ? "✓ All repos covered" : `⚠ ${gapCount} repo(s) not ingested`}</small>
|
||||
</div>
|
||||
</div>`);
|
||||
```
|
||||
|
||||
## Coverage Map
|
||||
|
||||
```js
|
||||
// Group by domain
|
||||
const byDomain = {};
|
||||
for (const r of repoRows) {
|
||||
(byDomain[r._domSlug] = byDomain[r._domSlug] ?? []).push(r);
|
||||
}
|
||||
|
||||
const domainBlocks = Object.entries(byDomain).sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
if (domainBlocks.length === 0) {
|
||||
display(html`<p style="color:gray">No repos registered. Run <code>make add-repo DOMAIN=<slug> SLUG=<slug> NAME="..." PATH=/path</code>.</p>`);
|
||||
} else {
|
||||
display(html`<div class="domain-list">
|
||||
${domainBlocks.map(([slug, rows]) => {
|
||||
const dom = domainBySlug[slug];
|
||||
const allCovered = rows.every(r => r._hasSbom);
|
||||
const hasEps = (epByDomain[slug] ?? 0) > 0;
|
||||
const hasTds = (tdByDomain[slug] ?? 0) > 0;
|
||||
return html`
|
||||
<div class="domain-block ${allCovered ? '' : 'domain-gap'}">
|
||||
<div class="domain-header">
|
||||
<span class="domain-name">${dom?.name ?? slug}</span>
|
||||
<span class="domain-chips">
|
||||
${allCovered
|
||||
? html`<span class="chip chip-ok">SBOM ✓</span>`
|
||||
: html`<span class="chip chip-warn">SBOM ⚠</span>`}
|
||||
${hasEps
|
||||
? html`<span class="chip chip-ok">EPs: ${epByDomain[slug]}</span>`
|
||||
: html`<span class="chip chip-neutral">EPs: —</span>`}
|
||||
${hasTds
|
||||
? html`<span class="chip chip-ok">TDs: ${tdByDomain[slug]}</span>`
|
||||
: html`<span class="chip chip-neutral">TDs: —</span>`}
|
||||
</span>
|
||||
</div>
|
||||
<table class="repo-table">
|
||||
<thead><tr>
|
||||
<th>Repo</th>
|
||||
<th>SBOM</th>
|
||||
<th>Packages</th>
|
||||
<th>Local path</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${rows.map(r => html`<tr class="${r._hasSbom ? '' : 'row-gap'}">
|
||||
<td class="repo-cell"><code>${r.repo}</code></td>
|
||||
<td class="${r._hasSbom ? 'sbom-ok' : 'sbom-warn'}">${r.sbom}</td>
|
||||
<td>${r.pkgs}</td>
|
||||
<td class="path-cell">${r.path}</td>
|
||||
</tr>`)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>`);
|
||||
}
|
||||
```
|
||||
|
||||
## All Repos Table
|
||||
|
||||
```js
|
||||
const domainFilter = Inputs.select(["all", ...new Set(repoRows.map(r => r._domSlug)).values()], {label: "Domain", value: "all"});
|
||||
const gapFilter = Inputs.toggle({label: "Gaps only (no SBOM)", value: false});
|
||||
display(html`<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem">${domainFilter}${gapFilter}</div>`);
|
||||
```
|
||||
|
||||
```js
|
||||
const filteredRows = repoRows.filter(r =>
|
||||
(domainFilter.value === "all" || r._domSlug === domainFilter.value) &&
|
||||
(!gapFilter.value || !r._hasSbom)
|
||||
);
|
||||
|
||||
display(Inputs.table(filteredRows.map(r => ({
|
||||
Repo: r.repo,
|
||||
Domain: r.domain,
|
||||
SBOM: r.sbom,
|
||||
Pkgs: r.pkgs,
|
||||
"EPs (domain)": r.eps || "—",
|
||||
"TDs (domain)": r.tds || "—",
|
||||
Path: r.path,
|
||||
})), {maxWidth: 1000}));
|
||||
```
|
||||
|
||||
## How to Ingest a Repo
|
||||
|
||||
```js
|
||||
display(html`<div class="howto">
|
||||
<h4>Register a new repo</h4>
|
||||
<pre>cd ~/the-custodian/state-hub
|
||||
make add-repo DOMAIN=<slug> SLUG=<repo-slug> NAME="Display Name" PATH=/absolute/path</pre>
|
||||
|
||||
<h4>Ingest SBOM (single ecosystem, auto-detect lockfile at root)</h4>
|
||||
<pre>make ingest-sbom REPO=<slug> REPO_PATH=/absolute/path</pre>
|
||||
|
||||
<h4>Ingest SBOM (multi-ecosystem repo — scans all lockfiles recursively)</h4>
|
||||
<pre>make ingest-sbom REPO=<slug> SCAN=1 REPO_PATH=/absolute/path</pre>
|
||||
|
||||
<h4>Infra-only repos (Ansible/shell — no lockfile)</h4>
|
||||
<p>Register the repo for inventory purposes. SBOM gap is expected and intentional.
|
||||
Terraform providers are tracked via <code>.terraform.lock.hcl</code> (auto-detected by <code>--scan</code>).</p>
|
||||
</div>`);
|
||||
```
|
||||
|
||||
<style>
|
||||
.kpi-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; }
|
||||
.card-warn { border: 2px solid #e53935; }
|
||||
.card-ok { border: 2px solid #2e7d32; }
|
||||
.big-num { font-size: 2.2rem; font-weight: bold; margin: 0.25rem 0; }
|
||||
|
||||
.domain-list { display: flex; flex-direction: column; gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.domain-block { background: var(--theme-background-alt); border-radius: 8px; padding: 0.9rem 1rem; }
|
||||
.domain-gap { border-left: 4px solid #f0a500; }
|
||||
.domain-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.65rem; flex-wrap: wrap; }
|
||||
.domain-name { font-weight: 700; font-size: 1rem; }
|
||||
.domain-chips { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||
.chip { font-size: 0.72rem; font-weight: 600; border-radius: 10px; padding: 0.1rem 0.5rem; }
|
||||
.chip-ok { background: #e8f5e9; color: #2e7d32; }
|
||||
.chip-warn { background: #fff3cd; color: #856404; }
|
||||
.chip-neutral { background: var(--theme-background); color: gray; border: 1px solid var(--theme-foreground-faint); }
|
||||
|
||||
.repo-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
.repo-table th { text-align: left; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: gray; padding: 0.3rem 0.5rem; border-bottom: 1px solid var(--theme-foreground-faint); }
|
||||
.repo-table td { padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--theme-foreground-faint); }
|
||||
.repo-table tr:last-child td { border-bottom: none; }
|
||||
.row-gap { background: #fffbf0; }
|
||||
.repo-cell { font-family: monospace; }
|
||||
.sbom-ok { color: #2e7d32; font-weight: 600; }
|
||||
.sbom-warn { color: #856404; font-weight: 600; }
|
||||
.path-cell { font-family: monospace; font-size: 0.78rem; color: gray; }
|
||||
|
||||
.howto { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem 1.25rem; margin-top: 0.5rem; }
|
||||
.howto h4 { margin: 0.75rem 0 0.3rem; font-size: 0.9rem; }
|
||||
.howto h4:first-child { margin-top: 0; }
|
||||
.howto pre { background: var(--theme-background); border-radius: 4px; padding: 0.5rem 0.75rem; font-size: 0.82rem; overflow-x: auto; margin: 0 0 0.5rem; }
|
||||
.howto p { font-size: 0.85rem; color: gray; margin: 0 0 0.5rem; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user