Files
state-hub/dashboard/src/contributions.md
tegwick 9f744dd7f3 feat(ep-td+dashboard): complete CUST-WP-0004 EP/TD tracking workstream
EP catalogue (all domains):
- EP-RAIL-001 ep_id patched (schema fix: add ep_id to EPUpdate)
- EP-RAIL-003 (git bare-repo mirrors) and EP-RAIL-004 (offsite secondary
  backup) registered from railiance-cluster/docs/backup-restore.md
- EP-CUST-003..007 ep_ids assigned to existing custodian EPs
- EP-CUST-008 (State Hub API auth) and EP-CUST-009 (update_workstream MCP
  tool) registered as new custodian extension points

TD catalogue (railiance — first 5 items):
- TD-RAIL-001: backup cron runs as root without audit trail (high/security)
- TD-RAIL-002: k3s kubeconfig world-readable mode 644 (medium/security)
- TD-RAIL-003: no Ansible role unit tests (medium/test)
- TD-RAIL-004: age key extracted via awk — fragile (medium/impl)
- TD-RAIL-005: etcd snapshot retention uncoordinated (low/impl)

Dashboard (T08 + T10):
- Extract API URL and POLL to src/components/config.js; all 15 pages
  now import from the shared module (contributions/goals keep custom POLL)
- Shared .kpi-infobox, .filter-bar, .filter-search/.filter-owner CSS
  moved to observablehq.config.js head <style> block; removed from 9 pages
- Build: 0 errors, 0 warnings

API (T09):
- progress.py: limit param now Query(100, le=1000) — prevents unbounded
  list requests; closes TD-CUST-004 for the only endpoint that had limit

CUST-WP-0004 marked completed (all 10 tasks done).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 01:40:52 +01:00

6.6 KiB

title
title
Contributions
import {API} from "./components/config.js";
const POLL = 30_000;
// Live poll for contributions
const contribState = (async function*() {
  while (true) {
    let data = [], ok = false;
    try {
      const r = await fetch(`${API}/contributions/`);
      ok = r.ok;
      data = ok ? await r.json() : [];
    } catch {}
    yield {data, ok, ts: new Date()};
    await new Promise(res => setTimeout(res, POLL));
  }
})();
const contribs = contribState.data ?? [];
const _ok = contribState.ok ?? false;
const _ts = contribState.ts;

Contributions

import {injectTocTop} from "./components/toc-sidebar.js";
import {withDocHelp}  from "./components/doc-overlay.js";

const _liveEl = html`<div class="live-indicator">
  <span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
  ${_ok ? `Live · ${_ts?.toLocaleTimeString()}` : html`<span style="color:red">API offline</span>`}
</div>`;
withDocHelp(_liveEl, "/docs/live-data");
injectTocTop("live-indicator", _liveEl);

const _h1 = document.querySelector("#observablehq-main h1");
if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/contributions"); }
// Filters
const typeFilter  = Inputs.select(["all", "br", "fr", "ep", "upr"], {label: "Type", value: "all"});
const statFilter  = Inputs.select(
  ["all", "draft", "submitted", "acknowledged", "accepted", "rejected", "merged", "withdrawn"],
  {label: "Status", value: "all"}
);
const repoFilter  = Inputs.text({label: "Target repo", placeholder: "filter by repo…"});
display(html`<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem">
  ${typeFilter}${statFilter}${repoFilter}
</div>`);
const tf = typeFilter.value;
const sf = statFilter.value;
const rf = repoFilter.value?.trim().toLowerCase() ?? "";

const filtered = contribs.filter(c =>
  (tf === "all" || c.type === tf) &&
  (sf === "all" || c.status === sf) &&
  (!rf || (c.target_repo ?? "").toLowerCase().includes(rf))
);

Summary

const typeLabels = {br: "Bug Report", fr: "Feature Request", ep: "Extension Point", upr: "Upstream PR"};
const typeCounts = Object.fromEntries(["br","fr","ep","upr"].map(t => [
  t, contribs.filter(c => c.type === t).length
]));
const needsFollowUp = contribs.filter(c => ["submitted","acknowledged"].includes(c.status)).length;

