-
Notifications
You must be signed in to change notification settings - Fork 1
Pipeline Design 192
Now I have full context. Here is the Architecture Decision Record:
The Overview tab's pipeline cards currently render an SVG node-graph (renderPipelineSVG in dashboard/src/components/charts/pipeline-rail.ts) to visualize stage progress. This SVG:
-
Cannot be tested with happy-dom — SVG coordinates are calculated numerically, not as assertable semantic attributes; the existing
pipeline-rail.test.tsonly tests string shape, not accessibility. -
Is not accessible — no
role,aria-valuenow, oraria-label; screen readers see raw SVG text/node labels without context. -
Has no ETA — the server already exposes
/api/predictions/:issue(used byapi.fetchPredictions) but nothing in the Overview tab consumes it. -
Horizontal overflow on mobile — the SVG is fixed-width (
STAGES.length * 80 + 40 = 920px) and does not flex-wrap on small screens.
The constraint driving the HTML-based approach is the existing test infrastructure: Vitest + happy-dom cannot meaningfully assert SVG geometry, but can assert HTML attributes like aria-valuenow and CSS class names.
Replace the SVG rail in Overview pipeline cards with a new renderPipelineProgress pure-function component that produces an accessible HTML progress indicator. The SVG is retained in the detail panel (pipelines.ts) where animated visuals remain appropriate.
Key design choices:
-
Pure render function, no class/closure —
renderPipelineProgress({ pipeline, eta_s? }): string. No internal state; called fresh on every WebSocket re-render cycle. Matches the pattern of all other chart components (renderPipelineSVG,renderBarChart,renderDonutChart,renderSparkline). -
Module-level ETA cache in
overview.ts—const etaCache = new Map<number, number>()lives at module scope (not inside the render function) so the async ETA fetch result survives re-renders without causing a full second render pass. -
Targeted DOM patch for ETA — after
container.innerHTML = html, afetchPredictionsloop updates#pipeline-eta-{issue}spans directly. This avoids setting up an observable/reactive pattern where none exists in the codebase. -
STAGESarray as canonical source —stagesDone.length / STAGES.lengthdetermines the percentage. The server'sstagesDone: string[]onPipelineInfois the authoritative list of completed stages.
-
Option B: Augment SVG rail with an overlay label
- Pros: No visual regression on pipeline cards; preserves animated pulse circle on active node
- Cons: SVG ARIA attributes are poorly supported (
role="progressbar"on<svg>is valid but has inconsistent AT support); ETA label placement requires absolute positioning over a variable-width SVG; two code paths to maintain; happy-dom SVG testing remains difficult - Rejected: Accessibility and testability goals cannot be met without replacing the SVG in the card context
-
Option C: Reactive state subscription (store.subscribe)
- Pros: ETA would update without DOM patching; more "reactive" architecture
- Cons: No
store.subscribepattern exists anywhere in the codebase (state.tsexposesstore.get/setonly); introducing it here would be an architectural mismatch; the targeted DOM patch is simpler and equally correct - Rejected: Over-engineering for a one-off async update; doesn't align with existing patterns
-
Option D: Server-sent ETA embedded in
FleetState- Pros: ETA arrives in the WebSocket message; no separate fetch
- Cons: Requires modifying
server.ts,FleetStatetype, and the daemon's state serialization — changes across 3+ layers for a display feature; prediction data is already behind a separate cacheable endpoint - Rejected: Scope creep; changes the data contract rather than the view layer
┌─────────────────────────────────────────────────────────┐
│ dashboard/src/views/overview.ts │
│ │
│ etaCache: Map<number, number> ← module-level │
│ │
│ renderOverviewPipelines(data: FleetState) │
│ │ │
│ ├── renderPipelineProgress(pipeline, eta_s) [NEW] │
│ │ │ │
│ │ ├── STAGES, STAGE_SHORT ← design/tokens.ts │
│ │ ├── escapeHtml, formatDuration ← core/helpers │
│ │ └── PipelineInfo ← types/api.ts │
│ │ │
│ └── (async) api.fetchPredictions(issue) │
│ │ │
│ └── GET /api/predictions/:issue │
│ └── server.ts: getMetricsHistory() │
│ └── stage_durations → eta_s │
└─────────────────────────────────────────────────────────┘
Kept as-is (no changes):
renderPipelineSVG ← used in pipelines.ts detail panel only
// dashboard/src/components/charts/pipeline-progress.ts
import { STAGES, STAGE_SHORT } from "../../design/tokens";
import { escapeHtml, formatDuration } from "../../core/helpers";
import type { PipelineInfo } from "../../types/api";
export interface PipelineProgressOptions {
pipeline: PipelineInfo; // must be a valid PipelineInfo; stage may be ""
eta_s?: number; // seconds until completion; undefined or 0 → no ETA label
}
// Pure function — never throws.
// Preconditions:
// - pipeline.stagesDone is string[] (may be empty)
// - pipeline.issue is a number (used as DOM id suffix)
// Postconditions:
// - returned string contains role="progressbar"
// - aria-valuenow = pipeline.stagesDone.length
// - aria-valuemax = STAGES.length (11)
// - aria-valuemin = "0"
// - aria-label contains "Pipeline #<issue>", stage name, and percentage
// - all user-controlled strings passed through escapeHtml()
export function renderPipelineProgress(opts: PipelineProgressOptions): string;ETA cache contract (overview.ts):
// Module-level — survives across render() calls, cleared on tab destroy()
const etaCache = new Map<number, number>(); // issue → eta_s
// Called after container.innerHTML is set:
for (const p of data.pipelines) {
api.fetchPredictions(p.issue).then((pred) => {
if (typeof pred.eta_s === "number") {
etaCache.set(p.issue, pred.eta_s);
const el = document.getElementById("pipeline-eta-" + p.issue);
if (el) el.textContent = pred.eta_s > 0 ? "~" + formatDuration(pred.eta_s) + " remaining" : "";
}
});
// No .catch() needed — fetchPredictions already catches in api.ts
}WebSocket message
→ store.set("fleetState", data)
→ overview.render(state: FleetState)
→ renderOverviewPipelines(data)
→ for each p of data.pipelines:
eta_s = etaCache.get(p.issue) // sync: may be undefined on first render
html += renderPipelineProgress({pipeline: p, eta_s})
→ container.innerHTML = html // synchronous DOM update
→ for each p: fetchPredictions(p.issue) // async fan-out, does NOT block render
→ etaCache.set(p.issue, eta_s)
→ document.getElementById("pipeline-eta-N").textContent = "~Xm remaining"
Why this order matters: The synchronous innerHTML write happens first using cached ETA (possibly stale on first load, but correct on subsequent renders). The async ETA fetch updates only the ETA span, avoiding a second full-card re-render that would cause flicker or lose click event listeners.
| Layer | Error | Handling |
|---|---|---|
renderPipelineProgress |
pipeline.stagesDone is undefined |
` |
renderPipelineProgress |
pipeline.stage is ""
|
Renders "pending" in aria-label; no chip gets .active class |
renderPipelineProgress |
eta_s is 0 or undefined
|
ETA span rendered empty; no "remaining" text |
renderPipelineProgress |
pipeline.title contains <script>
|
escapeHtml() in ariaLabel; no XSS vector |
api.fetchPredictions |
Network error or non-200 | Already wrapped in .catch(() => ({})) in api.ts:381; ETA cache entry never set; span stays empty |
overview.ts ETA patch |
#pipeline-eta-N not found (re-render removed card) |
if (el) guard; silent no-op |
Files to create:
-
dashboard/src/components/charts/pipeline-progress.ts— new pure-function component -
dashboard/src/components/charts/pipeline-progress.test.ts— 16 unit tests
Files to modify:
-
dashboard/src/views/overview.ts— add import, module-leveletaCache, replacerenderPipelineSVGcall in card HTML, add async ETA fetch loop -
dashboard/public/styles.css— add.pipeline-progress-*and.pipeline-stage-chipCSS rules after the.pipeline-metablock (~line 715); add@media (max-width: 480px)rule hiding.pipeline-progress-stages
Dependencies: None new. Reuses STAGES, STAGE_SHORT (tokens.ts), escapeHtml, formatDuration (helpers.ts), api.fetchPredictions (api.ts), PipelineInfo (types/api.ts).
Risk areas:
-
renderPipelineSVGimport inoverview.ts— after removing the SVG call from pipeline cards, verify whetherrenderPipelineSVGis still used elsewhere inoverview.ts. If not, remove the import to avoid a dead-import lint warning. (pipelines.tshas its own import ofrenderPipelineSVG; it is unaffected.) -
stagesDonevsSTAGESmismatch —STAGEShas 11 entries (intake → monitor). The server may sendstagesDoneentries that are not inSTAGES(e.g. a future stage name). TheindexOfcheck in chip rendering safely falls through to the neutral state; the percentage cap at 100 prevents overflow. -
ETA cache growth —
etaCacheis module-level and never cleared. For the typical use case (<10 active pipelines) this is immaterial. If a pipeline completes and is removed fromdata.pipelines, its cache entry becomes a stale orphan but causes no visible issue. -
CSS animation
stage-glow— the.pipeline-stage-chip.activerule references astage-glowkeyframe animation. This keyframe must be defined instyles.css(either pre-existing or added alongside the new rules); omitting it silently degrades to no animation.
Accessibility
-
role="progressbar"present on wrapper element -
aria-valuenowequalspipeline.stagesDone.lengthfor each rendered card -
aria-valuemin="0"andaria-valuemax="11"present -
aria-labelcontains issue number, stage name (or "pending"), and percentage
Correctness
- Progress fill
widthstyle equalsMath.round(stagesDone.length / STAGES.length * 100)% - Chips with
.doneclass count equalsstagesDone.length - Exactly one chip has
.activeclass whenpipeline.stagematches aSTAGESentry - ETA span is empty when
eta_sisundefinedor0; shows"~Xm remaining"wheneta_s > 0
Security
-
<script>inpipeline.titledoes not appear unescaped in rendered output - All chip
titleattributes useescapeHtml(stage)even for static values
Tests
- All 16 unit tests in
pipeline-progress.test.tspass -
npm testexits 0 — no regressions inoverview.test.ts,pipeline-rail.test.ts, or any other suite - Coverage for
pipeline-progress.ts: ≥ 70% statements, ≥ 60% branches (vitest coverage config)
Responsive
- At viewport width < 480px,
.pipeline-progress-stagesisdisplay: none; progress bar track remains visible
Regression
- Pipeline card click navigation to Pipelines tab still works (click listener is re-attached after
innerHTMLwrite) -
renderPipelineSVGstill renders correctly in the Pipelines detail panel (not affected by this change)
| Layer | Count | Coverage target |
|---|---|---|
Unit (pipeline-progress.test.ts) |
16 | 100% statements on the new pure function |
| Integration | 0 new |
overview.test.ts existing suite covers the overview render path |
| E2E | 0 new | Manual browser smoke: load dashboard with active pipelines, verify ETA appears after ~1s |
Critical paths explicitly covered by the 16 unit tests:
- Happy path: 3 stages done, active stage, ETA → correct
aria-valuenow, fill width, chip classes, ETA text - Error case 1:
eta_s = undefined→ ETA span empty, no"remaining"text - Error case 2:
stagesDone = [],stage = ""→0%,"pending"in aria-label, no chip marked active or done - Edge case 1: all 11 stages done →
100%,aria-valuenow="11" - Edge case 2: XSS in title →
<script>escaped in output - Edge case 3:
eta_s = 0→ treated as absent, no ETA displayed