Skip to content

Pipeline Design 192

Seth Ford edited this page Mar 1, 2026 · 2 revisions

Now I have full context. Here is the Architecture Decision Record:


Design: [Test] Skill Injection E2E: Add pipeline progress indicator to dashboard

Context

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:

  1. Cannot be tested with happy-dom — SVG coordinates are calculated numerically, not as assertable semantic attributes; the existing pipeline-rail.test.ts only tests string shape, not accessibility.
  2. Is not accessible — no role, aria-valuenow, or aria-label; screen readers see raw SVG text/node labels without context.
  3. Has no ETA — the server already exposes /api/predictions/:issue (used by api.fetchPredictions) but nothing in the Overview tab consumes it.
  4. 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.


Decision

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/closurerenderPipelineProgress({ 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.tsconst 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, a fetchPredictions loop updates #pipeline-eta-{issue} spans directly. This avoids setting up an observable/reactive pattern where none exists in the codebase.
  • STAGES array as canonical sourcestagesDone.length / STAGES.length determines the percentage. The server's stagesDone: string[] on PipelineInfo is the authoritative list of completed stages.

Alternatives Considered

  1. 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
  2. Option C: Reactive state subscription (store.subscribe)

    • Pros: ETA would update without DOM patching; more "reactive" architecture
    • Cons: No store.subscribe pattern exists anywhere in the codebase (state.ts exposes store.get/set only); 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
  3. Option D: Server-sent ETA embedded in FleetState

    • Pros: ETA arrives in the WebSocket message; no separate fetch
    • Cons: Requires modifying server.ts, FleetState type, 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

Component Diagram

┌─────────────────────────────────────────────────────────┐
│  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

Interface Contracts

// 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
}

Data Flow

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.


Error Boundaries

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

Implementation Plan

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-level etaCache, replace renderPipelineSVG call in card HTML, add async ETA fetch loop
  • dashboard/public/styles.css — add .pipeline-progress-* and .pipeline-stage-chip CSS rules after the .pipeline-meta block (~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:

  1. renderPipelineSVG import in overview.ts — after removing the SVG call from pipeline cards, verify whether renderPipelineSVG is still used elsewhere in overview.ts. If not, remove the import to avoid a dead-import lint warning. (pipelines.ts has its own import of renderPipelineSVG; it is unaffected.)
  2. stagesDone vs STAGES mismatchSTAGES has 11 entries (intake → monitor). The server may send stagesDone entries that are not in STAGES (e.g. a future stage name). The indexOf check in chip rendering safely falls through to the neutral state; the percentage cap at 100 prevents overflow.
  3. ETA cache growthetaCache is module-level and never cleared. For the typical use case (<10 active pipelines) this is immaterial. If a pipeline completes and is removed from data.pipelines, its cache entry becomes a stale orphan but causes no visible issue.
  4. CSS animation stage-glow — the .pipeline-stage-chip.active rule references a stage-glow keyframe animation. This keyframe must be defined in styles.css (either pre-existing or added alongside the new rules); omitting it silently degrades to no animation.

Validation Criteria

Accessibility

  • role="progressbar" present on wrapper element
  • aria-valuenow equals pipeline.stagesDone.length for each rendered card
  • aria-valuemin="0" and aria-valuemax="11" present
  • aria-label contains issue number, stage name (or "pending"), and percentage

Correctness

  • Progress fill width style equals Math.round(stagesDone.length / STAGES.length * 100)%
  • Chips with .done class count equals stagesDone.length
  • Exactly one chip has .active class when pipeline.stage matches a STAGES entry
  • ETA span is empty when eta_s is undefined or 0; shows "~Xm remaining" when eta_s > 0

Security

  • <script> in pipeline.title does not appear unescaped in rendered output
  • All chip title attributes use escapeHtml(stage) even for static values

Tests

  • All 16 unit tests in pipeline-progress.test.ts pass
  • npm test exits 0 — no regressions in overview.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-stages is display: none; progress bar track remains visible

Regression

  • Pipeline card click navigation to Pipelines tab still works (click listener is re-attached after innerHTML write)
  • renderPipelineSVG still renders correctly in the Pipelines detail panel (not affected by this change)

Test Pyramid Breakdown

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

Clone this wiki locally