display(html`<div class="grid grid-cols-5" style="gap:1rem;margin-bottom:1.5rem">
  <div class="card">
    <h3>Total</h3>
    <p class="big-num">${contribs.length}</p>
  </div>
  ${["br","fr","ep","upr"].map(t => html`
    <div class="card">
      <h3>${typeLabels[t]}</h3>
      <p class="big-num">${typeCounts[t]}</p>
    </div>
  `)}
</div>
${needsFollowUp > 0 ? html`<div class="follow-up-banner">⚠ ${needsFollowUp} contribution(s) awaiting upstream response (submitted / acknowledged)</div>` : ""}
`);

Status Kanban

const statusCols = [
  {key: "draft",        label: "Draft",       color: "#aaa"},
  {key: "submitted",    label: "Submitted",   color: "steelblue"},
  {key: "acknowledged", label: "Acknowledged",color: "#f0a500"},
  {key: "accepted",     label: "Accepted",    color: "#4caf50"},
  {key: "merged",       label: "Merged",      color: "#2e7d32"},
  {key: "rejected",     label: "Rejected",    color: "#e53935"},
  {key: "withdrawn",    label: "Withdrawn",   color: "#bbb"},
];

const colMap = {};
for (const c of filtered) {
  (colMap[c.status] = colMap[c.status] ?? []).push(c);
}

const activeCols = statusCols.filter(s => colMap[s.key]?.length);
if (activeCols.length === 0) {
  display(html`<p style="color:gray">No contributions match the current filters.</p>`);
} else {
  display(html`<div class="kanban">
    ${activeCols.map(s => html`
      <div class="kanban-col">
        <div class="kanban-header" style="border-bottom:2px solid ${s.color}">${s.label} <span class="kanban-count">${colMap[s.key].length}</span></div>
        ${colMap[s.key].map(c => html`
          <div class="contrib-card">
            <div class="contrib-badge contrib-badge-${c.type}">${c.type.toUpperCase()}</div>
            <div class="contrib-title">${c.title}</div>
            ${c.target_org || c.target_repo ? html`<div class="contrib-repo">${[c.target_org, c.target_repo].filter(Boolean).join("/")}</div>` : ""}
            ${c.body_path ? html`<div class="contrib-path">${c.body_path}</div>` : ""}
            <div class="contrib-date">${new Date(c.created_at).toLocaleDateString()}</div>
          </div>
        `)}
      </div>
    `)}
  </div>`);
}

All Contributions

display(Inputs.table(filtered.map(c => ({
  Type:    c.type.toUpperCase(),
  Title:   c.title,
  Status:  c.status,
  Target:  [c.target_org, c.target_repo].filter(Boolean).join("/") || "—",
  Created: new Date(c.created_at).toLocaleDateString(),
})), {maxWidth: 900}));
<style> .live-indicator { font-size: 0.8rem; color: gray; padding: 0.55rem 0.7rem; margin-bottom: 0.75rem; } .card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; } .big-num { font-size: 2.2rem; font-weight: bold; margin: 0.25rem 0; } .follow-up-banner { background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 0.5rem 0.9rem; margin-bottom: 1rem; font-size: 0.9rem; } .kanban { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; } .kanban-col { background: var(--theme-background-alt); border-radius: 8px; padding: 0.75rem; } .kanban-header { font-weight: 600; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.04em; padding-bottom: 0.5rem; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.4rem; } .kanban-count { font-size: 0.75rem; background: var(--theme-background); border-radius: 10px; padding: 0.1rem 0.4rem; font-weight: 500; } .contrib-card { background: var(--theme-background); border-radius: 6px; padding: 0.6rem 0.75rem; margin-bottom: 0.5rem; } .contrib-badge { display: inline-block; font-size: 0.65rem; font-weight: 700; border-radius: 3px; padding: 0.1rem 0.35rem; margin-bottom: 0.25rem; } .contrib-badge-br { background: #fde8e8; color: #c62828; } .contrib-badge-fr { background: #e3f2fd; color: #1565c0; } .contrib-badge-ep { background: #f3e5f5; color: #6a1b9a; } .contrib-badge-upr { background: #e8f5e9; color: #2e7d32; } .contrib-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 0.2rem; line-height: 1.3; } .contrib-repo { font-size: 0.75rem; color: steelblue; font-family: monospace; } .contrib-path { font-size: 0.7rem; color: gray; font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .contrib-date { font-size: 0.7rem; color: gray; margin-top: 0.3rem; } </style>