state-hub/dashboard/src/sbom.md
tegwick afac54ec09 feat(state-hub): v0.3 MCP tools + dashboard pages for contributions and SBOM
MCP server additions (5 tools + 3 resources):
- register_contribution(), update_contribution_status(), get_contributions()
- ingest_sbom_tool(repo_slug, lockfile_path) — shells out to ingest_sbom.py
- get_licence_report()
- state://contributions, state://sbom/aggregated, state://sbom/{repo_slug}

Dashboard pages:
- contributions.md — live-polled Kanban by status (draft→merged), filter bar
  (type/status/repo), KPI grid (total + per type), follow-up banner, full table
- sbom.md — licence distribution bar chart (Plot), copyleft risk section,
  package table with ecosystem/direct/dev filters, repo-slug resolution
- data/contributions.json.py, data/sbom.json.py — Observable data loaders
- index.md — added Contribution & SBOM Health KPI row (total, follow-up count,
  copyleft risk indicator; sourced from state summary fields)
- observablehq.config.js — added Contributions + SBOM to nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:28:41 +01:00

4.9 KiB

title
SBOM
const API = "http://127.0.0.1:8000";
// Fetch SBOM data on load
let _entries = [], _report = {groups: [], copyleft_direct_count: 0}, _repos = [];
try {
  [_entries, _report, _repos] = await Promise.all([
    fetch(`${API}/sbom/`).then(r => r.ok ? r.json() : []),
    fetch(`${API}/sbom/report/licences/`).then(r => r.ok ? r.json() : {groups:[], copyleft_direct_count: 0}),
    fetch(`${API}/repos/`).then(r => r.ok ? r.json() : []),
  ]);
} catch {}
const entries  = _entries ?? [];
const report   = _report  ?? {groups: [], copyleft_direct_count: 0};
const repos    = _repos   ?? [];
const groups   = report.groups ?? [];
const riskCount = report.copyleft_direct_count ?? 0;

SBOM

Licence Risk

const riskBadge = riskCount === 0
  ? html`<span class="risk-ok">✓ No copyleft in direct prod deps</span>`
  : html`<span class="risk-warn">⚠ ${riskCount} direct prod dep(s) with copyleft licence</span>`;
display(html`<div class="kpi-row">
  <div class="card">
    <h3>Total Packages</h3>
    <p class="big-num">${entries.length}</p>
  </div>
  <div class="card">
    <h3>Repos Scanned</h3>
    <p class="big-num">${new Set(entries.map(e => e.repo_id)).size}</p>
  </div>
  <div class="card ${riskCount > 0 ? 'card-warn' : ''}">
    <h3>Licence Risk</h3>
    <p class="big-num">${riskCount}</p>
    <small>${riskBadge}</small>
  </div>
  <div class="card">
    <h3>Unique Licences</h3>
    <p class="big-num">${groups.length}</p>
  </div>
</div>`);

Licence Distribution

import * as Plot from "npm:@observablehq/plot";

if (groups.length === 0) {
  display(html`<p style="color:gray">No SBOM data ingested yet. Run <code>make ingest-sbom REPO=&lt;slug&gt;</code>.</p>`);
} else {
  const plotData = groups.slice(0, 15).map(g => ({
    licence: g.license_spdx ?? "(unknown)",
    count: g.count,
    copyleft: g.is_copyleft,
  }));
  display(Plot.plot({
    x: {label: "Packages"},
    y: {label: null, domain: plotData.map(d => d.licence)},
    color: {domain: [false, true], range: ["steelblue", "#e53935"], legend: true, tickFormat: d => d ? "Copyleft" : "Permissive"},
    marks: [
      Plot.barX(plotData, {y: "licence", x: "count", fill: "copyleft", tip: true}),
      Plot.ruleX([0]),
    ],
    marginLeft: 130,
    height: Math.max(80, plotData.length * 30 + 50),
    width: 600,
  }));
}

Copyleft Risk Detail

const copyleftGroups = groups.filter(g => g.is_copyleft);
if (copyleftGroups.length === 0) {
  display(html`<p style="color:green">✓ No copyleft packages found.</p>`);
} else {
  display(html`<div class="copyleft-section">
    ${copyleftGroups.map(g => html`
      <div class="copyleft-card">
        <span class="copyleft-badge">${g.license_spdx ?? "unknown"}</span>
        <span class="copyleft-count">${g.count} package(s)</span>
        <span class="copyleft-repos">${g.repos.join(", ")}</span>
      </div>
    `)}
  </div>`);
}

Package Table

// Filters
const ecoFilter  = Inputs.select(["all", "python", "node", "rust", "go", "java", "other"], {label: "Ecosystem", value: "all"});
const directOnly = Inputs.toggle({label: "Direct deps only", value: false});
const prodOnly   = Inputs.toggle({label: "Prod deps only (no dev)", value: false});
display(html`<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem">
  ${ecoFilter}${directOnly}${prodOnly}
</div>`);
// Build repo_id → slug lookup
const repoById = Object.fromEntries(_repos.map(r => [r.id, r.slug]));

const filteredEntries = entries.filter(e =>
  (ecoFilter.value === "all" || e.ecosystem === ecoFilter.value) &&
  (!directOnly.value || e.is_direct) &&
  (!prodOnly.value || !e.is_dev)
);

display(Inputs.table(filteredEntries.map(e => ({
  Package:   e.package_name,
  Version:   e.package_version ?? "—",
  Ecosystem: e.ecosystem,
  Licence:   e.license_spdx ?? "—",
  Repo:      repoById[e.repo_id] ?? e.repo_id?.slice(0, 8) ?? "—",
  Direct:    e.is_direct ? "✓" : "",
  Dev:       e.is_dev    ? "✓" : "",
})), {maxWidth: 900}));