From 3be88eee97e7372c0b5cd181a4a839ddcef8a1be Mon Sep 17 00:00:00 2001 From: "Jorge O. Castro" Date: Sat, 12 Sep 2026 17:17:39 -0400 Subject: [PATCH] fix(countme): count every published image variant under its family Clients send the published image name, so the id on the wire is `bluefin-lts-hwe-nvidia`, not `bluefin-lts`. The aggregation matched bare family ids and filtered on them in SQL, so every hardware and driver build was discarded before the counting rules ever saw it. - `normalizeCountmeRepo` strips `-nvidia` and `-hwe` and folds the result into its family, longest family first so an LTS image is never counted as flagship. - The SQL repo filter is gone; it pre-filtered to bare ids and dropped the variants before normalization could fold them. - Game mode stays an attribute, not an image. Removes adr/. --- adr/0001-agent-design-authorization.md | 83 ---- adr/0002-factory-page.md | 318 -------------- adr/0003-factory-two-level-navigation.md | 242 ---------- adr/0004-countme-counting-method.md | 101 ----- adr/0005-reports-2.0.md | 113 ----- adr/0006-multi-client-countme-reporting.md | 62 --- adr/0007-first-party-countme-aggregate.md | 175 -------- adr/README.md | 48 -- adr/research/cve-feasibility.md | 83 ---- .../factory-site-visualization-audit.md | 179 -------- adr/research/sparkline-system.md | 204 --------- adr/research/visual-form-prior-art.md | 413 ------------------ adr/template.md | 32 -- scripts/countme-worker.test.js | 77 +++- workers/countme-proxy/counts.mjs | 67 ++- workers/countme-proxy/index.mjs | 5 +- 16 files changed, 117 insertions(+), 2085 deletions(-) delete mode 100644 adr/0001-agent-design-authorization.md delete mode 100644 adr/0002-factory-page.md delete mode 100644 adr/0003-factory-two-level-navigation.md delete mode 100644 adr/0004-countme-counting-method.md delete mode 100644 adr/0005-reports-2.0.md delete mode 100644 adr/0006-multi-client-countme-reporting.md delete mode 100644 adr/0007-first-party-countme-aggregate.md delete mode 100644 adr/README.md delete mode 100644 adr/research/cve-feasibility.md delete mode 100644 adr/research/factory-site-visualization-audit.md delete mode 100644 adr/research/sparkline-system.md delete mode 100644 adr/research/visual-form-prior-art.md delete mode 100644 adr/template.md diff --git a/adr/0001-agent-design-authorization.md b/adr/0001-agent-design-authorization.md deleted file mode 100644 index 1a1f656be..000000000 --- a/adr/0001-agent-design-authorization.md +++ /dev/null @@ -1,83 +0,0 @@ -# 0001. Agent design change authorization - -- **Status:** Accepted -- **Date:** 2026-08-07 -- **Deciders:** @castrojo - -## Context - -`AGENTS.md` drew an absolute boundary: agents edit content only, never design. Any -request needing design had to "stop and ask a maintainer." - -The rule was right about the risk it was guarding. Agents that redesign a page to -satisfy a content request produce drive-by CSS, parallel components, and quiet -changes to fetch and fallback behavior on data-backed pages. That risk is real and -the boundary should stay. - -But the document described no state after approval. A maintainer could approve a -design change and there was still no mode in which an agent could implement it. -Every design task was therefore permanently undelegatable, no matter who -authorized it or how clearly it was specified. In practice this leaves two bad -options: the maintainer hand-writes all design work and becomes a bottleneck, or -design work gets smuggled through as "content" and the boundary erodes by -precedent. - -The trigger was a planned modernization of `/hive`, which is unambiguously a -design change to a data-backed page — exactly the work the old document could -neither authorize nor refuse cleanly. - -## Decision - -`AGENTS.md` defines two modes. - -**Default mode is content only**, with the same prohibitions as before. - -**Authorized mode** permits a design change when it traces to an approved record -in `adr/`. That record is the authorization. The agent implements what the record -specifies, cites it in the commit and pull request, and stops to report back if -the record turns out to be wrong or incomplete. - -An agent may draft a record when asked but may never approve its own -authorization, and verbal approval in chat does not count. The record must exist -before implementation code is written. - -Records live in `adr/` at the repository root, not `docs/`, because the docs -plugin is mounted at `routeBasePath: "/"` and anything under `docs/` publishes. - -## Scope - -**In scope:** the boundary model in `AGENTS.md`, the `adr/` directory, its README -and template, and this record. - -**Out of scope:** any change to `/hive`, its components, or its data pipeline. -That work needs its own record. - -## Consequences - -Design work becomes delegatable once it is specified, which is the point. The cost -is that specifying it is now real work — someone must write the record before code -starts, and vaguely specified design work will be refused rather than -improvised. - -The boundary gets stronger, not weaker. "Stop and ask a maintainer" previously had -no defined resolution, so pressure built to reinterpret design as content. -Escalation now has somewhere to go. - -`adr/` must stay out of `docs/`, or the records publish to the documentation site. - -## Alternatives considered - -**Leave `AGENTS.md` unchanged; humans write all design code.** Safest, and it -keeps the document short. Rejected because it makes the maintainer the sole -implementer of every design change, including rewrites of components in the -thousands of lines, and it does nothing about the pressure to relabel design as -content. - -**Record a one-off exception in the ticket or pull request.** Cheapest for a single -task. Rejected because the same gap reappears on the next design task, and a -precedent of ad-hoc exceptions erodes the rule faster than amending it honestly -does. - -**Enumerate specific always-allowed design changes** (for example, "tabs are -fine"). Rejected because the list cannot anticipate real requests, and every -entry is a permanent hole that applies even where it does not fit. diff --git a/adr/0002-factory-page.md b/adr/0002-factory-page.md deleted file mode 100644 index 81da1d3b6..000000000 --- a/adr/0002-factory-page.md +++ /dev/null @@ -1,318 +0,0 @@ -# 0002. Rename /hive to /factory and absorb project-level factory content - -- **Status:** Accepted -- **Date:** 2026-08-07 -- **Deciders:** @castrojo -- **Authorizes:** design changes to `src/pages/hive.tsx`, - `src/components/HiveFactoryDashboard.tsx`, their CSS, routes, navigation, and a - new data pipeline. See ADR 0001 for what that authorization means. - -## Context - -Project-level status lives in two places that split the wrong way. - -`factory.projectbluefin.io` is a GitHub Pages site from `projectbluefin/lab`. It -mixes two audiences: **project-level** status (release verdicts, build health, -image freshness, recent runs, open bugs) and **lab** internals (per-suite test -results and the ghost/exo-0 hardware — contributor cluster cards, BuildStream -cache heatmaps, the 40 Gbps link, work distribution, cold/warm speedup). - -`docs.projectbluefin.io/hive` renders `HiveFactoryDashboard` — live hive -orchestration state, and the natural home for project-level status, since docs is -where people already look. - -A reader who wants "is the project healthy?" has to visit both, and one of them is -mostly lab hardware they do not care about. Meanwhile `src/pages/hive.tsx` is -named after one component of a larger system. - -Facts verified while writing this record: - -- `src/pages/hive.tsx` renders `HiveFactoryDashboard` only. - `src/components/HiveDashboard.tsx` (1,902 lines) is imported nowhere. -- `hive.projectbluefin.io` currently 301s to `docs.projectbluefin.io/hive/` via a - Cloudflare rule that does not live in this repository. -- `lab.projectbluefin.io` already resolves to Cloudflare and returns 404 — - the signature of a hostname pointed at GitHub Pages that no repository claims. -- This repository's `CNAME` is `docs.projectbluefin.io`. GitHub Pages allows one - custom domain per repository, so a second hostname cannot be served by a bare - CNAME. - -## Decision - -**One public supersite, backed by two working tools.** `/factory` is the -culmination of the hive and factory dashboards: a single page an end user can -visit to understand the state of Bluefin. Maintainers continue to work in the -actual tools — the hosted hive at -`hosted-projectbluefin-knuckle-gjvq.hive.kubestellar.io` for operations, and the -lab site for lab internals. - -### Purpose and audience - -The audience is **end users**. The page is not an operations console and does not -need to be, because a better one already exists and maintainers use it. - -It is nonetheless **dense on purpose**. This page exists partly to show the -engineering behind Bluefin, so detail is a feature rather than clutter: agent -state, governor mode, token budget, cadence, ACMM level, advisories, and merge -statistics all stay, alongside the factory's release and build data. An end user -is not expected to act on every number. They are expected to come away -understanding that a serious automated system is running behind the distribution. - -This is the reason the structure below pairs an at-a-glance summary with deep -detail rather than choosing between them: the summary serves comprehension, and -the depth serves credibility. - -### Naming and routing - -- The page becomes **Factory**, served at `/factory`. The navbar shows "Factory" - only; hive content lives inside the page rather than as its own entry. -- `/hive` is **not** redirected. No redirect plugin is added. Links to the old - path break, which is accepted deliberately: the correct structure matters more - than preserving a URL, and an unreviewed redirect layer is its own maintenance - cost. In-repo links are corrected as part of this work. -- `hive.projectbluefin.io` is repointed at the hosted hive instance - (`hosted-projectbluefin-knuckle-gjvq.hive.kubestellar.io`), so the name refers - to the actual hive rather than to a docs page. -- `factory.projectbluefin.io` is repointed at this page, and the lab site moves to - `lab.projectbluefin.io`. - -Both hostname changes are **Cloudflare redirect rules or Workers, not CNAMEs** — -a bare CNAME to GitHub Pages 404s, as `lab.projectbluefin.io` currently -demonstrates. They are performed by a maintainer with zone edit rights; they are -outside this repository and outside CI. - -### Content moved here - -All project-level content from the factory site: release verdict per lane, rolling -7-day build health, what's-degrading, image status for every published lane -(bluefin, bluefin-lts, dakota, and the other container image lanes), recent runs, -and open bugs. - -### Content that stays on lab - -Per-suite test results and everything hardware: contributor cluster cards, -BuildStream cache utilization, the Thunderbolt link, work distribution, cold/warm -speedup, and layer rechunking. - -### Contributor onboarding - -`docs/agentic-contributing.md` keeps ownership of onboarding prose. `/factory` -carries a "contribute your compute" call to action, the trust tiers, and a short -"what you bring vs. what the hive provides" summary, then links to the hosted -hive's own contribute page for the rest. - -The hive's interactive setup-command generator at -`/contribute/operations` on the hosted instance is **not** reproduced here. It is -behavior spanning OS, CLI, mode, and runtime that would drift from the hive's own -generator the moment either changed. Link to it; do not clone it. - -### Structure - -Two tabs — **Live** (hive orchestration) and **Factory health** (build, release, -and image status) — with an always-visible status strip **above** the tabs. - -The strip and the tabs serve the two halves of the page's job. The strip answers -"how is Bluefin doing?" without any interaction, which is what most end users -want. The tabs organize the depth so density reads as substance rather than -noise. Neither works alone: without the strip the page is a wall, and without the -tabs the page is an unreadable scroll. - -Two conditions are part of this decision, not implementation detail: - -1. **Tab state is reflected in the URL**, so a specific view can be linked and - shared. -2. **An empty tab is never the default.** The status strip renders from whichever - sources are available, so "is anything on fire?" is answerable without - clicking. - -### Data - -Project-level data is **regenerated in this repository** from the GitHub Actions -API by a new `scripts/fetch-factory-*.js`, following the existing fetch-script and -`scripts/*.test.js` pattern, rather than consumed from the lab site. - -This avoids coupling to lab URLs that are being renamed as part of this same work. -The cost is duplicated logic, and one guardrail is mandatory: the release verdict -must **reference lab's ADR 0002 verdict definition rather than reimplement it from -memory**, or the two sites will publish contradicting verdicts on the same -release, which is worse than either site alone. - -### Fallback behavior changes deliberately - -Today each hive data source degrades to an invisible unavailable state. On -`/factory`, unavailability becomes **visible per panel**. This is a deliberate -change: a dashboard that silently renders less is indistinguishable from a healthy -one with less to report. - -Note that `hive-history.json` is a tracked CI seed while `registry-data.json` and -`hive-live-data.json` are untracked, so local and production builds fail -differently. Verify data-related changes in both. - -### Dead code - -`src/components/HiveDashboard.tsx` and `HiveDashboard.module.css` are deleted as -part of this work. - -A repository-wide sweep for unreferenced components found one other: -`src/components/WallpaperShowcase.tsx`, which is imported nowhere and is deleted -too. - -## Scope - -**In scope:** the route rename, navbar change, `hive.tsx` → -`factory.tsx`, restructuring `HiveFactoryDashboard` into the tabbed layout with -the status strip, the new factory data pipeline and its tests, per-panel -unavailable states, deleting `HiveDashboard.*`, and updating inbound links in -`docs/analytics.mdx` and `docs/agentic-contributing.md`. - -**Out of scope:** the Cloudflare and DNS changes (maintainer, outside this repo); -any change to the lab site or its pipeline; reproducing the setup-command -generator; test-suite and hardware content. - -## Consequences - -One page answers "is the project healthy?", and lab keeps the depth its audience -wants. The docs site gains a real data pipeline it must now maintain, including -drift against lab's verdict definition — the guardrail above is the mitigation, -and it is a standing cost, not a one-time one. - -External links to `docs.projectbluefin.io/hive` break, by choice. Links to -`hive.projectbluefin.io` intentionally change meaning and will land on the hosted -hive instead of documentation. - -Shipping is not atomic: the repository change and the two Cloudflare changes land -separately, so there is a window where naming is inconsistent. - -## Alternatives considered - -**Keep `/hive` and add factory content under the old name.** Rejected: the page -would be named after one component of the system it describes. - -**Consume lab's published JSON at build time.** Cheapest, and it avoids duplicated -logic. Rejected because it wires this site to URLs that this same decision -renames, guaranteeing a second breakage. - -**Three tabs, including contributor onboarding.** Rejected: it duplicates -`docs/agentic-contributing.md` and creates two owners for prose that will diverge. - -**No tabs, one sectioned page.** Genuinely close, and strictly better than tabs -implemented without URL state. Rejected because the two clusters serve distinct -questions and the combined scroll is long — but the two conditions above exist -precisely because this alternative wins if they are not met. - -**Anonymize the ghost hardware and keep the panels.** Rejected: it keeps the -pixels and discards the meaning, and the hardware belongs to the lab audience. - -**A minimal end-user summary with the telemetry stripped out.** Considered -seriously once the audience was settled as end users, on the reasoning that -governor mode and token budgets are not actionable for someone who just wants to -know whether to update. Rejected: it optimizes for comprehension alone and -discards the page's second job, which is to make the engineering behind Bluefin -visible. The status strip gives the summary without giving up the depth. - ---- - -## Addendum: visual system and panel inventory - -Added 2026-08-07 after an audit of both existing sites and prior-art research. -Supporting evidence is in `adr/research/`. The maintainer's direction for this -addendum: take **the best visualizations from each site** rather than porting -either, and treat sparklines as a first-class form. - -### Why neither site is ported as-is - -The factory site currently reads as broken. Measured from its own published -contracts: 1 of 15 image lanes has a release timestamp, 0 of 5 release lanes are -judged good, 0 of 5 cache cells have data, and 50 of 64 test-matrix rows are -waiting for results. - -The dominant cause is a single mapping defect, not real failure: `running` is -rendered as `failed`, with the reason string "latest publishing run concluded -running" and `finished_at: null`. In the same window 94% of publish builds -passed. **Any panel carried over must map in-flight work to a pending state and -never to a failure.** - -Two further constraints follow from that audit: - -- The Thunderbolt/USB-4 panel family (40 Gbps, 4.2x speedup, 145 s rechunk, - 45/55 split) is **hardcoded constants** inside an object whose own `status` is - `"unavailable"`, with no `source_url` or `collected_at`. It is not carried - unless a real collector is wired. -- The lab site publishes LAN IPs and private Argo URLs. Nothing carried here may - surface host addresses. - -### Sparkline system - -One component, extended from the existing `src/components/Sparkline.tsx`, which -is sound: pure, zero-dependency, inline SVG, SSR-safe, and it normalizes -min-to-max so it shows shape. - -`HiveFactoryDashboard.tsx` does not use it. It carries four private -reimplementations, three of which use a zero baseline, one of which is dead code, -and one of which coerces missing points to zero so gaps render as crashes to -zero. **Consolidate to the shared component first; delete the reimplementations.** - -Required extensions: variants `line`, `winloss`, `bars`, `bullet`; an explicit -`scale` of `minmax` | `zero` | shared `domain`; end/min/max markers; an optional -shaded normal-range band; and an optional `label` that switches the graphic from -`aria-hidden` to `role="img"` with a generated sentence. - -Rules that are part of this decision: - -1. **A sparkline never appears without its current value as a number.** The line - carries trend; the number carries scale. -2. **Small multiples share one domain.** Per-series autoscaling across a grid - makes every lane look identical regardless of value. This is the difference - between the best panel on the page and the most misleading one. -3. **Severity is encoded by intensity of a single hue, plus shape** — never by - hue alone, and not with the red/green pair currently in use. -4. **Gaps are drawn as gaps**, never interpolated or coerced to zero. -5. **Below a minimum point count, render "accumulating data"**, and render - "steady at zero" distinctly from missing. Silent disappearance is forbidden by - the unavailability rule above. - -Everything is hand-rolled inline SVG. Sankey is the sole exception and may use -the `echarts` dependency already present. - -### Data already available and unused - -The public hive registry (`https://hive.kubestellar.io/api/registry`, no auth) -serves `issueHistory` and `prHistory` at **672 points each, 16-minute interval, -spanning 7.5 days** — verified live. The current page renders 48 points of one -series and none of the other. Meanwhile six sparklines are drawn from a file -containing two points. The redesign inverts this. - -`scripts/fetch-hive-history.js` fetches 52 weekly commit buckets per contributor -and collapses them to three integers before writing. **Persist the weekly buckets** -so per-contributor activity sparklines can sit inline in leaderboard rows. - -`https:///api/contribute/fleet` is public and returns live named -contributors, their CLI backend and model, trust tier, and current task. Most -other hosted-hive endpoints now require auth. - -### Panels - -**Carry, from the factory site:** release verdict cards, rebuilt around a -plain-language headline with the digest retained; the rolling build-health KPI -strip with daily outcome bars; image freshness cards, rendering only lanes with -evidence and summarizing the rest in one honest line; the provenance and lineage -block, promoted from footnote to feature. - -**Carry, from the hive dashboard:** live agent state, the contributor -leaderboard, queue depth, and merge statistics. - -**New forms adopted:** a build-activity calendar heatmap; small multiples of -per-lane health on a shared domain; a Sankey of issue to agent to PR to merge to -release, with abandoned work shown as a visible dead end; and a live agent -activity pulse. - -**Dropped:** work distribution, layer rechunking, cache heat trend, the racing -leaderboard framing, raw poller run labels, and pie or donut charts. - -### Consequence: the SLA panel will look bad - -Median merge time currently runs far above the sub-30-minute target, so a bullet -graph against that target will show a large miss. It ships anyway. Publishing an -unflattering number next to the target is more credible than omitting it, and -omitting it would contradict the visible-unavailability rule. If the target -applies to a narrower class of work than the metric measures, the label says so. diff --git a/adr/0003-factory-two-level-navigation.md b/adr/0003-factory-two-level-navigation.md deleted file mode 100644 index 11cd12d61..000000000 --- a/adr/0003-factory-two-level-navigation.md +++ /dev/null @@ -1,242 +0,0 @@ -# 0003. Two-level navigation for /factory and first-party chart parity - -- **Status:** Accepted -- **Date:** 2026-08-07 -- **Deciders:** @castrojo -- **Supersedes in part:** 0002 — the two-tab structure, the "content that stays - on lab" split, and the "everything is hand-rolled inline SVG" rule. -- **Authorizes:** new routes under `/factory/*`, a two-level navigation - component, decomposition of `HiveFactoryDashboard.tsx`, a design-token layer, - an ECharts wrapper, seven new build-time data pipelines, and the panel - inventory listed below. See ADR 0001 for what that authorization means. - -## Context - -ADR 0002 gave `/factory` two tabs, `Live` and `Factory health`, selected with a -`?tab=` parameter. Measured from the shipped CSS the tab bar is `0.78rem`, -uppercase, `#8b949e` on `#0d1117` with no background fill, a 2px active -underline, and padding of `0.55rem 1.1rem` — below the 44px minimum target. It -sits below a tall hero and a tall status strip, roughly 400px down a 1080p -viewport. Readers reach content before they reach navigation. The maintainer's -assessment, that the tabs are "too hidden", is a correct reading of what the -page renders. - -Separately, ADR 0002 split content with lab and left the majority of the lab -site's visualizations behind. An audit of `projectbluefin/lab` counts roughly 45 -ECharts visuals across 11 pages. The valuable ones — countme active devices, -pass-rate trends, daily build outcomes, release timelines, freshness brackets, -duration percentile bands — did not come across. - -ADR 0002 rejected consuming lab's published JSON, on the grounds that it wires -this site to URLs the same decision renames. That reasoning still holds, and the -maintainer has now strengthened it: nothing on `/factory` may measure lab at -all. The consequence is that parity has to be rebuilt from the original upstream -sources rather than ported. - -Facts verified live on 2026-08-07 while writing this record: - -- `data-analysis.fedoraproject.org/csv-reports/countme/totals.csv` is public, - unauthenticated, **617,959,570 bytes**, sorted ascending by `week_start`, and - carries `os_name` values `Bluefin`, `Bluefin LTS`, `Aurora`, `Bazzite`. -- `formulae.brew.sh/api/analytics/os-version/365d.json` ranks `Bluefin` **#11 - worldwide** at 1,348,288 installs (0.48%) and `Bluefin LTS` #39 at 79,446. - There is no `analytics-linux` host; that path 404s. -- `flathub.org/api/v2/stats` attributes 410,969 downloads to `bluefin;44` and - exposes a Flatpak version distribution per operating system. -- `api.securityscorecards.dev` returns a current score of 4.5 for - `projectbluefin/bluefin`, and no history. -- The GitHub Packages REST API exposes **no** container download counter, and - Bluefin publishes to GHCR only. Pull counts cannot be obtained from any - public source. - -A design audit of the current page found four violations of ADR 0002's own -sparkline rules still shipping: an all-zero series disappears entirely rather -than drawing a flat line, six small multiples in `HistoryTrends` autoscale -independently, advisory severity is encoded by three hues, and `CiBadge` uses -the forbidden red/green pair. It also found three colour pairs below WCAG AA -(the worst at 2.7:1), two infinite animations with no `prefers-reduced-motion` -guard, 136 KB of static JSON fetched client-side after hydration, and a -one-second interval re-rendering the entire 5,364-line component tree. - -## Decision - -### Structure - -Two primary tabs, `Live` and `Factory`. Each reveals its own secondary row. -Every node is a real Docusaurus route, code-split: - -``` -/factory Live › Overview (default) -/factory/community Live › Community -/factory/images Factory › Images (the Factory primary lands here) -/factory/builds Factory › Builds -/factory/tests Factory › Tests -/factory/applications Factory › Applications -/factory/metrics Factory › Metrics -/factory/userspace Factory › Userspace -``` - -Routes replace the `?tab=` parameter. ADR 0002's two conditions survive the -change and are strengthened by it: the view is in the URL by construction, and -the hero and status strip remain above the navigation on every route, so "is -anything on fire?" is still answerable without clicking. `?tab=live` and -`?tab=health` are rewritten client-side **on `/factory` only** to the -corresponding route — a short effect, not a redirect plugin. - -The navigation itself is fixed by this decision, not left to implementation: -inactive tabs at `0.95rem`/600 in `--fx-text-muted`, active tabs with a filled -background rather than an underline alone, a 44px minimum target, the primary -row sticky at the top of the scroll container, and the secondary row visually -subordinate but never below the fold on a 1080p viewport. - -Lab's `Provisioning`, `Evidence` and `About` are not carried. The first two are -pure lab-cluster measurement. `About` is methodology prose, which belongs in -`docs/` rather than in a dashboard tab. - -### Charting - -ADR 0002's "everything is hand-rolled inline SVG" rule is replaced. -`Sparkline` remains the required tool for inline values, leaderboard rows and -small multiples, where it is better than a chart library. ECharts — already a -dependency, already used by `FactoryCharts.tsx` — is used for the substantial -panels: multi-series lines, stacked bars and areas, heatmaps, calendars, -scatter timelines, and the one permitted pie. - -All five sparkline rules from ADR 0002's addendum bind ECharts panels too. -Rule 4 in particular: a gap is `null` in a series with `connectNulls: false`, -never `0`. - -Every chart is wrapped by one component that renders nothing during static -generation, mounts on the client, applies the shared theme, disables animation -outright rather than branching on `prefers-reduced-motion`, and emits a -`
` data table so the numbers exist for a screen reader. A chart is -never the sole carrier of a claim, and never appears without its current value -stated as a number. - -### Content boundary - -ADR 0002's "content that stays on lab" split is superseded. `/factory` carries -the project-level story end to end. Lab-cluster measurement is not carried and -is **not approximated**: BuildStream caches, the cache heat trend, the -Thunderbolt link, work distribution, cold/warm speedup, layer rechunking, -contributor cluster hardware cards, KubeVirt provisioning, ArgoCD sync status, -pod memory against requests, policy compliance, Zot registry storage, and the -AT-SPI evidence workbench are dropped. - -`Adoption` is renamed `Metrics` and is Bluefin-only. Peer distributions appear -only where a comparison is the point: the ecosystem-share panel and the Flathub -peer bar. - -`Applications` measures the applications Bluefin ships — the curated catalog -already in `static/data/firehose-apps.json`, Flathub downloads attributed to -Bluefin, and the GNOME extension inventory. `Userspace` measures Bluefin's -userspace stack — the `fsdk-containers` images on GHCR, toolbox images, and the -Flatpak runtime distribution on Bluefin. Neither measures a cluster. - -`Tests` is reconstructed from GitHub Actions runs and jobs, at repo × workflow -granularity. This is coarser than lab's 64-row suite × variant matrix, which is -built from Argo runs this site cannot see, and repo × workflow is the honest -granularity available. - -### Data - -Zero lab resources. Every dataset is regenerated here from the original public -source by a `scripts/fetch-*.js` script in the existing pattern: pure exported -functions, a `scripts/*.test.js` beside it, never throwing, never exiting -non-zero, and always writing an explicit `unavailable: true` payload with a -reason rather than a silently empty file. - -The hive — `hive.kubestellar.io`, the hosted instance, and -`queue.projectbluefin.io` — is **not** lab. It is already sanctioned by ADR 0002 -and remains the source for `Live`. The prohibition covers `projectbluefin/lab` -and its cluster. - -The countme CSV is 618 MB and must never be downloaded whole in CI. Because it -is sorted ascending by `week_start`, an HTTP range request over the final -megabytes yields the most recent weeks. Each build fetches a bounded 12 MB tail -and merges it into a tracked seed, `static/data/countme-history.json`, so -history accrues without the file size ever being paid. A wider one-time -backfill exists behind a `--seed` flag and is deliberately not wired into -`npm run fetch-data`. - -Scorecard has no history endpoint, so `static/data/scorecard-history.json` -accumulates the same way. Both seeds are tracked; both cold-start nearly empty -and must render "accumulating data" rather than an empty chart. - -### Defects fixed as part of this work - -Because they are violations of ADR 0002's own rules or of accessibility -baselines, and because the code carrying them is being moved anyway: - -- An all-zero sparkline series must render a flat line, not disappear. -- Small multiples in `HistoryTrends` must share a domain; `HistoryTrends` is - then merged into `FactoryVitals`, which measures the same three metrics from - a richer source. -- Severity is one hue at four intensities plus a distinct glyph, everywhere, - including advisories and CI badges. -- The three sub-AA colour pairs are corrected and `#484f58` is removed rather - than kept as a token, so it cannot return by accident. -- Both infinite animations are guarded by `prefers-reduced-motion`. -- The one-second countdown is isolated so it cannot re-render a page. -- Static JSON is loaded per route, on demand, and cached across navigation. -- `_QueueBar` is dead and is deleted. - -## Scope - -**In scope:** the eight routes, the two-level navigation component, the design -token layer, the ECharts wrapper and shared theme, the visible-unavailability -component, the per-route data provider, decomposition of -`HiveFactoryDashboard.tsx` into panel modules, the seven new fetch pipelines and -their tests, the panel inventory above, the defect fixes above, and updating -inbound links. - -**Out of scope:** any change to the lab site or its pipeline; `workers/countme-proxy/`; -the Cloudflare and DNS work from ADR 0002; per-image CVE counts, which are gated -behind a written feasibility check and may be refused. - -## Consequences - -The page becomes eight code-split routes instead of one 5,364-line component, -which is the only way the added panels do not make it slower. Two tracked seeds -now accrue history, so two files change in most CI runs. - -Image pull counts are published nowhere and are simply absent; countme, Flathub -and Homebrew are the adoption signals instead. Homebrew third-party tap installs -are likewise unavailable, and Homebrew's `os-version` analytics is substituted — -a better metric, since it ranks Bluefin against every operating system rather -than against other taps. - -The OpenSSF score is currently 4.5 out of 10 and is published as-is, for the -same reason ADR 0002 chose to publish an unflattering median merge time: a -number omitted because it is unflattering is worse than the number. - -The docs site now maintains seven more pipelines. That is a standing cost and -the reason each one is required to degrade visibly rather than silently. - -## Alternatives considered - -**Keep two tabs and make them louder.** Cheapest, and it addresses the literal -complaint. Rejected because it does nothing about the missing visualizations, -and eight views' worth of content in two tabs is a scroll, not a structure. - -**One flat row of nine tabs.** Honest and simple. Rejected because `Live` and -`Factory` answer genuinely different questions and a flat row hides that; nine -peers also wrap on a laptop viewport, which reintroduces the original problem. - -**Keep the `?tab=` parameter and add a second one for the secondary tab.** -Rejected: it keeps the whole dashboard in one bundle, which is the thing that -makes eight views unaffordable, and two orthogonal query parameters are harder -to share correctly than a path. - -**Consume lab's published JSON.** Rejected by ADR 0002 and now rejected again, -more strongly, by the maintainer. - -**Keep hand-rolled SVG for everything.** Rejected: a calendar heatmap, a -percentile band and a scatter timeline hand-rolled three times over is a large -amount of bespoke, untested geometry for no gain, when ECharts is already -installed and already used on this site. - -**Approximate the dropped lab panels from public data.** Rejected: there is no -public source for cache hit rates or cluster health, so any such panel would be -a plausible-looking invention. Dropping them is honest; approximating them is -the failure mode ADR 0002's visible-unavailability rule exists to prevent. diff --git a/adr/0004-countme-counting-method.md b/adr/0004-countme-counting-method.md deleted file mode 100644 index 2ef4acbca..000000000 --- a/adr/0004-countme-counting-method.md +++ /dev/null @@ -1,101 +0,0 @@ -# 0004. Countme is counted the way ublue-os/countme counts it - -- **Status:** Accepted -- **Date:** 2026-08-08 -- **Deciders:** @castrojo -- **Authorizes:** a rewrite of the aggregation in `scripts/fetch-countme.js`, a - regenerated `static/data/countme-history.json`, and the renaming of the - affected panels. See ADR 0001 for what that authorization means. - -## Context - -The maintainer's report was that the countme charts could not be right. They -were not. `/factory/metrics` showed **23,737** weekly Bluefin devices while the -project's own README badge, generated by -[`ublue-os/countme`](https://github.com/ublue-os/countme), showed **3.8k** for -the same week. Two numbers for the same thing, published by the same project, -differing by a factor of six. - -The dashboard was wrong. `scripts/fetch-countme.js` summed the `hits` column -across every row matching an `os_name`, which is wrong twice over: - -1. **A hit is not a device.** DNF sends countme once a week for _each_ - countme-enabled repository. Bluefin systems appeared under 19 repo tags in a - single week, so each machine was counted about twice over. - -2. **Not every row is the same measurement.** Rows with `sys_age = -1` are not an - "all ages" subtotal. In `mirrors-countme`'s `totals.py`, `totals()` runs two - independent passes and writes both into the same table: `BucketSelect` - produces the real countme rows (`sys_age` 1–4), and `BucketSelectUniqueIP` - produces a legacy unique-IP estimate under a hardcoded `sys_age` of `-1`. - Summing them stacks two different metrics on top of each other. - -Measured for week `2026-07-27`, the shipped figures were inflated by a factor -that differed per variant — 6.9× for Bluefin, 3.9× for Bluefin LTS, 19.6× for -Fedora. That is worse than a uniform scale error: it silently corrupted every -_comparison_ on the page, including the ecosystem-share pie and the -Bluefin-versus-LTS headline. - -## Decision - -**Port the counting rules from `ublue-os/countme:data_processing.py` rather than -deriving our own.** - -The rules, now implemented in `scripts/fetch-countme.js`: - -1. Drop `sys_age = -1`. -2. Restrict to the base `^fedora-[0-9]+$` repo — the one repo every Fedora-based - system has exactly one of. Summing across those tags sums across _releases_, - not across one machine's repos. -3. Exempt Bluefin LTS, which has no `fedora-N` repo and is counted across its own - EPEL repos instead. -4. Skip two weeks upstream got wrong: `2024-12-29` (partial year-end week) and - `2025-07-06` (Fedora infrastructure migration, a ~40% artefact drop). - -The payload carries a `method` field, and `mergeHistory` refuses to merge stored -weeks produced by a different method. A routine run only reads a ~6 week window, -so without that guard the fix would have corrected the recent weeks and left the -older ones inflated, producing one series with a 6× step in the middle and -nothing on the page to say so. - -Verified against the published badges for the same week: Bluefin 3,761 → "3.8k", -Aurora 2,826 → "2.8k", Bazzite 89,550 → "89k". All three match. - -## Alternatives considered - -**Derive our own unique-system estimate.** The countme feature's author documents -a different method — group by host identifiers and take `max(hits)` across repo -tags. It is defensible and was implemented first, giving 3,431 for the same week. -It was thrown away. It is _more_ defensible in isolation and _less_ defensible in -context: it would have produced a third number for a quantity the project already -publishes, and a docs site that disagrees with its own project's badges is a bug -regardless of which number is more theoretically sound. Agreement with the -canonical implementation is the requirement; methodological taste is not. - -**Consume `ublue-os/countme`'s output directly.** Preferable in principle — one -implementation, no drift. Rejected because that project publishes rendered SVGs -and shields.io badge endpoints carrying a single latest value, and the dashboard -needs the weekly series as data. Deriving it here is duplication, so the rules -are cited to their source in the file header and the obligation to keep them in -step is stated there. - -**Filter to `sys_age >= 2`,** as Fedora Infrastructure's own -`countme-weekly-totals-ui.sh` does, to drop first-week installs and suppress -CI/container noise. Rejected: it also drops genuine new adopters, which is -exactly what a growth chart exists to show, and `ublue-os/countme` does not do it. - -## Consequences - -- The published Bluefin figure falls from ~23.7k to ~3.8k. This is a correction, - not a regression, and the drop is visible to anyone who watched the old number. - `docs/analytics.mdx` explains the counting so the change is legible. -- The site now agrees with the project's badges and growth charts. Previously it - contradicted them. -- The stored history was regenerated. It covers 35 weeks, more than the 26 it - replaced, because the backfill read a wider window. -- `scripts/fetch-countme.js` is now coupled to a file in another repository. That - coupling is real and is stated in the header; it is preferable to two - implementations that silently disagree. -- Bluefin LTS is labelled as EPEL-only and undercounted wherever it is charted. - `ublue-os/countme` has gone further and disabled its LTS badge entirely, with - the comment "centos countme data is broken". We keep the series but say so. diff --git a/adr/0005-reports-2.0.md b/adr/0005-reports-2.0.md deleted file mode 100644 index 7c0b21e46..000000000 --- a/adr/0005-reports-2.0.md +++ /dev/null @@ -1,113 +0,0 @@ -# 0005. Reports 2.0 public monthly snapshot model - -- **Status:** Accepted -- **Date:** 2026-09-07 -- **Decider:** @castrojo - -## Context - -Monthly reports are immutable blog-post snapshots, while `/factory` is the live -factory dashboard and `/changelogs` is the release-scoped changelog surface. -The current report already combines public GitHub activity, publishing lanes, -Countme, Homebrew, Flathub, tap promotions, contributor recognition, and -automation signals. It lacks a single model for wider factory coverage and for -the additional charts requested for Reports 2.0. - -The published reports must remain source-attributed, SSR-safe, and free of -render-time network requests. Data must be collected only from original public -sources; unavailable inputs must remain visible, and missing values must not be -coerced to zero. The proposal therefore treats a report as a complete, -month-specific `Report Snapshot`, rather than a live dashboard embedded in a -blog post. - -## Decision - -Reports 2.0 remains a monthly blog-post archive. It is optimized for public -community members and contributors: it makes completed work, delivery -reliability, participation, and externally measured ecosystem context easy to -inspect without presenting internal operations as public fact. - -Each generated Report Snapshot will have four source-attributed sections: - -1. **Activity** — merged work, contribution calendar, category mix, and - repository activity. The portfolio is explicitly configured, rather than - inferred from organization membership. -2. **Delivery** — named publishing-lane outcomes, run cadence and duration, - release events, and visible pending or unavailable states. -3. **Participation** — human and automation activity, contributors, new - contributors, and the existing leaderboard when its source is available. -4. **Ecosystem context** — Countme, Homebrew, Flathub, and tap-promotion - measurements with their actual source windows. - -The initial stable portfolio is the existing image, documentation, branding, -installation, shared-factory, and automation work that is publicly measurable. -`projectbluefin/testsuite`, `projectbluefin/server`, `projectbluefin/actions`, -`projectbluefin/bonedigger`, and `projectbluefin/aurorafin-shared` are added -only where the generator can collect a defined public signal. `projectbluefin/utah` -and `projectbluefin/utah-packages` form the Experimental Portfolio and are -always labeled separately. Lab-cluster data, internal URLs, and unconfigured -repositories are excluded. `ublue-os/*` remains external ecosystem context, -not factory portfolio activity. - -The report uses more charts only when a chart answers a distinct question: - -| Question | Form | -| ------------------------------------------------------------ | -------------------------------------------------- | -| When did completed work occur? | Calendar heatmap | -| Which portfolio areas changed, at comparable scale? | Shared-domain small multiples | -| How is activity distributed by repository and work category? | Matrix heatmap or ordered bar chart | -| What happened in each publishing lane? | Accessible status table with outcome bar | -| Are run cadence and duration changing? | Bounded multi-series line or duration distribution | -| How are automated and human contributions distributed? | Ordered stacked bars | -| How are public ecosystem measures changing? | Source-window-labeled trend lines | - -Every chart states the current numeric value, describes its units and source -window, exposes the snapshot data in a `
` table, uses a glyph in -addition to severity intensity, retains `null` gaps, and says `accumulating -data` below its minimum history. ECharts remains the library for substantive -interactive charts because it is already a dependency; sparklines remain -server-rendered inline SVG for compact trends. No new chart dependency is -introduced. ECharts use is client-only and never fetches data at render time. -Its required modules and SSR boundary must be verified through the Context7 -`apache/echarts` library before implementation. - -Release Changelogs remain the canonical per-release surface. Reports may show -release-count or cadence measurements and link to `/changelogs`, but they do -not duplicate package-level release notes. - -Existing published reports remain untouched. Reports 2.0 begins with the first -newly generated report after this record is accepted. Historical-series charts -use an explicitly versioned tracked seed or show `accumulating data`; they do -not fabricate a backfill from incompatible historical methods. - -## Consequences - -The generator must produce one validated snapshot shape that supplies all -rendered components. It gains public-source adapters and tests for each -portfolio signal, while the post remains self-contained and immutable after it -is committed. Report components gain one shared chart wrapper instead of -individual client-only implementations. - -The report becomes denser without becoming a replacement for `/factory` or -`/changelogs`. Adding a repository requires an explicit portfolio entry, a -documented public signal, a source label, an unavailable state, and an offline -test fixture. - -## Considered options - -**Turn reports into a live `/reports` dashboard.** Rejected: it breaks the -archive contract and overlaps `/factory`. - -**Add every Project Bluefin repository automatically.** Rejected: repository -membership does not define an honest metric, and some repositories lack public -signals suitable for a monthly public report. - -**Use a new visualization dependency.** Rejected: ECharts and SSR-safe -sparklines already cover the required forms; a new dependency adds bundle and -maintenance cost without a demonstrated need. - -## Approval - -Implementation is not authorized while this record is Proposed. A maintainer -must review and set it to Accepted before code, data-pipeline, component, or -workflow changes begin. diff --git a/adr/0006-multi-client-countme-reporting.md b/adr/0006-multi-client-countme-reporting.md deleted file mode 100644 index d67257d3a..000000000 --- a/adr/0006-multi-client-countme-reporting.md +++ /dev/null @@ -1,62 +0,0 @@ -# 0006. Multi-client countme reporting via common services and countme.projectbluefin.io - -- **Status:** Accepted -- **Date:** 2026-09-07 -- **Deciders:** @castrojo - -## Context - -Project Bluefin clients currently have fragmented countme reporting: - -1. **Bluefin (standard):** Inherits Fedora's native DNF countme configuration (`countme=1` in `/etc/yum.repos.d/fedora.repo`) sending weekly metalink queries to `mirrors.fedoraproject.org`. -2. **Bluefin LTS:** Built on CentOS Stream 10 where standard rpm-ostree countme is broken (coreos/rpm-ostree#5464); it runs a custom `bluefin-lts-countme.service` (`dnf makecache`) reaching Fedora infrastructure via EPEL mirrors. Upstream mirrors undercount EPEL clients. -3. **Dakota:** Built on GNOME OS bootc with BuildStream, possessing no DNF or rpm-ostree packages. PR `projectbluefin/common#807` created a standalone `dakota-countme` service targeting `countme.projectbluefin.io`. - -Relying exclusively on Fedora infrastructure leaves Dakota unmeasurable, undercounts LTS systems, and delays metrics by the weekly upstream batch processing cycle. We require all Project Bluefin clients (`bluefin`, `bluefin-lts`, and `dakota`) to report directly via `projectbluefin/common` services to `countme.projectbluefin.io` in addition to upstream Fedora and ublue reporting. - -## Decision - -1. **Unified Client Service in `projectbluefin/common`:** - Deploy a shared systemd service (`bluefin-countme.service` / `bluefin-countme.timer`) and helper script (`/usr/libexec/bluefin-countme`) in `system_files/shared/` across all images. - - **Trigger:** Scheduled weekly with `RandomizedDelaySec=12h` and `Persistent=true`. - - **Isolation:** Runs under systemd `DynamicUser=yes` with `StateDirectory=bluefin-countme`. - - **Throttling:** Computes 7-day rate-limiting locally via `/var/lib/bluefin-countme/lastrun`. - - **Cohort estimation:** Determines coarse Fedora-style installation age buckets (1: first week, 2: 2–4 weeks, 3: 5–24 weeks, 4: >24 weeks) using an installation timestamp stored in `/var/lib/bluefin-countme/epoch`. - - **Metadata extraction:** Reads `/usr/share/ublue-os/image-info.json` for `image-name`, `image-flavor`, `image-tag`, and `/etc/os-release` for version. - - **Target:** Sends an empty GET request to `https://countme.projectbluefin.io/metalink?repo=${IMAGE_NAME}&tag=${IMAGE_TAG}&flavor=${IMAGE_FLAVOR}&arch=${ARCH}&countme=${BUCKET}`. - - **Privacy:** Transmits NO machine-id, persistent token, hostname, or personal identifier. - - **Opt-out:** Honored if `/etc/projectbluefin/countme/disabled` (or legacy `/etc/dakota-countme/disabled`) exists. - -2. **Server Ingestion on `countme.projectbluefin.io`:** - The Cloudflare Worker (`workers/countme-proxy/index.mjs`) receives `/metalink` GET requests from all supported client repos (`bluefin`, `bluefin-lts`, `dakota`), returning `200 countme accepted` with `cache-control: no-store`. - -3. **Coexistence with Upstream:** - Existing Fedora and EPEL repo queries remain untouched. The `common` countme service runs alongside upstream reporting to provide first-party visibility without breaking upstream Fedora countme contribution. - -## Scope - -**In scope:** - -- Architecture and specification for multi-client countme reporting across `bluefin`, `bluefin-lts`, and `dakota`. -- Expanding test coverage in `scripts/countme-worker.test.js` to validate `bluefin`, `bluefin-lts`, and `dakota` metalink requests. -- Documenting countme client behavior and opt-out procedures in `docs/analytics.mdx`. -- Updating `docs/skills/cloudflare-workers.md` with `/metalink` proxy contracts. -- Generalizing PR `projectbluefin/common#807` client script and unit definitions for all projectbluefin client images. - -**Out of scope:** - -- Modifying `ublue-os/*` upstream repositories. -- Disabling upstream Fedora repository countme flags. - -## Consequences - -- All Project Bluefin variants gain consistent, reliable, first-party countme reporting. -- Dakota systems become measurable alongside Bluefin and Bluefin LTS. -- Strict client-side rate limiting and coarse age buckets ensure high privacy preservation without tracking individual machines. -- Users have a documented, single filesystem opt-out mechanism across all images. - -## Alternatives considered - -- **Per-image countme services:** Creating separate timers/scripts for Bluefin, Bluefin LTS, and Dakota across distinct repositories. Rejected: causes configuration drift, duplicated maintenance, and testing overhead across the factory. -- **Reporting via machine-id HMAC hash:** Computing a weekly salted hash on the client and counting unique hashes server-side. Rejected: transmitting machine-id derivatives increases privacy exposure and raised reviewer objections; client-side throttling with age buckets achieves cohort estimation with zero identifying payload. -- **Replacing Fedora countme entirely:** Turning off upstream Fedora countme and relying solely on first-party service. Rejected: Project Bluefin relies on Fedora and desires continued participation in upstream community metrics. diff --git a/adr/0007-first-party-countme-aggregate.md b/adr/0007-first-party-countme-aggregate.md deleted file mode 100644 index 8bb442d49..000000000 --- a/adr/0007-first-party-countme-aggregate.md +++ /dev/null @@ -1,175 +0,0 @@ -# 0007. Weekly active systems from the first-party countme aggregate - -- **Status:** Accepted -- **Date:** 2026-09-12 -- **Deciders:** castrojo - -## Context - -ADR 0006 and commit `f846c652` removed every `projectbluefin` image from the -Fedora CSV pipeline. `scripts/lib/countme-sources.mjs` now states the rule as -code: a Project Bluefin count may come only from `countme.projectbluefin.io`, -with one exception, `ublue-os/bluefin:stable`, whose number is upstream's to -publish. `scripts/countme-first-party.test.js` fails the build on a violation. - -That removal left no replacement. Verified at the time of this record: - -- `/analytics` rendered `Weekly active systems unavailable` on the deployed - site. The panel was a hardcoded `Unavailable` with no data wiring. -- `static/data/countme-history.json` carries `aurora`, `bazzite`, and `fedora` - only, and declares `data-analysis.fedoraproject.org` as its source — a literal - `FORBIDDEN_SOURCES` match for any Project Bluefin series. -- `projectbluefin/countme` does not exist. `api.github.com/repos/projectbluefin/countme` - returns 404 and every raw artifact path under it returns 404. -- The worker resolved all first-party chart routes to that nonexistent - repository, so `/`, `/growth.svg`, and every per-image route returned the - placeholder SVG unconditionally. -- The worker already recorded pings into D1 (`telemetry_events`), so the - measurements existed; nothing read them back. - -## Decision - -The D1 table behind `countme.projectbluefin.io` is the source for every Project -Bluefin count, and the worker exposes it. - -1. The worker aggregates `telemetry_events` into Monday-anchored weekly counts - per repo and serves `GET /counts.json`, shaped like the existing dataset - contract: `generatedAt`, `source`, `method`, `unit`, `variants`, `weeks[]`, - with optional `unavailable` and `stateReason`. -2. Every week carries every first-party repo key, holding either a value or - `null`. A gap is explicit rather than inferred from an absent key, and a - recorded `0` stays `0`. -3. Per-image chart and badge routes render from the same aggregate. The three - legacy routes — `/growth_bluefins.svg`, - `/sources/ublue-os/bluefin/growth.svg`, and `/badge-endpoints/bluefin.json` — - continue to proxy `ublue-os/countme`, and nothing else does. -4. `/analytics` reads `/counts.json` at runtime and renders the panel from it, - falling back to a reasoned `Unavailable` when the service reports no weeks. -5. `connect-src` in `docusaurus.config.ts` gains `https://countme.projectbluefin.io`. - -### Chart types - -- **Weekly active systems — multi-series line, zero-anchored, `connectNulls` - off, `smooth` off.** These are discrete weekly readings. A spline invents - values between measurements and a connected null bridges a week nobody - reported; both are claims the data does not support. The y-axis is anchored at - zero because a floating floor turns a flat series into a cliff. -- **Series are separated by dash pattern and marker shape, not hue.** The - Bluefin categorical ramp is six shades of one blue, so hue alone cannot tell - two series apart. `seriesDash` already existed for this; marker shapes were - added alongside it. Both survive greyscale and colour-blind reading. -- **Per-image SVG routes — single-series line with the current value printed as - text**, and an `accumulating data` panel below two points, so a chart embedded - outside the site carries the same guarantees as one inside it. - -### Where the first-party reader lives - -`src/components/analytics/firstPartyCountme.ts`, not in the component. -`scripts/countme-first-party.test.js` forbids one file from holding both a -catalogue of Project Bluefin image ids and a computed index into a countme week, -because that pairing twice published a Fedora-derived number under a Project -Bluefin name. The component holds the catalogue; the reader holds the week keys. -Splitting them keeps the gate a gate instead of an exemption list. - -## Scope - -**In scope:** - -- `workers/countme-proxy/{index,routes,render}.mjs` and its tests. -- The weekly active systems panel in - `src/components/analytics/CountmeAnalyticsCharts.tsx`. -- `src/components/analytics/firstPartyCountme.ts`. -- The `connect-src` entry in `docusaurus.config.ts`. - -**Out of scope:** - -- `scripts/fetch-countme.js` and the upstream peers pipeline. It correctly - publishes `aurora`, `bazzite`, and `fedora`, and this record does not reopen - that decision. -- The publication matrix and the churn panels. -- Any change to `ublue-os/*`. -- Retention, sampling, or deduplication policy for `telemetry_events`. - -## Consequences - -Dakota, Utah, and Server report `accumulating data` until clients ping, and that -is the honest state rather than a placeholder. Bluefin and Bluefin LTS restart -from the first-party epoch, so the published series is shorter than the retired -Fedora one and the two are not comparable; the method field records which is -which. - -The page now depends on a service at request time rather than on build-time -data, so a worker outage degrades the panel to its reasoned unavailable state -instead of showing stale numbers. The `
` table and the summary keep the -numbers reachable when the canvas does not paint. - -A count is now a measurement of pings, not of DNF metalink hits. It cannot be -reconciled against Fedora's counters, and should not be presented as if it could. - -## Alternatives considered - -**Keep reading `countme-history.json` for Project Bluefin images.** Rejected: it -is Fedora-derived, `isPermittedSource` rejects it, and it no longer carries the -keys at all. - -**Point the chart routes at `ublue-os/countme` until first-party data -accumulates.** Rejected: that is the EPEL-summed series ADR 0006 and `f846c652` -removed. `/badge-endpoints/bluefin-lts.json` was the live instance of this and -now serves from D1. - -**Create a `projectbluefin/countme` repository mirroring the upstream layout.** -Rejected: it duplicates ingestion the worker already performs, adds a scheduled -job and a second source of truth, and the D1 rows are already the measurement. - -**Generate the aggregate at build time into `static/data/`.** Rejected: counts -accumulate continuously while the site rebuilds only on merge, so the published -number would age with the deployment rather than with the data. - -**Log y-axis to fit Fedora's magnitude alongside Bluefin's.** Rejected here: the -panel no longer plots Fedora, so the range that motivated it is gone, and a log -axis is easy to misread on a page that does not otherwise use one. - -## Addendum, 2026-09-12: game mode and the upstream panel - -Two follow-on decisions, taken after the first deployment. - -### Game mode is an attribute, not an image - -Clients report game mode two ways: a `-gaming` repo id, and a `gamemode=1` -parameter. Live D1 carried both — `dakota-gaming` with `gamemode=1`, and a -`bluefin` row also flagged `gamemode=1` — so this is not a Dakota-specific -image. Treating `dakota-gaming` as its own repo would have split Dakota's -population in two and left a phantom image in the catalogue. - -The service normalizes the repo id and folds both spellings into the base -image. `weeks[i][repo]` is the whole population, including game mode. -`weeks[i].gaming[repo]` is the part of it that was in game mode. The two are a -population and its share, never addends: `gaming[repo] <= weeks[i][repo]` holds -for every repo and week, and a test asserts it. - -A repo that reported with nobody in game mode is `0`, not `null` — a real -measurement, distinct from a week it did not report at all. The page draws a -game-mode series only for images with a non-zero reading, so a flat zero never -implies a population nobody measured. Each game-mode series carries its parent -image's colour with a dotted stroke, so it reads as a share of the line above -it rather than as a separate image, and the current split is stated in words -next to the image's number. - -Payload method bumped to `first-party-d1-v2`. - -### The upstream image is shown alongside - -`ublue-os/bluefin:stable` is published on the page next to the first-party -counts, so the migration between the two is visible rather than inferred. This -is `UPSTREAM_ALLOWED`, the single permitted upstream series, and it is read -through our own worker's existing legacy routes. - -Upstream publishes rendered matplotlib SVGs and a rounded badge value, and no -time-series file. The panel therefore embeds the chart upstream publishes -instead of replotting a series that does not exist, and states the badge's -value as its current number. The image is framed rather than recoloured: -altering another project's published chart would misrepresent it. - -The two series are not the same quantity — the legacy one counts DNF metalink -hits, the first-party one counts image check-ins — and the panel says so rather -than inviting a subtraction. diff --git a/adr/README.md b/adr/README.md deleted file mode 100644 index 0d6c68d29..000000000 --- a/adr/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Design decisions - -This directory holds design decisions for the documentation site. A record here is -what authorizes an agent to make a design change — see the boundary section in -`AGENTS.md`. - -This directory sits at the repository root **on purpose**. The docs plugin is -mounted at `routeBasePath: "/"`, so anything placed under `docs/` becomes a -published page. Design records are internal and must not publish. - -## When you need a record - -You need one for any change to layout, CSS, component behavior, animation, page -structure, JSX or HTML structure, routes, navigation, data shapes, fetch timing, -API endpoints, or fallback behavior. - -You do not need one for prose, frontmatter, links, alt text, captions, blog -metadata, authors, report text, or a content item added to an existing component -in its existing format. - -## Rules - -- A maintainer writes and approves the record. An agent may draft one when asked, - but may not approve its own authorization. -- The record must exist before implementation code is written. -- Implementation covers what the record specifies and nothing more. -- If implementation shows the record is wrong or incomplete, stop and report back - rather than amending it in passing. - -## Format - -Name files `NNNN-short-title.md`, numbered sequentially. Use `template.md` as a -starting point. Keep each record to one decision — split anything larger. - -Set `Status` to `Proposed`, `Accepted`, `Superseded by NNNN`, or `Rejected`. -Leave accepted records in place when they are superseded; supersede, do not -delete, so the reasoning stays readable. - -## Index - -| ADR | Title | Status | -| ---------------------------------------------- | -------------------------------------------------------------------------------- | -------- | -| [0001](0001-agent-design-authorization.md) | Agent design change authorization | Accepted | -| [0002](0002-factory-page.md) | Rename /hive to /factory and absorb factory content | Accepted | -| [0003](0003-factory-two-level-navigation.md) | Two-level navigation for /factory and first-party chart parity | Accepted | -| [0004](0004-countme-counting-method.md) | Countme is counted the way ublue-os/countme counts it | Accepted | -| [0005](0005-reports-2.0.md) | Reports 2.0 public monthly snapshot model | Proposed | -| [0006](0006-multi-client-countme-reporting.md) | Multi-client countme reporting via common services and countme.projectbluefin.io | Accepted | diff --git a/adr/research/cve-feasibility.md b/adr/research/cve-feasibility.md deleted file mode 100644 index df294a53d..000000000 --- a/adr/research/cve-feasibility.md +++ /dev/null @@ -1,83 +0,0 @@ -# Per-image CVE counts — feasibility check and refusal - -> Research output supporting ADR 0003, Task 16 of the implementation plan. -> Measured 2026-08-07 against live endpoints and the data actually on disk. - -## The question - -The lab site publishes a per-lane CVE table (critical/high/medium/low/fixable/total) -and a stacked-area severity trend, sourced from Grype scans run against its own -cluster. ADR 0003 forbids reading anything from lab. Can an equivalent be built -from public sources plus the data this repository already has? - -The plan gated this behind three conditions, all of which had to hold: - -1. a full scan completes in under 60 seconds, -2. it needs no authentication, -3. OSV returns real advisories for the RPM packages these images actually ship. - -## What was measured - -**The SBOM seed does not contain an SBOM.** `static/data/sbom-attestations.json` -is 4.2 MB across 19 streams, but `releases[].packageVersions` carries **11 -curated package versions** per release — GNOME, kernel, Podman, systemd, Mesa and -a handful of others — not a package inventory. Several releases carry zero -(`bluefin-stable/stable-20260606` has none at all). The file exists to show -"what version of the things people ask about is in this image", which is a -different job. - -A Bluefin image ships on the order of two thousand packages. Eleven of them is -0.5% of the surface. - -**OSV does answer for Red Hat packages.** `POST https://api.osv.dev/v1/querybatch` -returned in well under a second, unauthenticated, with real advisories: - -``` -{"package":{"name":"podman","ecosystem":"Red Hat"},"version":"5.8.2"} - -> RHSA-2022:1762, RHSA-2022:5068, RHSA-2022:7398, RHBA-2024:1136, - RHEA-2025:4782, RHBA-2025:15692, ... -``` - -So conditions 1 and 2 hold. Condition 3 holds only partially: the results mix -`RHSA` (security), `RHBA` (bug fix) and `RHEA` (enhancement) advisories, so a -naive count of `vulns[]` is not a CVE count. The `Red Hat` ecosystem also maps to -the RHEL-derived lane (`bluefin-lts`); the Fedora-based lanes are the majority of -what Bluefin publishes, and OSV's Fedora RPM coverage is not equivalent. - -## Decision: refuse - -Per-image CVE counts are **not published** on `/factory/images`. - -The blocking reason is not the API. It is that scanning the 11 packages this -repository actually has and labelling the result "CVEs in this image" would be a -false claim about a 2,000-package surface — and it would be a confident-looking -false claim, rendered as a number in a table, next to numbers that are true. - -That is precisely the failure ADR 0002's visible-unavailability rule exists to -prevent, stated there as: _"a dashboard that silently renders less is -indistinguishable from a healthy one with less to report."_ A number derived -from 0.5% of the surface is worse than that — it is not less, it is wrong. - -## What ships instead - -`/factory/images` carries a short security-posture block that: - -- states plainly that per-image CVE counts are not published here, and why; -- links to each repository's own GitHub security advisories, which are - authoritative and already public; -- links to the OpenSSF Scorecard panel on `/factory/metrics`, which is a real, - sourced, honest security signal for the repositories themselves. - -## What would change this - -Any one of: - -- the SBOM attestation pipeline starts persisting the **full** package inventory - per release, rather than 11 curated versions — at which point an OSV batch scan - becomes both feasible and honest, filtering `vulns[]` to `RHSA`/`CVE` ids; -- a public, per-digest scan result is published by the build pipeline itself - (for example a Grype or Trivy SARIF uploaded as a release asset), which could - be read directly without inventing anything; -- OSV's Fedora RPM coverage reaches parity with its Red Hat coverage. - -Until one of those is true, the honest output is a sentence, not a table. diff --git a/adr/research/factory-site-visualization-audit.md b/adr/research/factory-site-visualization-audit.md deleted file mode 100644 index 736166c84..000000000 --- a/adr/research/factory-site-visualization-audit.md +++ /dev/null @@ -1,179 +0,0 @@ -# Factory site (factory.projectbluefin.io) — visualization audit - -> Research output supporting ADR 0002. Generated 2026-08-07 by a background research agent. -> Findings were spot-checked against live endpoints; see ADR 0002 for what was adopted. - -# Factory Overview Dashboard — Inventory, Quality Ranking, and Data Audit - -**Sources verified live on 2026-08-07 (page build stamp: `Aug 7, 2026, 10:00 PM UTC`).** Everything below was read directly from the live site, its JSON contracts, and the Astro source in `projectbluefin/lab`. - -## Summary - -The Factory Overview Dashboard is a **statically prerendered Astro page** (`projectbluefin/lab:src/pages/index.astro`, 80 KB single file) that imports seven build-time JSON contracts and renders ~12 panels, with four ECharts visuals (7-day stacked outcome bars, cache heatmap, cache-heat trend line, per-lane sparklines). Its evidence discipline is genuinely excellent — every row carries `state`, `state_reason`, `source_url`, `collected_at`, `derivation` — but that discipline is currently producing a page that _reads as broken_: **14 of 15 image lanes show "—/Awaiting Evidence" (93%)**, **0 of 5 release lanes are "good"**, **0 of 5 cache cells have data (heatmap is 100% gray "N/A")**, **50 of 64 test-matrix rows are "waiting for results" (78%)**, and the "Recent Runs" panel is showing runs that are **six days stale** without saying so. Meanwhile the most visually impressive panel (USB-4/Thunderbolt telemetry: 40 Gbps, 4.2× speedup, 145 s rechunk, 45/55 work split) is **hardcoded constants in a JSON blob whose own `status` is `"unavailable"`**, with no `source_url` — the single biggest honesty problem on the site. A whole "Raptor Grid Leaderboard" section is dead code (`const raptorEntrants: any[] = [];`), and the per-lane sparklines the page computes are never rendered because they only appear inside that dead section. - ---- - -## A) Complete visualization inventory - -| # | Panel | Question it answers | Visual form | Data source (file → field) | Update / staleness behavior | -| --- | ----------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 1 | **"Is the last release good?" verdict cards** (5) | Is the newest published image of lane X safe to install? | Card grid; per-card 4 status "chips" (build / QA (lab) / signature / CVE regression) + truncated digest + build-run link | `data/release-verdict.json` → `rows[].verdict`, `.build.status`, `.qa.status`, `.signature.status`, `.security_regression.status`, `.digest`; rendered at `lab:src/pages/index.astro` ("Is the last release good?" block) | Prerendered at deploy; JSON regenerated ~15 min (`_meta.generated_at: 2026-08-07T22:15:25Z` vs page-rendered "Collected Aug 7, 10:01 PM UTC") → **page can disagree with its own JSON**. Missing lane → explicit `pending`/`unavailable`, never inferred good (ADR 0002) | -| 2 | **Rolling 7-day factory build health** | Is the build factory itself healthy this week? | 6 KPI cards (All builds 79 / Passed 74 / Failed 5 / Success rate 94% / Duration 26m median · 39m avg / Running work 0) | `data/history/build-runs.ndjson`, filtered `plane === "publish"`, `status ∈ {passed,failed,running}`, rolling 7×24 h from build time; computed in `index.astro` (`loadPublishBuildHistory`, `medianDuration`, `publishSuccessRate`) | Window is **relative to build time**, not view time — ages silently. Degrades honestly: missing file → "Build health unavailable" + `state_reason` | -| 3 | **Seven-day outcomes chart** | How were pass/fail distributed day by day? | ECharts **stacked bar** (green passed / red failed), `#factory-build-outcomes-chart` | Same NDJSON → `buildOutcomeDays[{date,passed,failed}]` | Rendered client-side; silently absent if ECharts CDN fails (`https://cdn.jsdelivr.net/npm/echarts@5`) | -| 4 | **What's degrading?** | What got worse recently? | Dense list of rows: `failing`/`degrading` pill + label + right-aligned detail (some linked) | Computed in `index.astro`: verdict rows with `verdict==='bad'`, `qa.status==='pending'`, `image_builds` duration regression (>1.3× older half), 3-run fail streaks, `upstream-status.json` freshness > 7 d (testing) / 14 d (stable) | Purely derived; empty state is a green "no degradation signals" line. Currently 10 rows, 6 of them restatements of the verdict cards | -| 5 | **Image status** (9 cards / 15 lane rows) | How fresh is each image, and did its tests pass? | Card per variant; per-branch big number ("6d" / "—"), colored tone, "Awaiting Evidence" badge, state_reason text, plus a row of **mini test pills** (smoke/dev/soft/sys/common/flatcar) deep-linking to `/tests/#` | `data/upstream-status.json` → `rows[].published_at`, `.freshness_age_days`, `.state`, `.state_reason`; test pills from `data/tests-matrix.json` → `rows[].result_status`, `.pass_rate` | Freshness thresholds 7 d (testing/nightly) / 14 d (stable) via `getAgeTone`. Unavailable renders "—" + literal collector reason | -| 6 | **Contributor Clusters** | Whose real hardware is running the tests? | Machine cards: status pill, role, OS image, two **horizontal gauge bars** (Mem %, CPU %), capacity line, static spec block, **IP address**, and a 40G link chip | `factory-stats.json` → `factory.cluster.nodes[]` (`name,status,role,ram_gb,cpu_threads,os_image,cpu_usage_pct,mem_usage_pct,load_1m_history`); specs/IPs are a **hardcoded `nodeSpecs` map** in `index.astro`; link chips from `factory-copy.json` → `fabric.links[]` | Snapshot at collector run; `_meta.live_snapshot_ok: false` and `factory-telemetry.json` errors: `live_cluster_snapshot … private endpoint unavailable`. Only 2 of 6 mapped hosts appear | -| 7 | **BuildStream Cache Utilization heatmap** | How full are the remote-execution caches? | ECharts **heatmap**, 2 drives (ghost, exo-0) × 3 caches (bazel-remote CAS, Buildbarn CAS, Buildbarn AC), `visualMap` 0–100 %, gray `outOfRange` for unavailable | `data/bst-cache.json` → `rows[]` (`node,cache_backend,storage_type,percent,used_bytes,capacity_bytes,state,state_reason`) | **All 6 cells currently render "N/A" gray**; tooltip surfaces `state_reason` ("cluster unreachable or bst-artifact-server not responding on /status") | -| 8 | **Cache heat trend** | Is cache traffic (GET requests / hit-miss) trending? | ECharts multi-series **time line** | `docs/data/history/cache-heat.ndjson` (not published) | Currently the amber line "Cache heat trend unavailable: no historical cache snapshots are published yet." Chart div hidden | -| 9 | **40 Gbps Thunderbolt Link** | Is the fast interconnect up? | Big-number stat card + latency + two link-state words | `bst-cache.json` → `usb4_telemetry.bandwidth_gbps: 40.0`, `latency_ms: null`, `ghost_link/exo0_link: "unavailable"`, `status: "unavailable"` | Renders **"Latency: ms"** (empty bold) because `latency_ms` is `null` and the template interpolates unconditionally. Header pill reads "Link DOWN · 40 Gbps" | -| 10 | **Work Distribution** | How is build work split across nodes? | Two-tone **split progress bar** (ghost 45 % / exo-0 55 %) | `bst-cache.json` → `usb4_telemetry.work_distribution` | Static constants; **no `source_url`, no `collected_at`, no `state`** | -| 11 | **Cold vs Warm Speedup** | How much does the cache buy you? | Big number "4.2x" + Cold 20.2m → Warm 4.8m | `usb4_telemetry.cold_build_duration_min/warm_build_duration_min/speedup_ratio` | Static constants inside an object whose `status` is `"unavailable"` | -| 12 | **Layer Rechunking Speed** | How fast is image re-chunking? | Big number "145s" | `usb4_telemetry.rechunk_duration_sec: 145` | Static constant, unsourced | -| 13 | **Recent Runs & Builds** | What has the lab been doing lately? | Dense changelog list, 12 rows: status pill + label + UTC timestamp + duration + trigger | `factory-stats.json` → `recent_runs[0..11]` (`overall,label,started_at,duration_min,trigger`) | **Latest entry `2026-08-01T15:47:00Z` — six days older than the page's own "updated Aug 7" stamp, with no staleness warning** | -| 14 | **Open Bugs (n)** | What is known-broken? | Dense list: issue link + created date | `factory-stats.json` → `open_bugs[]` | Currently 1 item (#552). Has a real empty state | -| 15 | **Per-lane build sparklines** _(computed, never displayed)_ | Are lane build durations drifting? | ECharts line w/ pass/fail colored symbols (`bootTriageSparks`, `.triage-spark[data-lane]`) | `factory-stats.json` → `image_builds[lane][]` last 20 runs | **Dead**: the only `.triage-spark` elements live inside the "Raptor Grid Leaderboard" section, gated by `raptorEntrants.length > 0` where `const raptorEntrants: any[] = [];` | -| 16 | **Data-integrity footer** | Where did this come from, when? | One-line link + timestamp | `factory-stats.json` `_meta.generated` | Always rendered | - -**Other routes on the same site** (from `lab:src/pages/`): `/about/`, `/adoption/` (81 KB), `/applications/`, `/builds/`, `/community/`, `/evidence/`, `/images/`, `/provisioning/`, `/tests/`, `/userspace/`. - -**`/tests/` sub-panels** (page is ~2.8 MB): (a) **"Needs triage"** ranked operator list of 62 rows (gate failures → freshness → streaks → flakes → not-enrolled); (b) **"Tests at a glance"** trend charts; (c) **"Run history and current state"** charts + a keyboard-accessible text drilldown listing all 64 rows; (d) **"Suite by variant matrix"** (gate/info coded grid, gray = unavailable, cells link to enrollment issues); (e) **per-row evidence cards** (failed scenarios, run history, screenshots, workflow UIDs); (f) triage/local-repro runbook; (g) "About the test split" explainer. Charts come from `lab:src/components/TestsCharts.astro`. - ---- - -## B) Quality ranking — what to carry, what to fix, what to drop - -### Tier 1 — carry over, excellent - -1. **Release verdict cards ("Is the last release good?")** — the best thing on the site. It answers _the_ end-user question, it is backed by a written, versioned definition (ADR 0002: build ∧ lab-QA-on-exact-digest ∧ cosign), it shows the digest so the claim is falsifiable, and it deliberately refuses to gate on newly disclosed CVEs (ADR 0002: _"that signal punishes releasing, which is backwards"_). It degrades honestly — a lane with missing lab evidence renders `pending`, never "good". Carry the _concept_ verbatim; see C for the "failed = still running" bug that must be fixed first. -2. **Image status freshness cards with embedded test pills** — "6d / testing / Published Aug 1" plus a strip of colored suite pills that deep-link into per-suite evidence is dense, scannable, and genuinely end-user relevant ("how old is the image I'd install, and did it boot?"). The per-branch thresholds (7 d testing / 14 d stable) are sensible. Legible without insider knowledge except for the suite abbreviations. -3. **Rolling 7-day build health KPI strip + stacked outcome bars** — 79 builds / 94 % / 26 m median is exactly the "serious automated system" flex, in numbers a non-expert reads instantly, and the stacked green/red daily bars give shape without a legend lesson. Explicit about scope ("plane: publish", "GitHub Actions publish builds are authoritative here; Argo Workflows lab runs remain separate") and has a real unavailable state. -4. **Contributor Clusters machine cards** — emotionally the strongest panel: "each card is a real machine running real tests," with live CPU/mem gauges, real OS image strings (`Bluefin (Version: testing-44.20260708.3)`, `Fedora CoreOS 44.20260707.3.1`), thread/RAM capacity, and a 40 G link chip. This is the community-hardware story no other distro dashboard tells. **Must strip the LAN IPs (`192.168.1.102`, `192.168.1.170`) before going public.** -5. **`/tests/` "Needs triage" ranked list + suite-by-variant matrix** — the ordering policy (gate failures first, then freshness, streaks, flakes, coverage gaps) is real product thinking, and the gate-vs-info distinction is explained on-page. The matrix conveys scale honestly (gray = unavailable rather than fake green). Carry a _condensed_ version. - -### Tier 2 — good idea, weak execution - -6. **"What's degrading?"** — right instinct, poor signal-to-noise. Of 10 current rows, 6 are literal restatements of the verdict cards above it ("bluefin-stable release is bad" / "bluefin-testing QA evidence stale"), so the panel is mostly an echo. Two rows are also self-contradictory: _"bluefin-stable release is bad — build: latest publishing run concluded **running**"_ labels an in-flight build as a failure. **Fix:** exclude anything already shown in a verdict card; keep only genuinely _new_ trend signals (duration regression, fail streaks, freshness breaches); never emit "failed" for `running`; and add a "what this means for you" clause per row. -7. **BuildStream cache heatmap** — the form is right (2×3 heatmap with a 0–100 % visualMap and gray unavailable cells is legible and impressive _when populated_), but today **all six cells read "N/A"** and every tooltip says "cluster unreachable". Also the explanatory paragraph ("allocator counters × known block size, not raw disk bytes") is honest but requires insider vocabulary. **Fix:** don't ship the heatmap unless the collector reaches the cluster; if it can't, replace with a single "cache hit rate" number or hide the section entirely rather than showing a gray grid to end users. -8. **Cold vs Warm Speedup (4.2×)** — the _most_ end-user-legible engineering flex on the page ("a rebuild that takes 20 minutes cold takes 5 minutes warm"). But it is currently a hardcoded literal in `bst-cache.json` `usb4_telemetry` with **no `source_url`, no `collected_at`, and a parent `status: "unavailable"`**. **Fix:** derive it from real `build-runs.ndjson` durations (cache-warm vs cache-cold runs) with provenance, or drop it. As-is it fails the project's own row-contract standard. -9. **40 Gbps Thunderbolt link panel** — great story, currently visibly broken: it prints **"Latency: ms"** with nothing between, and says "ghost link: unavailable · exo-0 link: unavailable" directly under a giant "40 Gbps". **Fix:** null-guard the latency interpolation, and reframe as a _static architecture fact_ ("nodes are meshed over 40 Gbps USB4 — see architecture doc") rather than fake live telemetry, unless a real prober is wired. -10. **Recent Runs & Builds** — a nice "the machine never sleeps" ticker, but currently shows entries from **Aug 1** on a page stamped **Aug 7**, with no staleness treatment, and many rows read "Duration: 0m", which looks broken. **Fix:** add an age badge, hide rows older than the freshness threshold behind an explicit "collector stale" state, and suppress or relabel 0-minute poller noise (`orphan-pod-gc-…`, `pr-label-poller-…` are not interesting to end users). -11. **Per-lane build sparklines** — well-implemented (duration line, symbols colored by pass/fail, tooltip with timestamp+duration+outcome) and backed by real `image_builds` data, but **rendered nowhere**. **Fix:** attach one 120×38 sparkline to each release-verdict card or image-status lane. This is the cheapest available density win. - -### Tier 3 — drop - -12. **Raptor Grid Leaderboard** (`lab:src/pages/index.astro`, "Motorsports Telemetry — Factory Leaderboard") — dead code (`raptorEntrants = []`) whose hardcoded header pills assert "40 Gbps USB4 Link: ACTIVE", "BuildBarn RE Grid: 24 Workers", "Dakota RE Target: 12m-14m" — none of which is backed by any contract. Racing metaphors ("Best Lap (Warm)", "Avg Pit Stop", "Heavy Stint") obscure rather than explain. Drop the framing; salvage only the sparkline column. -13. **Cache heat trend** — currently a permanent amber "unavailable" line because `docs/data/history/cache-heat.ndjson` is never published. An empty panel that has never had data should not exist on a public page. -14. **Work Distribution (45 % / 55 %)** — an unsourced constant that answers no end-user question. Even if real, "which of two machines got 55 % of the actions" is pure insider trivia. -15. **Layer Rechunking Speed (145s)** — unsourced constant, unexplained jargon ("re-chunking over high-throughput USB-4 CAS sync"). No end user knows what a chunk is. -16. **Open Bugs** — currently one issue about a test fixture (`#552: test: align Dakota upstream availability fixture`). Not a user-facing signal; drop from the public page or replace with "known issues affecting installs". -17. **Argo/lab internal run labels in Recent Runs** (`pr-label-poller-1785599100`, `orphan-vm-cleanup-…`, `cosmic-commit-poller-…`) — infrastructure exhaust. Also note the backing `run_url`s point at `http://192.168.1.102:32746/...` (unreachable private endpoints) — never surface these publicly. - ---- - -## C) Observed data-quality and honesty problems (with numbers) - -**Yes — the page currently reads as broken to a first-time visitor.** Quantified: - -| Signal | Numbers | Source | -| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Image lanes with a real published-release timestamp | **1 of 15 (6.7 %)** — only `bluefin-testing` (6 d). 14 lanes render "—" + "Awaiting Evidence" | `upstream-status.json` `summary_metrics`: `tracked_upstream_lanes: 15`, `lanes_with_release_data: 1`, `lanes_without_release_data: 14`; `_meta.status: "partial"` | -| Release lanes judged "good" | **0 of 5 (0 %)** — all five cards render `bad` | `release-verdict.json` `summary_metrics[0]`: `id: lanes_good, value: 0, total: 5` | -| CVE-regression chips populated | **0 of 5** — all say `unavailable` ("publisher-side CVE summary export not yet wired (phase 3)") | `release-verdict.json` `rows[].security_regression.status` | -| Cache heatmap cells with data | **0 of 5 published cells (0 %)**; the grid draws 6 cells so one is "missing from published dataset" entirely | `bst-cache.json` `summary_metrics`: `cache_cells_total: 5`, `cache_cells_available: 0`, `cache_cells_unavailable: 5` | -| Test-matrix rows with results | **14 of 64 (21.9 %)**; **50 of 64 (78.1 %) "waiting for results"**; only **11 rows (17 %)** have canonical `qa-run-v1` evidence; 3 flaky rows | `tests-matrix.json` `summary_metrics` | -| Suite result docs published | **20 of 27 expected (coverage_ratio 0.7407)**; 7 missing incl. `results/bluefin-stable-smoke.json`, `results/bluefin-lts-stable-smoke.json`, `results/bazzite-testing-smoke.json` | `factory-telemetry.json` `coverage` | -| Mini test pills on the main page showing `missing` | **18 of 33 pills (54.5 %)** — all 6 for Aurora, all 6 for Bazzite, all 6 for Fedora bootc; only **4 pills are green** | Live HTML `class="mini-test-pill status--missing"` | -| Scenario failures | **185 of 771 scenarios failed (24 %)**; the `common` suite alone is 156/230 failed (32 % pass) | `factory-stats.json` `test_coverage` | -| Telemetry snapshot state | `state: "degraded"`, with an explicit error: `live_cluster_snapshot — private endpoint unavailable or incomplete from GitHub-hosted runner → public telemetry downgraded to unknown/degraded` | `factory-telemetry.json` `snapshot`, `errors[0]` | -| Live cluster snapshot | `live_snapshot_ok: false` | `factory-stats.json` `_meta` | - -**Additional honesty defects (each independently disqualifying for a public page):** - -1. **"failed" is used to mean "still running."** Three of five verdict cards show `build: failed` with `reason: "latest publishing run concluded running"` and `finished_at: null` (`release-verdict.json` rows `bluefin-stable`, `bluefin-testing`, `dakota-testing`). The page then propagates this into "What's degrading?" as _"failing · bluefin-stable release is bad"_. The factory is **not** failing — 94 % of publish builds passed in the same window. This single mapping bug is why the page looks like a disaster area. -2. **Freshness measures the collector, not the data.** `_meta.freshness: {age_minutes: 1, state: "fresh"}` while `recent_runs[0].started_at` is `2026-08-01T15:47:00Z` — six days old. The page says "updated Aug 7" over week-old content. -3. **The static page can contradict its own JSON.** The prerender was collected `22:01 UTC`; `release-verdict.json` regenerated at `22:15 UTC` with different values (e.g. `bluefin-lts-testing.qa.status` is `failed — gate QA suite(s) failing: system` in JSON, but the rendered card says `pending — …smoke…`). -4. **Unverifiable numbers presented as telemetry.** `usb4_telemetry` carries `status: "unavailable"`, `latency_ms: null`, `ghost_link/exo0_link: "unavailable"` — yet the page renders 40 Gbps, 4.2×, 20.2m→4.8m, 145s, 45/55 as if measured. None of these five fields has `source_url`, `collected_at`, `state`, or `derivation`, unlike every other row in the codebase. This violates the project's own row contract from ADR 0002. -5. **Rendering bug from an unguarded null:** "Latency: **** ms" prints with an empty value. -6. **Implausible counters on `/tests/`:** "bluefin / stable / smoke — GATE · **597-run failure streak**" and "bluefin-lts / stable / smoke — **440-run failure streak**", the first attached to a row simultaneously labeled "Running work". These are polling-snapshot counts, not distinct runs, and they read as absurd. -7. **Two panels disagree about concurrency:** build-health says `Running work: 0`, while `factory-telemetry.json` `queue_pressure_now` = `11` (`count(runs where overall=running)`, denominator 25). Different planes, no on-page reconciliation. -8. **Private-network disclosure:** LAN IPs in `nodeSpecs` (`192.168.1.102`, `.170`, `.171`, `.223`, `.225`, `.239`) render on the page, and `factory-stats.json` `recent_runs[].run_url` exposes `http://192.168.1.102:32746/workflows/argo/...` publicly. -9. **Only 2 of 6 mapped contributor machines appear** (`exo-1`, `exo-2`, `hamilton`, `bazzite` are in `nodeSpecs` but absent from `factory.cluster.nodes`), so the "community hardware" story renders at one-third strength with no "offline" tombstones. - ---- - -## D) The data actually available - -### `data/factory-stats.json` (~100 KB) — top-level keys - -| Key | Shape | Populated? | -| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `recent_runs[]` | `{id, overall: passed\|fail\|running, label, started_at, finished_at, duration_min, trigger: manual\|poller\|pr-poller, run_url}` | 25 entries, **stale (newest 2026-08-01)**; `run_url` is a private `192.168.1.102:32746` Argo URL; `label` often `null` | -| `_meta` | `{generated, refreshed_at, live_snapshot_ok: false, freshness_minutes: 1, freshness:{age_minutes,threshold_minutes:180,state:"fresh"}, source:"github-actions/update-test-results"}` | Yes (but see C2) | -| `open_bugs[]` | `{number, title, url, created_at, affects: [], area: "unknown"}` | 1 entry; `affects`/`area` empty | -| `test_coverage` | `{scenarios_total: 771, scenarios_failed: 185, images_with_results: 4, coverage_by_suite: {developer:{images:3,scenarios:63,failed:1}, smoke:{3,411,20}, common:{2,230,156}, system:{3,42,7}, software:{3,15,1}, flatcar:{1,10,0}}}` | **Fully populated — the richest under-used dataset on the site** | -| `github.testing_lab` | `{open_issues: 0, open_prs: 0, prs_merged_7d: 71}` | Populated; **`prs_merged_7d: 71` is not rendered anywhere** | -| `factory.cluster` | `nodes[]: {name, status:"ready", role, ram_gb, cpu_threads, os_image, kernel_version:"7.1.3-200.fc44.x86_64", kubelet_version:"v1.36.2+k3s1", cpu_usage_pct, mem_usage_pct, load_1m_history:[…8-9 floats]}`, `total_ram_gb: 125` | 2 nodes. **`kernel_version`, `kubelet_version`, and `load_1m_history` are populated but never displayed** — free density | -| `factory.images.` | `{repo, source:"github-releases", releases_api, stable_seen_at, stable_age_days, stable_tag, stable_source_url, testing_seen_at, testing_age_days, testing_tag, testing_source_url}` for `bluefin, bluefin-lts, dakota, aurora, bazzite, snosi` | **Only `bluefin.testing_*` is non-null** (`"2026-08-01T15:11:54Z"`, `6`, tag `image-poll-fedora-bootc-latest-1785597000`); all 11 other slots are `null` | -| `pipelines.containerdisk` | `{runs_7d: 421, runs_all_time: 1567}` | Populated; **never rendered** — a great "flex" number | -| `build_history.[]` | Keys: `dakota-build-pipeline`, `dakota-qa-pipeline`, `bluefin-qa-pipeline`, `bluefin-server-build-pipeline`, `build-cd-sync`, `cosmic-build-pipeline`, `build-containerdisk`; rows `{id, overall, label, started_at, finished_at, duration_min, trigger, run_url}` | Populated but **overwhelmingly `fail`** and months-old; private run URLs | -| `image_builds.[]` | Lanes: `bluefin-stable, bluefin-testing, bluefin-next, bluefin-lts-stable, bluefin-lts-testing, bluefin-lts-hwe, bluefin-lts-nvidia, dakota, dakota-aarch64`; rows `{id, overall: passed\|fail\|pending\|running, started_at, finished_at, duration_min, run_url (public GitHub), branch}` | **Well populated and public — the best raw signal in the file.** ~20 runs/lane, durations 0–227 min. Note the large number of `pending` rows (GH runs that never reached a clean conclusion) | - -### `data/history/build-runs.ndjson` (~188 KB, one JSON object per line) - -```json -{ - "recorded_at": "2026-07-19T18:27:06Z", - "plane": "publish", - "repo": "projectbluefin/bluefin", - "lane": "bluefin-stable", - "run_id": "28785894951", - "status": "passed", - "started_at": "2026-07-06T10:44:59Z", - "finished_at": "2026-07-06T11:36:46Z", - "duration_min": 52, - "run_url": "https://github.com/projectbluefin/bluefin/actions/runs/28785894951", - "failure_stage": null -} -``` - -Consumer contract in `lab:src/pages/index.astro` (`interface PublishBuildRun`): dedupe by `plane:run_id` keeping max `recorded_at`; filter `plane === 'publish'` and `status ∈ {passed,failed,running}`. `failure_stage` exists in the schema but is `null` in every record I sampled — **a ready-made "where do builds break?" breakdown that has no data yet.** This is the only genuinely durable, public-URL, long-window dataset the site has. - -### Other contracts - -- **`data/release-verdict.json`** — `schema_version: 1`, `_meta.status: "live"`, `summary_metrics[]`, `rows[]` with `{id, lane, variant, branch, image_ref: "ghcr.io/projectbluefin/bluefin:testing", digest: "sha256:bf615…", verdict: good|bad|pending, build{status,reason,run_url,finished_at}, qa{status,reason,last_run,lab_sourced:true, informational{status,reason,rows[]}}, signature{status:"passed", method:"cosign keyless (GitHub Actions OIDC)"}, security_regression{status,reason}, source_url, collected_at, derivation, state, state_reason}`. Nested `qa.informational.rows[]` carry full per-suite evidence incl. `run_history[]` of `qa-run-v1` snapshots, `terminal_failure_streak`, `flake_flips`, `screenshot_url`. -- **`data/upstream-status.json`** — `schema_version: "v1"`, `_meta.status: "partial"`, `groups[]` (`gnome-os`, `fedora-bootc`, `projectbluefin`, `ublue`, `cosmic`), `rows[]` `{id, group, variant, display_name, publisher_repo, org, branch, published_at, freshness_age_days, open_prs (always null), state, state_reason, source_url, collected_at, derivation}`. -- **`data/tests-matrix.json`** — `schema_version: "v3"`; `suite_roles: {smoke:"gate", system:"gate", flatcar:"gate", developer:"info", software:"info", common:"info"}`; `dimensions.variants` (12), `.branches` (`stable`,`testing`), `.suites` (6); rows carry `result_status`, `pass_rate`, `evidence_state`, `terminal_failure_streak`, `flake_flips`, `run_history[]`, `screenshot_url`, `enrollment_issue_url`, `compatibility{contract:"tests-matrix-v2", legacy_results_path}`. -- **`data/factory-telemetry.json`** — `schema_version: "v2"`; `lineage{collector.workflow: ".github/workflows/update-test-results.yml", run_id, run_url, commit_sha, commit_url, inputs_digest_sha256, inputs[]{path,sha256}}` — **a genuine, per-file-hashed provenance chain, and arguably the most "engineering rigor" artifact on the whole site, currently invisible on the overview page**; `coverage{expected_result_docs:27, observed_result_docs:20, coverage_ratio:0.7407, missing_docs[]}`; `metrics[]` = `suite_pass_rate_24h 71.43 %` (10/14), `scenario_pass_rate_24h 76.01 %` (586/771), `queue_pressure_now 11`; `errors[]`. -- **`data/bst-cache.json`** — `rows[]` all `state:"unavailable"`; `capacity_bytes` known (`21474836480` CAS, `20971520` AC) but `used_bytes`/`percent` `null`; plus the unsourced `usb4_telemetry` block. -- **`data/factory-copy.json`** — editorial strings + `fabric.links[{from:"ghost", to:"exo-0", speed_gbps:40, status:"up", source_url}]` and `screenshots[]` pointing at `screenshots/--latest.png`. - ---- - -## E) Recommendations — the 6 ideas the new public page must keep - -1. **Release verdict cards, rebuilt around a plain-English headline.** Keep the ADR-0002 three-input structure (build ∧ QA-on-exact-digest ∧ cosign) and the visible digest — that's the credibility. But lead each card with a sentence an end user reads in one second ("**Bluefin testing — safe to update**" / "**verification in progress**"), demote the four chips to a detail row, and **never render `running` as `failed`**. Data: `release-verdict.json` `rows[]`. This is the only panel that answers "should I update today?" - -2. **Rolling 7-day build health KPI strip + the stacked daily outcome bars.** 79 builds, 94 %, 26 m median, all from a public, permanent, well-shaped dataset (`build-runs.ndjson`) that will _never_ look empty. It is the honest counterweight to the verdict cards' current all-red state and it is the single best "a serious automated system builds this OS" number on the site. Widen the window to 30 days so the numbers get bigger and steadier. - -3. **Image freshness cards with the mini test-pill strip — but only for lanes that have evidence.** "6d · testing · Published Aug 1 · smoke ✅ sys ❌" in a single card is dense, honest, and clickable. **Change the failure mode:** instead of nine cards mostly reading "—/Awaiting Evidence" (14/15 lanes today), render only populated lanes and put the rest behind one honest line — "9 upstream lanes tracked, collectors not yet publishing release timestamps (see contract)". Same truth, none of the "broken site" impression. - -4. **Contributor Clusters, expanded into the flagship panel.** Real machines, real OS strings, live CPU/mem gauges — nobody else in the Linux-distro world shows this. Add the fields already in the data but unused: `kernel_version`, `kubelet_version`, `load_1m_history` (turn it into a per-node sparkline), `total_ram_gb: 125`, and tombstone cards for the four offline hosts so the community story is 6 machines, not 2. **Remove every LAN IP.** - -5. **A condensed gate-vs-info test matrix with the "Needs triage" ordering.** The gate/info split is defensible, explained on-page, and directly maps to "did the OS boot, upgrade, and roll back?" — which is what an end user cares about. Carry the compact matrix and the top ~5 triage rows, link out for the rest; do **not** carry the 2.8 MB page or the 62-item "Not enrolled" list. Fix the `597-run failure streak` counter before it goes public. - -6. **The provenance/lineage footer, promoted from footnote to feature.** `factory-telemetry.json` `lineage` gives a collector workflow, run URL, commit SHA, an `inputs_digest_sha256`, and a per-file SHA-256 for all 20 result documents, plus `coverage_ratio: 0.7407` and an explicit `errors[]`. A small "how this page knows what it knows — 20/27 evidence files, digest `sha256:8076f7…`, collected from run #31223118889" block is _the_ engineering-rigor flex, it costs almost nothing, and it makes every other number on the page credible. - -**Bonus, cheap:** (7) attach the already-written per-lane duration **sparkline** (`bootTriageSparks`) to each verdict/image card — it exists and renders nothing today; (8) surface `pipelines.containerdisk.runs_7d: 421 / runs_all_time: 1567` and `github.testing_lab.prs_merged_7d: 71` as headline counters — both are populated, both are impressive, neither is displayed anywhere. - -**Do not carry:** the Raptor Grid Leaderboard (dead code + unsourced assertions), the cache heat trend (never had data), Work Distribution and Layer Rechunking (unsourced constants, no user question), Open Bugs (currently a test-fixture ticket), and the raw Argo run ticker (private IPs, poller noise). Carry the cache heatmap and the Thunderbolt/speedup story **only if** a real collector is wired — otherwise they are the two panels most likely to make the new page look broken or dishonest. - ---- - -## Gaps and uncertainties - -- **Not fetched:** `/builds/`, `/images/`, `/evidence/`, `/adoption/`, `/applications/`, `/provisioning/`, `/userspace/`, `/about/`, `/community/` — I confirmed these routes exist from `lab:src/pages/` but did not audit their visuals (the brief scoped me to the overview + tests pages). -- **Not fetched:** `data/history/qa-runs.ndjson`, `data/history/test-runs.ndjson`, `data/variant-publishers.json`, `data/test-surface.json`, `data/page-contracts.md`, `docs/results/*.json` — all referenced as `source_url`s in the contracts above; their schemas are inferred from consumer code, not read directly. -- **Not read:** `lab:src/components/TestsCharts.astro` (10.8 KB) and `lab:src/pages/tests.astro` (14.8 KB) — so my description of the `/tests/` chart types comes from the rendered page text, not the chart config. Also unread: `src/scripts/`, `src/lib/`, and the collectors under `scripts/` (e.g. `scripts/collect_release_verdict.py`, named in ADR 0002). -- **Line-number citations:** the repo's dashboard source is a single 80 KB `index.astro`; I cite by section marker/identifier (e.g. the `nodeSpecs` map, the `degrading` loop, `bootBstCacheChart`) rather than line ranges because I read it via three paginated raw fetches rather than a local checkout. -- **`load_1m_history`** has 8 entries for `exo-0` and 9 for `ghost` with no timestamps — sampling interval is undocumented, so my "turn it into a sparkline" suggestion assumes a regular cadence I could not verify. -- **Timing caveat:** the live HTML was prerendered at `22:01 UTC` and the JSON contracts I read were generated at `22:15 UTC`; a few per-lane statuses therefore differ between my "live page" quotes and my "JSON field" quotes. I flagged the specific instance (`bluefin-lts-testing.qa`) in section C. diff --git a/adr/research/sparkline-system.md b/adr/research/sparkline-system.md deleted file mode 100644 index 08c227bc2..000000000 --- a/adr/research/sparkline-system.md +++ /dev/null @@ -1,204 +0,0 @@ -# Sparkline system — Tufte/Few research and failure modes - -> Research output supporting ADR 0002. Generated 2026-08-07 by a background research agent. -> Findings were spot-checked against live endpoints; see ADR 0002 for what was adopted. - -I found something important in the repo while researching this: **there are four separate sparkline implementations already shipping, with two mutually incompatible scaling semantics.** That reframes this section from "add sparklines" to "unify the ones you have, then scale out." - ---- - -# Sparkline Research Thread + Section G - -## A3) Sources for this thread - -| Source | What it actually says | -| ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| https://en.wikipedia.org/wiki/Sparkline | Definition: "a very small line chart, **typically drawn without axes or coordinates**. It presents the general shape of a variation (typically over time)… in a simple and highly condensed way." The contrast with normal charts is stated explicitly: "Whereas a typical chart is designed to professionally show as much data as possible, and is **set off from the flow of text**, sparklines are intended to be **succinct, memorable, and located where they are discussed**." Tufte's own phrasings are quoted: he "documented a compact style in 1983 called '**intense continuous time-series**'"; he "introduced the term _sparkline_ in 2006 for '**small, high resolution graphics embedded in a context of words, numbers, images**'", which are "'**data-intense, design-simple, word-sized graphics**'". Sizing rule: "**The sparkline should be about the same height as the text around it.**" Grouping: "Sparklines are small enough to be embedded in text, or **several sparklines may be grouped together as elements of a small multiple**." Provenance: first software sparkline was Peter Zelchenko's 1999 "inline-chart" feature in Medved QuoteTracker; Tufte later attributed the idea to Knuth's _METAFONTbook_. | -| https://en.wikipedia.org/wiki/Bullet_graph | "A bullet graph is a variation of a bar graph developed by **Stephen Few**… the bullet graph serves as a **replacement for dashboard gauges and meters**. Bullet graphs were developed to overcome the fundamental issues of gauges and meters: they typically **display too little information, require too much space, and are cluttered with useless and distracting decorations.**" Anatomy: "features a **single, primary measure**… compares that measure to one or more other measures to enrich its meaning (for example, **compared to a target**), and displays it in the context of **qualitative ranges of performance, such as poor, satisfactory, and good**." Decoding key: "The thick, horizontal center line represents the **actual value**. The thin, black vertical line represents a **target value**. The colored or grey scale bands represent **ranges**." And the crucial accessibility clause: "The qualitative ranges are displayed as **varying intensities of a single hue** to make them **discernible by those who are color blind** and to restrict the use of colors on the dashboard to a minimum." Also: "may be horizontal or vertical and may be **stacked to allow comparisons of several measures at once**." Source book: Few, _Information Dashboard Design_, 2006. | -| **Repo:** `src/components/Sparkline.tsx:26-35` | Min–max normalized: `const min = Math.min(...data); const max = Math.max(...data); const range = max - min \|\| 1;` then `y = height - pad - ((v - min) / range) * (height - pad*2)`. Defaults `width=120, height=28`, `color="#58a6ff"`. `aria-hidden="true"` (line 47). Comment at line 8: "CSS custom properties not supported in inline SVG". | -| **Repo:** `HiveFactoryDashboard.tsx:923-945` (`Sparkline`) | A _second_ implementation. `W=120, H=36`, `const max = Math.max(...data, 1)` — **zero-baseline, not min-max**. | -| **Repo:** `HiveFactoryDashboard.tsx:949-978` (`MiniSparkline`) | A _third_. `W=100, H=24`, again zero-baseline `Math.max(...data, 1)`, `y = H - 2 - (v/max)*(H-6)`. Colors via CSS classes with a 4-value union `type SparkColor = "default" \| "green" \| "amber" \| "purple"` (line 947). `aria-hidden="true"` (line 972). | -| **Repo:** `HiveFactoryDashboard.tsx:1539-1541, 1589-1606` (`sparkPoints` + inline `historySparkline`) | A _fourth_. `W=160, H=36`, zero-baseline. Six metrics defined at `:1559-1566` with hardcoded GitHub-dark hexes: ACMM Level `#d29922`, Budget Used `#f85149`, Queue Depth `#58a6ff`, Advisories `#bc8cff`, Merged/Cycle `#3fb950`, Median Merge `#f0883e`. | -| **Repo:** `HiveFactoryDashboard.tsx:1605` | **An existing good pattern:** when `hasData` is false it renders the string `"accumulating data"` instead of an empty chart. | -| **Repo:** `HiveFactoryDashboard.tsx:836, 2638` | **An existing bad pattern:** `spark && spark.some((v) => v > 0) && (...)` — an all-zero series makes the sparkline **vanish entirely**. | -| **Repo:** `FactoryCharts.tsx:324-328` | **The accessibility pattern to copy:** each chart container carries `role="img"` plus a full prose `aria-label`, e.g. `aria-label="Build time: LTS 11 to 15 minutes, Dakota 114 to 273 minutes."` | -| **Repo:** `static/data/registry-data.json:242` (`issueHistory`), `:2932` (`prHistory`) | Both are `{t, v}` arrays; `t` is **epoch seconds** at roughly 960 s (16 min) spacing (e.g. `1785073403 → 1785074363 → 1785075323`). | -| **Repo:** `static/data/hive-history.json` | 2-hourly `{t, acmmLevel, govMode, queue, agents, runningAgents, advisories, medianMergeMins}`. | - -**Primary-source gap, stated plainly:** I could not reach Tufte's own site. `edwardtufte.com/tufte/books_sparklines/`, `/notebook/sparklines/`, and `/tufte/beautiful_evidence` all returned **404**, and `edwardtufte.com/bboard/q-and-a-fetch-msg?msg_id=0001OR` redirected to a cookie-consent notice with no content. **Every Tufte quotation above is taken from the Wikipedia article, which cites _Beautiful Evidence_ — I have not read Tufte's text directly in this pass.** Likewise Stephen Few: `perceptualedge.com/articles/misc/Bullet_Graph_Design_Spec.pdf` fetched but returned undecodable binary PDF; the bullet-graph specifics come from Wikipedia's summary of _Information Dashboard Design_. - ---- - -## B3) What the sources actually establish - -**1. A sparkline is a word, not a chart.** "Word-sized graphics"; "located where they are discussed"; "about the same height as the text around it" (https://en.wikipedia.org/wiki/Sparkline). The design implication is that a sparkline belongs **inside a sentence or a table cell**, next to the number it describes — not floating in its own bordered card with a title. Bluefin's current `historyTrendCard` treatment (`HiveFactoryDashboard.tsx:1583-1607`) is label-above / number-above / chart-below, which is a mini-chart, not a sparkline. Inline is both more Tufte and more compact. - -**2. No axes is the default, not a compromise.** "typically drawn without axes or coordinates" — because the sparkline's job is **shape**, not lookup. This is licence to strip everything, _provided_ the current value is printed as a number next to it. The number carries the scale; the line carries the trend. Never ship one without the other. - -**3. Small multiples are the sanctioned grouping.** "several sparklines may be grouped together as elements of a **small multiple**" — the Wikipedia definition puts this in the first paragraph. A grid of identical sparklines across image lanes is not a stylistic choice; it is the form's canonical plural. - -**4. Bullet graphs exist specifically to kill gauges.** Few's stated motivation — gauges "display too little information, require too much space, and are cluttered with useless and distracting decorations" (https://en.wikipedia.org/wiki/Bullet_graph) — is a direct argument against the "speedometer" treatment that dashboards reach for. For "merge time vs. the <30 min SLA", a bullet graph is the correct form and a gauge is the wrong one. - -**5. Few solved the colour-blindness problem structurally, not with a palette swap.** Bullet graph ranges use "**varying intensities of a single hue**… to make them discernible by those who are color blind and to **restrict the use of colors on the dashboard to a minimum**." That is the single best design rule available for this page, and it generalizes: encode severity by _lightness_, reserve _hue_ for category, and never let hue alone carry a verdict. - ---- - -## Where sparklines fail — and the failures already present in this repo - -This is the honest part the maintainer asked for. Every one of these is live in the codebase today. - -**F1. Two incompatible scaling semantics, same-looking glyph.** `src/components/Sparkline.tsx:26-28` normalizes to **min–max** (the lowest value sits at the bottom of the box). `MiniSparkline` at `HiveFactoryDashboard.tsx:953` and `sparkPoints` at `:1539` normalize to a **zero baseline**. These produce dramatically different pictures from identical data: min–max turns a series that wobbles 800→810 into a mountain range; zero-baseline turns it into a flat line. **Two glyphs that look the same but mean different things is the worst possible outcome**, and a reader has no way to tell which is which. Pick one per _use case_, label it, and never mix within a grid. - -**F2. Misleading autoscaling — the "flat data looks dramatic" failure.** Min–max normalization always fills the box, so **noise is indistinguishable from signal**. `hive-history.json` shows `agents: 6, runningAgents: 5` essentially constant across the whole window; a min–max sparkline of that renders as a jagged, alarming zigzag over a range of ±1. Fix: min–max only when you also print the min and max as numbers, or clamp to a minimum domain span. - -**F3. Silent disappearance.** `HiveFactoryDashboard.tsx:836` and `:2638` gate on `spark.some((v) => v > 0)`. A genuinely-zero series (zero advisories — good news!) makes the sparkline **vanish**, and the panel silently renders less. This is precisely what ADR 0002 forbids: "a dashboard that silently renders less is indistinguishable from a healthy one with less to report" (`adr/0002-factory-page.md`). The fix already exists twenty lines away — `:1605` renders `"accumulating data"` instead. Use that everywhere, and add a distinct `"steady at zero"` state. - -**F4. Irregular time intervals rendered as regular.** Every implementation maps index to x (`x = (i / (data.length - 1)) * W`), ignoring `t` entirely. `registry-data.json`'s `issueHistory` is ~960 s spacing but `hive-history.json` intervals visibly vary (sampled gaps of 6,116 s, 8,546 s, 8,915 s). Any pipeline hiccup silently compresses real time. Fix: either map x from `t` on a true time domain, or bucket into fixed intervals before rendering, and say which. - -**F5. Too few data points.** With 3–4 points a "sparkline" is a jagged polyline that implies a trend that isn't there. Set a floor (I'd say **≥12 points**) and fall back to `"accumulating data"` below it. The existing guard is only `data.length < 2` (`Sparkline.tsx:24`). - -**F6. Colour carrying meaning alone.** `HiveFactoryDashboard.tsx:1560-1566` pairs `#f85149` (red, Budget Used) against `#3fb950` (green, Merged/Cycle) — the classic red/green deuteranopia collision. Compounded by `Sparkline.tsx`'s own note that "CSS custom properties not supported in inline SVG" (line 8), so these are baked hexes that don't respond to light mode either. - -**F7. Invisible to screen readers.** All four implementations are `aria-hidden` (`Sparkline.tsx:47`, `HiveFactoryDashboard.tsx:972`, `:1592`). That's defensible when the number is adjacent and the line is pure decoration — but the moment a sparkline carries the _trend_ claim, hiding it removes information. Meanwhile `FactoryCharts.tsx:324-328` already does this right with `role="img"` + a prose `aria-label`. - -**F8. Where sparklines simply beat full charts** — and this is the case _for_ the maintainer's preference: when you need **many series compared at a glance in a tight vertical space**, when the trend matters more than any individual value, and when the graphic must sit inline with the number it annotates. When you need to read a specific value, compare precise magnitudes, or show more than one series in the same box, use a real chart. - ---- - -## G) Sparkline system - -A coherent system rather than scattered one-offs. Concretely: **one component, four variants, three sizes, one palette, one honesty contract.** - -### G.1 Consolidate first - -Replace all four implementations with one exported `` from `src/components/Sparkline.tsx` (extend it; don't start over). Delete `HiveFactoryDashboard.tsx:923-945`, `:949-978`, and the inline `sparkPoints`/`historySparkline` at `:1539-1606`. This is a prerequisite, not a nice-to-have — while four implementations exist, no visual rule can be enforced. - -Proposed API: - -```ts -type SparkVariant = "line" | "winloss" | "bar" | "bullet"; -type SparkScale = "zero" | "minmax" | "domain"; -type SparkSize = "inline" | "cell" | "card"; // 60×14 | 100×20 | 160×32 - -interface SparklineProps { - data: number[]; - t?: number[]; // epoch seconds — enables true time-domain x-axis - variant?: SparkVariant; // default "line" - scale?: SparkScale; // default "zero"; "domain" for small multiples - domain?: [number, number]; // shared scale across a small-multiple grid - band?: [number, number]; // shaded normal/acceptable range - target?: number; // bullet-graph reference line - size?: SparkSize; // default "cell" - showMinMax?: boolean; // default true at "cell"/"card" - showEnd?: boolean; // default true — the current-value dot - label: string; // REQUIRED. drives aria-label. no anonymous sparklines. - unit?: string; - emptyText?: string; // default "accumulating data" - minPoints?: number; // default 12 -} -``` - -Two deliberate API decisions: **`label` is required** (an unlabelled sparkline is the failure mode, so make it unrepresentable), and **`scale` is explicit with no silent default switch** (fixes F1). - -### G.2 Sizes — three, and only three - -| Token | Dimensions | Where | -| -------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `inline` | 60 × 14 | Inside a sentence or a table cell. Matches surrounding text height, per "The sparkline should be about the same height as the text around it" (https://en.wikipedia.org/wiki/Sparkline). | -| `cell` | 100 × 20 | Leaderboard rows, per-lane rows. Roughly today's `MiniSparkline` (`:951-952`). | -| `card` | 160 × 32 | Headline metric cards and small-multiple grids. Roughly today's `historyTrends` (`:1568-1569`). | - -Aspect ratio ~4:1 to ~5:1 throughout. Do not let a sparkline exceed 200 px wide — past that it is a chart and should get axes. - -### G.3 Colour semantics — single-hue intensity, per Few - -Adopt Few's structural solution: "varying intensities of a **single hue**… to make them discernible by those who are **color blind**" (https://en.wikipedia.org/wiki/Bullet_graph). - -- **One neutral hue for all ordinary sparklines.** The existing `#58a6ff` blue is fine. A sparkline's default state is _informational_, not _judgmental_ — most of the six metrics at `:1560-1566` do not need six different colours, and giving them six implies six different meanings that don't exist. -- **Colour is reserved for verdict, and only when there is one.** Two states: `nominal` (neutral blue) and `attention` (amber `#d29922`). **Drop the red/green pair entirely** — `#f85149` vs `#3fb950` is the deuteranopia collision, and green-on-a-line-chart reads as "good" even when the line is going the wrong way. -- **Shape is the second channel, always.** Amber sparklines additionally get a thicker stroke and a filled end-dot with a ring; nominal ones get a plain dot. Verdict must survive greyscale. -- **Band shading is 12% of the line hue**, never a second hue. -- **Theme-reactive.** Given `Sparkline.tsx:8` ("CSS custom properties not supported in inline SVG"), resolve colours in the _parent_ component from CSS vars and pass hex strings down as props. Don't re-hardcode. - -### G.4 Marking min / max / current - -Tufte's convention, and the one thing that makes an axis-less line honest: - -- **Current value:** a filled dot at the right end, always on (`showEnd`). This is what makes the eye land on "now". -- **Min and max:** two small open dots, plus the **numbers printed at the ends of the graphic in the same colour**. This is the axis substitute. Without it, an axis-less min–max-scaled sparkline is unreadable. -- **Normal-range band:** a light shaded horizontal band behind the line for series with a known acceptable range (queue depth, build duration). The line leaving the band is then self-evidently the story, with no colour change needed. -- **Zero line:** draw a hairline at y=0 when `scale="zero"` and the data approaches it. - -### G.5 The honesty contract — every sparkline, no exceptions - -1. **Never alone.** Always accompanied by the current value as a printed number. The line is the trend; the number is the scale. -2. **Scale is declared.** Small multiples share `domain`; a `minmax` sparkline must print min and max. The current mixed regime (F1) is the bug this fixes. -3. **Missing data is visible, never hidden.** Remove the `spark.some(v => v > 0)` gates at `:836` and `:2638`. States: `"accumulating data"` (< `minPoints`), `"steady at zero"` (all zeros — good news, not absence), `"unavailable — last seen 2h ago"` (fetch failed). Directly implements ADR 0002's per-panel visible unavailability. -4. **Window is stated once per grid**, not per sparkline: "last 14 days · 168 snapshots · 2h resolution". The existing `panelMeta` at `:1578` already does this — keep it. -5. **Gaps are drawn as gaps.** Break the polyline on missing buckets rather than interpolating across them. - -### G.6 Hover / tooltip - -- Native `` inside the SVG for the whole sparkline: `"Queue depth: 58 now, 19–175 over 14 days"`. Zero JS, works on SSG, survives hydration. -- On `card` size only, a crosshair on pointer-move showing `date · value`. Not on `inline`/`cell` — a word shouldn't have a tooltip UI. -- **Never** a hover-only sparkline. The trend must be readable without interaction, per the "screenshot-able" requirement from the previous section. - -### G.7 Accessibility - -Copy the pattern already working at `FactoryCharts.tsx:324-328` (`role="img"` + prose `aria-label`): - -- **Decorative case** (a number is adjacent and states the trend in text): keep `aria-hidden="true"`. Correct and cheap. -- **Information-bearing case** (the sparkline _is_ the claim): `role="img"` with a generated sentence — `aria-label="Queue depth over 14 days: started at 175, now 58, lowest 19, highest 175. Trending down."` Generate this from the same props that draw the line, so it can never drift out of sync. -- Focusable (`tabindex="0"`) only where a tooltip exists. -- Contrast ≥3:1 for the stroke against the panel background in both themes. -- `@media (prefers-reduced-motion: reduce)` on any animated draw-in. **There are currently zero occurrences of this in `src/`** — verified by grep. - ---- - -## G.8 Concrete Bluefin sparklines — what feeds each - -Ordered by impact. - -| # | Sparkline | Variant / scale | Feeding series | Notes | -| ------ | ----------------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **1** | **Build success rate, per image lane** — the small-multiple centerpiece | `line`, `scale="domain"` `[0,100]` | Per-lane CI history (not yet in the shipped JSON — **this series does not exist today and needs to be produced**) | Five identical sparklines on a **shared 0–100 domain** is the whole flex. Shared domain is non-negotiable; per-lane autoscaling would make a 99% lane and a 60% lane look identical. | -| **2** | **Pass/fail win-loss bars, last 40 builds per lane** | `winloss` | Same per-lane CI history | The classic form for binary outcomes: up-tick above the midline for pass, down-tick below for fail, equal height, no scale needed. **Genuinely dense and instantly readable** — 40 builds in 100 px. This is the highest-impact new variant and it is ~15 lines of `<rect>`s. | -| **3** | **Merge time vs. the <30 min SLA** | **`bullet`** | `hive-history.json` `medianMergeMins` (currently 800–1086 in the sampled window) | Few's form exactly: actual as the thick bar, **target as a thin vertical line at 30**, qualitative bands behind (https://en.wikipedia.org/wiki/Bullet_graph). The <30 min SLA is documented at `docs/agentic-contributing.md:144-160`. **Be warned this will read as a dramatic miss** — median is currently ~13–18× the target. That is exactly the Backblaze "publish your own worsening numbers" move (https://www.backblaze.com/blog/backblaze-drive-stats-for-q1-2025/), and it is more credible than hiding it. If the SLA applies to a narrower class of issue than `medianMergeMins` measures, **say so in the label** rather than dropping the panel. | -| **4** | **Build duration trend per lane** | `line`, `scale="zero"`, with `band` for acceptable range | `FactoryCharts.tsx:135-152` currently hardcodes LTS `11 → 15` and Dakota `114 → 273` | A sparkline shows the drift instantly where two dots cannot. **Note:** I could not find a "+42%" string anywhere in the repo (grepped `trending\|duration\|42%` across `src/`, `docs/`, `static/`); the committed numbers are +36% for LTS and +139% for Dakota. Whatever figure ships, derive it from the series, don't hardcode it. | -| **5** | **Issue and PR volume** | `line`, `scale="zero"`, `t` supplied | `registry-data.json:242` `issueHistory`, `:2932` `prHistory`, both `{t,v}` at ~960 s | Already half-wired: `HiveFactoryDashboard.tsx:1911-1912` does `issueHistory.slice(-48).map(e => e.v)`. **Pass `t` too** — 48 points at 16 min is 12.8 hours, and the label should say so. `prHistory` appears unused for sparklines today; pair them in one row. | -| **6** | **Contributor activity, inline per leaderboard row** | `inline` (60×14) | Per-contributor task history from `registry-data.json` `leaderboard[]` (only cumulative `tasks_completed`/`tasks_failed` today — **a per-contributor time series does not exist and would need to be produced**) | This is the most Tufte-correct placement on the whole page: a word-sized graphic in a table cell next to the name and the number. Pairs with the live `current_task` string. Until the series exists, use a win-loss bar of that contributor's recent PR outcomes from `queue.json` `check_state`. | -| **7** | **Token burn over 24h** | `line`, `scale="zero"` | `totalTokens24h` (596,755,619) is a **scalar snapshot, not a series** — needs a 24h history to be added | Once the series exists this is a great one, because it should visibly bulge when `governorMode` hits `SURGE`. Annotate the SURGE windows on it. | -| **8** | **Queue depth, ACMM level, advisories, budget** | `line`, `scale="zero"` | `hive-history.json` — already wired at `:1559-1566` | Keep, but restyle to one hue and fix the scaling. Queue depth 175→19 is a genuinely good-looking series. | -| **9** | **Image release freshness cadence per lane** | `bar` | Days-since-release per lane; `FactoryCharts.tsx:184-196` currently hardcodes `[0,0,3,6,6]` | Micro bar chart of the last N release intervals per lane shows cadence _regularity_, which a single "6 days old" number cannot. | -| **10** | **Live agent activity pulse** | `winloss` or `bar`, `inline` | `registry-data.json` `agents[].lastActivityAt` bucketed | An inline 20-bucket activity strip per agent row, beside the agent's name and live task. Cheap, dense, and it makes "the factory never sleeps" visible per-agent. | - -### G.9 Assessment: the small-multiple grid as the page centerpiece - -**Yes — with one hard condition.** A 5-across grid of identical sparklines, one per image lane (bluefin, bluefin-lts, dakota, aurora, bazzite), each with lane name, current value, and the same 30-day window, is: - -- **Canonically correct.** The Wikipedia definition names small multiples as the sanctioned plural form of the sparkline in its first paragraph (https://en.wikipedia.org/wiki/Sparkline). -- **Genuinely beautiful and screenshot-able.** Repetition with variation is the thing the eye enjoys; the odd lane out announces itself with no colour, no legend, no annotation. -- **Achievable now.** It is `<Sparkline>` × 5 in a CSS grid. **Zero new dependencies.** No ECharts, no D3. -- **Consistent with ADR 0002** — it is dense on purpose, and each cell can independently render "unavailable" without the grid collapsing. - -**The hard condition: a shared domain.** `Sparkline.tsx` today normalizes each series to its own min/max (`:26-28`), which would make all five lanes look _identical_ regardless of their actual values — the grid would be beautiful and completely false. Add `domain` support before shipping this. That one prop is the difference between the best panel on the page and the most misleading. - -Pair it with a one-sentence caption underneath — the pattern that makes panels shareable (Cloudflare's key findings are full sentences, https://blog.cloudflare.com/radar-2024-year-in-review/): _"Five images, thirty days, one shared scale. Dakota is the one to watch."_ - ---- - -## G.10 Library or hand-rolled? - -**Hand-roll all of it.** Every variant here — line, win/loss, micro-bar, bullet, banded, small multiples — is inline SVG `<polyline>` and `<rect>` with linear scaling. `Sparkline.tsx` already proves the approach works in SSG (its own doc comment: "Zero-dependency inline SVG sparkline. Works in SSR/SSG contexts"). Total estimated surface: one component, ~200 lines. Hand-rolled sparklines also **server-render**, which the current ECharts panels do not (`FactoryCharts.tsx:42-57` initializes inside `useEffect`, so they are blank in the static HTML and in social previews). - -**The one genuine exception is the horizon chart.** Horizon charts (bands folded and layered to show many series in tight vertical space) need band-splitting and layered clipping, and — more importantly — **most readers cannot decode them without a legend**, which contradicts the "word-sized, no axes" premise. For Bluefin's five lanes, the small-multiple grid (G.9) is strictly better. **Recommend: skip horizon charts.** If you later have 30+ series, revisit, and at that point ECharts (already in `package.json:54`) covers it. - ---- - -## Gaps for this thread - -- **Tufte's own site is unreachable for this material.** `edwardtufte.com/tufte/books_sparklines/` → 404; `edwardtufte.com/notebook/sparklines/` → 404; `edwardtufte.com/tufte/beautiful_evidence` → 404; `edwardtufte.com/bboard/q-and-a-fetch-msg?msg_id=0001OR` → redirected to a cookie-consent page with no article content. **All Tufte quotations in this section are secondhand via https://en.wikipedia.org/wiki/Sparkline**, which cites _Beautiful Evidence_ (2006). I have not verified the exact wording against the book. The specific claims I'd flag as needing verification if precision matters: the aspect-ratio guidance and the min/max/current dot conventions, which I stated from convention and from Wikipedia's "Tufte offers some useful design principles for the sizing of sparklines" — a sentence that asserts the principles exist without listing them. -- **Stephen Few's bullet graph design spec** (`perceptualedge.com/articles/misc/Bullet_Graph_Design_Spec.pdf`) fetched but returned raw undecodable PDF bytes. The bullet-graph anatomy, motivation, and the single-hue colour-blindness rationale come from https://en.wikipedia.org/wiki/Bullet_graph summarizing _Information Dashboard Design_ (2006). I did not read Few's spec directly, so **the exact recommended band count and proportions are not sourced** — I'd verify before finalizing pixel specs. -- **Data series that do not exist yet** and would need to be produced for my recommendations #1, #2, #6, and #7: per-lane CI pass/fail history, per-contributor activity time series, and a token-burn time series. `totalTokens24h` is a scalar in `registry-data.json`, not a series. I verified their absence by reading the shipped JSON, but I could not check the OAuth-gated hive API (`hosted-projectbluefin-knuckle-gjvq.hive.kubestellar.io/api/status`), so they may exist upstream. -- **The "+42%" duration figure** the maintainer referenced: I grepped `trending|duration|buildTime|successRate` and `42%` across the repo and **found no such string**. The committed build-time data at `FactoryCharts.tsx:135-152` is LTS 11→15 min and Dakota 114→273 min. It may come from the internal lab site, which I have not accessed. -- **Verified vs. opinion:** the four-implementation finding, the two-scaling-semantics conflict, the `spark.some(v => v > 0)` gates, the hardcoded palettes, the `aria-hidden` usage, the `"accumulating data"` fallback, the `role="img"` pattern, and the `{t,v}` data shapes are all **verified by reading the files cited**. The proposed API, sizes, palette, and honesty contract in G are **my recommendations**, informed by the two Wikipedia sources. diff --git a/adr/research/visual-form-prior-art.md b/adr/research/visual-form-prior-art.md deleted file mode 100644 index d7d62d81a..000000000 --- a/adr/research/visual-form-prior-art.md +++ /dev/null @@ -1,413 +0,0 @@ -# Prior art — shareable visual forms for a public engineering dashboard - -> Research output supporting ADR 0002. Generated 2026-08-07 by a background research agent. -> Findings were spot-checked against live endpoints; see ADR 0002 for what was adopted. - -# Addendum: The Aesthetics of Shareable Data Visualization - -This extends the earlier report with the _craft_ thread the maintainer asked for. New primary sources fetched for this pass are listed below; anything I could not reach is flagged explicitly in the gaps at the end. I also read the repo's existing chart code, which materially changes the implementation advice. - ---- - -## A2) Additional inventory — sources examined for this thread - -| Source | What I actually observed | -| ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| https://en.wikipedia.org/wiki/R/dataisbeautiful | The subreddit's submission rules are the interesting artifact: "requires users submitting visualizations to clearly credit **both the individual who created the visualization and the source of the data**… If someone submits a visualization they created themselves, the rules require them to put '[OC]' in the title, and to **identify the source of data and software tool they used**." Also documents a community-level rejection of an over-used form: in November 2019 moderators **temporarily banned animated bar chart races** — "graphs showing the relative position of entities on a list over time." And a saturation effect: so many Tinder-data submissions arrived that a separate subreddit was spun off. | -| https://flowingdata.com/ (Nathan Yau) | Front page feature is Andy Kirk's _Seinfeld Chronicles_. The post is almost entirely about **method as spectacle** — it quotes at length how the data was collected ("each episode's data was gathered through two full viewings. The first viewing would focus on recording the times of when discrete scenes started and finished… The second viewing was done to validate the first-pass data", "all measurements were recorded in and against units of 5-seconds periods"), and Yau's own reaction is admiration for the _labor_: "That's a lot to watch. I don't think I have the patience or stamina to collect data like that." The shareable thing here is the obsessiveness, not the chart. | -| https://www.visualcinnamon.com/portfolio/ (Nadieh Bremer) | Only her book blurb rendered: "I poured a decade of my experience with creative data visualization into this book, distilling it into 13 lessons on how to make your visual **more custom, effective, memorable**." The portfolio itself did not render (JS). I make no claims about her individual pieces. | -| https://d3-graph-gallery.com/ | "This gallery displays hundreds of chart, always providing **reproducible & editable source code**." Points to https://www.react-graph-gallery.com for React. | -| https://www.react-graph-gallery.com/ | "A collection of chart examples built with React and D3.js. From beginner-friendly basics to production-ready templates." Relevant because it is the React+D3-primitives route rather than a chart-library route. | -| https://d3-graph-gallery.com/sankey.html | Sankey is defined as the form for "display[ing] flows". | -| https://www.data-to-viz.com/caveat/pie.html | Titled, bluntly, "**Bad by definition**": "humans are pretty bad at reading angles… try to figure out which group is the biggest one and try to order them by value. You will probably struggle to do so and **this is why pie charts must be avoided**." | -| https://www.data-to-viz.com/caveat/spaghetti.html | "A Spaghetti plot is a line plot with many lines displayed together. With more than a few (**~5?**) groups this kind of graphic gets really hard to read, and thus provides little insight." | -| https://www.data-to-viz.com/graph/streamgraph.html | "Contrary to a stacked area, there is no corner: edges are rounded what gives this nice impression of flow… resulting in a flowing and organic shape." But with a hard caveat: "they are **bad to study the evolution of each individual group**: it is very hard to substract the height of other groups at each time point"; "**Stream chart gets really useful when displayed in an interactive mode**: highlighting a group gives you directly an insight of its evolution"; and "streamgraph work well when there is a **clear pattern in the data**. If the proportion of each group remain more or less the same all along the time frame, the figure won't be very insightfull." | -| https://www.data-to-viz.com/graph/heatmap.html | "A heatmap is really useful to display a **general view** of numerical data, **not** to extract specific data point. In the graphic above, the huge population size of China and India **pops out**." — i.e. the heatmap's job is producing a _pop-out_, not a lookup table. | -| https://www.data-to-viz.com/graph/chord.html | "represents flows or connections between several entities (called nodes). Each entity is represented by a fragment on the outer part of the **circular layout**. Then, arcs are drawn between each entities. **The size of the arc is proportional to the importance of the flow.**" | -| https://observablehq.com/@d3/gallery (meta description; page body is JS-rendered) | D3's own framing of what it is _for_, in three named capabilities: "**Animation** — D3's data join, interpolators, and easings enable flexible animated transitions between views while **preserving object constancy**. **Interaction** — D3's low-level approach allows for performant incremental updates during interaction… **Analysis** — D3 is for more than visualization." "Object constancy" is the technical term for the thing that makes live dashboards feel alive rather than twitchy. | -| **Repo:** `/var/home/jorge/src/documentation/package.json:54` | `"echarts": "^6.1.0"` — **ECharts is already a dependency.** This is the single most important implementation fact for this section. | -| **Repo:** `/var/home/jorge/src/documentation/src/components/FactoryCharts.tsx:1-25` | Imports are already tree-shaken through `echarts/core`: `import { PieChart, LineChart, BarChart } from "echarts/charts"` plus `TitleComponent, TooltipComponent, LegendComponent, GridComponent` and `CanvasRenderer`, registered via `echarts.use([...])`. Adding a Sankey, heatmap, or graph chart is **one more entry in that array**, not a new dependency. | -| **Repo:** `FactoryCharts.tsx:62-95`, `:276-290` | Two of the six existing panels are **donut/pie charts** — "Release status / 4 bad · 1 pending" and "Telemetry coverage / tracked image lanes". Directly contradicted by data-to-viz's "bad by definition". | -| **Repo:** `FactoryCharts.tsx:33-35` | Palette is hardcoded: `chartText = "#cbd5e1"`, `mutedText = "#64748b"`, `gridLine = "rgba(148, 163, 184, 0.14)"` — dark-mode only, not theme-reactive. | -| **Repo:** `src/components/Sparkline.tsx:1-60` | Zero-dependency inline SVG, SSR-safe, `data: number[]`, normalizes to fill height, optional area fill, `strokeLinejoin="round"`. Currently `aria-hidden="true"`. Doc comment: "Zero-dependency inline SVG sparkline. Works in SSR/SSG contexts." **This is the right primitive and it already exists.** | -| **Repo:** `grep -r "prefers-reduced-motion" src/` | **Zero matches.** No motion-preference handling anywhere in the site today. | - ---- - -## B2) What actually goes viral, and why — patterns for the "screenshot-able" panel - -**The unit of virality is one surprising sentence, rendered.** Every strong example in both passes leads with a claim, not a chart: Cloudflare's "Starlink traffic from Malawi grew **38x**" (https://blog.cloudflare.com/radar-2024-year-in-review/), Octoverse's "a new developer joins GitHub **every second**" (https://octoverse.github.com/), Backblaze's "**Zero failures for the quarter** — four drives get a gold star" (https://www.backblaze.com/blog/backblaze-drive-stats-for-q1-2025/). **A panel that cannot be captioned in one sentence will not be shared.** Design each panel around its caption. - -**Provenance is part of the aesthetic, not a footnote.** r/dataisbeautiful _enforces_ this in its rules — credit the maker, credit the data source, name the software tool (https://en.wikipedia.org/wiki/R/dataisbeautiful). This is not bureaucracy; it is why screenshots from there survive being reposted. A small, always-present "source · updated · tool" line in the corner of every panel makes each panel independently shareable, and it is exactly the pattern OWID uses on every chart (Source / Last updated / Next expected update / Unit — https://ourworldindata.org/grapher/annual-co2-emissions-per-country). - -**Effort, made visible, is the flex.** FlowingData's featured piece was celebrated for the _collection method_ — two full viewings per episode, five-second granularity — not the chart (https://flowingdata.com/). This is the single most transferable insight for Bluefin: the impressive thing is that a machine watched every build, all night, forever. Show the tally of that labor. - -**Forms burn out.** Bar chart races were _banned_ by the subreddit for saturation (https://en.wikipedia.org/wiki/R/dataisbeautiful). Treat racing bars and word clouds as spent. Novelty of _form_ is a depreciating asset; novelty of _subject matter_ is not — and "four robots and four humans are building an operating system right now" is subject-matter novelty nobody else has. - -**Density is fine; ambiguity is not.** data-to-viz's caveats are all about _decodability_, not quantity: pie charts fail because "humans are pretty bad at reading angles"; spaghetti plots fail past "~5" series. The heatmap is endorsed precisely because a value "**pops out**". Dense-but-decodable (heatmap, small multiples) is the target; dense-and-ambiguous (pie, 12-line spaghetti) is the failure. - -**Interaction rescues the beautiful-but-unreadable forms.** data-to-viz on streamgraphs: "gets really useful when displayed in an interactive mode: **highlighting a group** gives you directly an insight of its evolution." If you ship a streamgraph or chord diagram, hover-to-isolate is mandatory, not optional. - ---- - -## F) Visual form recommendations - -Ordered by impact-per-effort. Implementation notes assume React 19 + Docusaurus (SSG) + TypeScript, with `echarts@^6.1.0` already present (`package.json:54`) and already tree-shaken via `echarts/core` (`FactoryCharts.tsx:1-25`), plus the existing zero-dep `Sparkline.tsx`. - ---- - -### F1. The Factory Calendar — GitHub-style contribution heatmap, but for builds ⭐ top pick - -**Data:** one cell per day (or per 2-hour bucket) for the last 12 months. Colour-encode **merges completed** or **builds shipped**; you already have the raw material in `hive-history.json` (2-hourly `{t, queue, medianMergeMins, runningAgents, govMode}`) and `registry-data.json`'s `issueHistory` `{t,v}` series. - -**Why it's shareable:** it is the most recognized data graphic in software, and its power is that a _pattern_ pops out before any number is read — exactly data-to-viz's stated purpose for heatmaps ("useful to display a **general view**… the huge population size of China and India **pops out**" — https://www.data-to-viz.com/graph/heatmap.html). And here the pattern is the punchline: a human contribution graph has weekend gaps and holiday deadzones. **Bluefin's will be solid.** Ship it with the caption "_The factory does not take weekends off_" and put a real human's graph beside it for contrast — the unexpected comparison is the single most reliable viral device in the corpus (cf. Cloudflare's Malawi 38x, https://blog.cloudflare.com/radar-2024-year-in-review/). - -**Implementation:** **no dependency needed.** It is a 53×7 grid of `<rect>`s in one inline SVG — ~40 lines, same shape as `Sparkline.tsx`, SSR-safe. Use ~5 discrete colour steps, not a continuous ramp (discrete steps survive screenshotting and colour-blind viewers). Give each cell a `<title>` for tooltips and make the whole thing a `<table>`-role or provide a text summary for screen readers. - -**Real-world example of the form done well:** the D3 gallery calendar-view family — https://d3-graph-gallery.com/ (reproducible source per chart). - ---- - -### F2. Small multiples of image-lane health ⭐ second pick - -**Data:** one tiny sparkline card per image lane — bluefin, bluefin-lts, dakota, aurora, bazzite — each showing build duration or success over 30 days, all on a **shared y-axis**, plus a current-state chip. - -**Why it's shareable:** it turns "five lanes are healthy" from an assertion into something the eye verifies in half a second, and the odd-one-out jumps out. This is the honest replacement for the two donut charts currently at `FactoryCharts.tsx:62-95` and `:276-290` — data-to-viz calls pie charts "**Bad by definition**" because "humans are pretty bad at reading angles" (https://www.data-to-viz.com/caveat/pie.html). It is also the correct fix for the spaghetti problem: five overlaid lines is right at the "~5?" limit where line plots stop working (https://www.data-to-viz.com/caveat/spaghetti.html) — so don't overlay them, tile them. - -**Implementation:** **zero new code beyond a wrapper.** Five instances of the existing `Sparkline` in a CSS grid. One change required: `Sparkline.tsx` normalizes each series to its own min/max (`const min = Math.min(...data)` at line 26), which makes small multiples **lie** — five lanes would all look identical regardless of scale. Add optional `domainMin`/`domainMax` props so a shared scale can be passed in. Also drop `aria-hidden="true"` (line 47) when the sparkline is the sole carrier of meaning, and give each card a text value beside it. - -**Real-world example:** Tufte's small-multiples principle; the practical gallery equivalent is the faceted line chart at https://d3-graph-gallery.com/. - ---- - -### F3. Sankey: issue → agent → PR → review → merge → release ⭐ the "engineering might" flex - -**Data:** flow volumes over the last 7 or 30 days. Nodes: `issues filed` → each named agent (`scanner`, `quality`, `ci-maintainer`, `supervisor`) and each named human contributor → `PR opened` → `approved` / `changes requested` / `abandoned` → `merged` → `shipped in image`. All of this exists: `registry-data.json` `leaderboard[]` with `tasks_completed` / `tasks_failed` per actor, and `queue.json`'s `review_state` / `check_state` / `recommended_action`. - -**Why it's shareable:** Sankey is _the_ form for "display[ing] flows" (https://d3-graph-gallery.com/sankey.html), and flow diagrams are one of the reliably-viral families. Critically, it is the only form that makes **the whole factory legible in one image** — a non-expert sees work enter on the left, pass through named robots and named humans in the middle, and emerge as an OS update on the right. It also lets you be honest in a way that _increases_ credibility, per Backblaze publishing its own worsening AFRs (https://www.backblaze.com/blog/backblaze-drive-stats-for-q1-2025/): route the abandoned/reverted work into a visible dead-end branch instead of hiding it. A Sankey where 100% of flow reaches "merged" reads as fake. - -**Implementation:** ECharts has this built in — add `SankeyChart` to the existing `echarts/charts` import and to the `echarts.use([...])` array at `FactoryCharts.tsx:11-22`. **No new dependency.** Hand-rolling Sankey layout is genuinely hard (it needs an iterative node-ordering algorithm); this is the one form where I'd say use the library without hesitation, and you already have it. Keep node count under ~15 or it becomes hairball. - ---- - -### F4. The live agent pulse — a DSN-style radial/orbital activity view - -**Data:** `registry-data.json` `agents[]` (`supervisor`, `ci-maintainer`, `quality`, `scanner` running; `sec-check` paused) each with `lastActivityAt`, plus `leaderboard[].current_task` for the four active humans. - -**Why it's shareable:** this is the "spectacle" slot, and it is the thing no other OS can screenshot. The NASA DSN Now grid works on laypeople for exactly one reason — a small number of named machines each visibly doing a named thing to a named target (https://eyes.nasa.gov/apps/dsn-now/, feed at https://eyes.nasa.gov/dsn/data/dsn.xml). Bluefin's version: agent nodes around a ring, repo nodes in the centre, an animated pulse travelling the arc when an agent touches a repo, and the agent's live task string underneath. - -**Implementation:** **no dependency.** Absolutely-positioned divs on a CSS grid, or inline SVG with `<circle>` + a CSS `@keyframes` pulse. Use `trigonometry` for ring placement (~15 lines). **Mandatory:** wrap all motion in `@media (prefers-reduced-motion: reduce)` — I grepped the whole of `src/` and found **zero** occurrences, so this is a genuine gap the page will inherit. Also honour object constancy: when data refreshes, agents must keep their ring position and _transition_, never re-sort and jump (D3's own framing: "animated transitions between views while **preserving object constancy**" — https://observablehq.com/@d3/gallery). - ---- - -### F5. Waffle / unit chart for queue composition - -**Data:** the 165 actionable issues + 77 actionable PRs (`registry-data.json`), one square per item, coloured by `recommended_action` from `queue.json` (`fix-ci` / `resolve-conflicts` / `review` / `investigate`). - -**Why it's shareable:** unit charts encode count _and_ proportion in one figure, so nobody has to trust your percentage — they can count the squares. It is the honest, screenshot-proof replacement for a donut. 242 squares is a satisfying, dense-but-countable block; it rewards the second look the maintainer wants. - -**Implementation:** **zero dependency.** A CSS grid of `<div>`s, or `<rect>`s in one SVG. Genuinely ~20 lines. Colour with a colour-blind-safe 4-colour set and label each category with text, never colour alone. - ---- - -### F6. Streamgraph of activity by agent over time — **conditionally** - -**Data:** `hive-history.json` at 2-hour resolution, stacked by agent or by `govMode`. - -**Why it's shareable when it works:** "there is no corner: edges are rounded what gives this nice impression of flow… resulting in a flowing and **organic shape**" (https://www.data-to-viz.com/graph/streamgraph.html). It is one of the genuinely beautiful forms. - -**The condition — and I'd hold this one back initially:** the same page warns that streamgraphs "are **bad to study the evolution of each individual group**" and only "work well when there is a **clear pattern** in the data. If the proportion of each group remain more or less the same all along the time frame, the figure won't be very insightfull." Your sampled history shows `agents: 6, runningAgents: 5` almost flat across the whole window — that is precisely the "no clear pattern" case, and it would render as a pretty, meaningless ribbon. **Ship this only once you have a metric with real compositional swing** (e.g. token spend by agent, where `SURGE` mode should produce visible bulges). If you do ship it, hover-to-highlight is required, per the same source. - -**Implementation:** ECharts `LineChart` with `areaStyle` + `stack` + `smooth: true` gets you 90% of the look with the modules **already imported** (`FactoryCharts.tsx:11`). No new dependency. - ---- - -### F7. Radial / circular timeline of build cadence — **medium confidence** - -**Data:** 24-hour or 7-day clock, with build starts and promotions plotted around the dial; `hive-history.json` timestamps give you this directly. - -**Why it's shareable:** the circular form makes the _periodicity_ the subject, and the finding is a good one — a human-run project has a diurnal rhythm; an agent-run one may not. Symmetry and radial forms photograph well. - -**Implementation:** no dependency; SVG arcs via `Math.cos`/`Math.sin`. **Caveat, and it's why this is medium and not top-tier:** radial layouts distort area and make comparison harder than a linear axis. Use it as a _decorative-but-true_ accent panel, and make sure the same fact is stated in words nearby. If cadence turns out to be genuinely uniform, F1's calendar heatmap already tells that story better. - ---- - -### F8. Chord / arc diagram of cross-repo relationships — **do not ship yet** - -**Data:** the 11 repos in `registry-data.json` (`common`, `bluefin`, `bluefin-lts`, `actions`, `testsuite`, `dakota`, `server`, `fsdk-containers`, `bluefinctl`, `finpilot`, `dakota-iso`) with flow = shared PRs or dependency edges. - -**Assessment: it would look spectacular and communicate little.** Chord diagrams draw arcs "between each entities" with arc size "proportional to the importance of the flow" (https://www.data-to-viz.com/graph/chord.html) — they are built for _bidirectional_ flow matrices like migration. Your repo relationships are mostly a DAG, and a Sankey (F3) already tells that story with far better decodability. **Recommend: skip.** If you want the circular-layout beauty, spend it on F4's agent ring, which carries live meaning. - ---- - -### F9. Racing bar chart for the contributor leaderboard — **actively recommend against** - -`registry-data.json`'s `leaderboard[]` is a natural fit and it is tempting. But r/dataisbeautiful **banned the form outright in November 2019** for saturation (https://en.wikipedia.org/wiki/R/dataisbeautiful). It reads as 2019, it can't be screenshotted (the whole point is the motion), and it's SEO/social-preview-hostile. **Do the static version instead:** a ranked bar list with avatars (`leaderboard[].avatar_url` resolves to real GitHub avatars), `trust_tier` as a visible badge, and — the shareable part — the live `current_task` string as the row subtitle. Named humans mid-task beats an animated ranking, and it matches the Backblaze move of naming its analysts (https://www.backblaze.com/blog/backblaze-drive-stats-for-q1-2025/). - ---- - -### F10. Cross-cutting craft rules for every panel - -- **A caption line under every panel, in prose.** The screenshot must carry its own meaning. Precedent: every Cloudflare key finding is a full sentence (https://blog.cloudflare.com/radar-2024-year-in-review/). -- **A source/updated/tool micro-line in every panel corner.** r/dataisbeautiful's rules require creator + data source + tool (https://en.wikipedia.org/wiki/R/dataisbeautiful); OWID ships Source / Last updated / Next expected update / Unit on every chart (https://ourworldindata.org/grapher/annual-co2-emissions-per-country). This is what makes a panel survive being reposted without context. -- **Retire the two donuts.** `FactoryCharts.tsx:62-95` ("Release status") and `:276-290` ("Telemetry coverage") are pie charts; the reference says the form is "bad by definition" (https://www.data-to-viz.com/caveat/pie.html). Replace with a stacked bar or the waffle in F5. -- **Never more than ~5 overlaid series.** Past that, tile into small multiples (https://www.data-to-viz.com/caveat/spaghetti.html). -- **Discrete colour steps, colour-blind-safe, and never colour alone.** Every status must also carry a shape, an icon, or a word. -- **Fix the hardcoded dark palette.** `FactoryCharts.tsx:33-35` pins `#cbd5e1` / `#64748b` — light-mode Docusaurus readers get low contrast. Read from CSS custom properties at render time (note `Sparkline.tsx`'s own comment: "CSS custom properties not supported in inline SVG" — so pass resolved values in via props rather than CSS vars inside the SVG). -- **Object constancy on every live update.** Transition values in place; never re-sort or re-layout on refresh. This is the entire difference between "alive" and "twitchy" and D3 names it explicitly (https://observablehq.com/@d3/gallery). -- **`prefers-reduced-motion` on everything that moves.** Currently zero occurrences in `src/` (verified by grep). This is both an accessibility requirement and the difference between "premium" and "annoying". -- **Make the labor visible.** FlowingData's featured piece is celebrated for the collection method, quoted at length (https://flowingdata.com/). Somewhere on the page, state plainly how many builds were watched, how many checks were run, how many nights it ran unattended. - ---- - -## G) Gaps for this thread — stated explicitly - -- **https://www.reddit.com/r/dataisbeautiful/ — HTTP 403** on the subreddit, `top.json?t=all`, `old.reddit.com/r/dataisbeautiful/top/`, and the wiki index (which returned the bare string "Reddit"). **I did not see a single actual top post.** Everything I report about r/dataisbeautiful comes from https://en.wikipedia.org/wiki/R/dataisbeautiful (the submission rules, the bar-chart-race ban, the Tinder saturation) plus one general web search, which I used only for orientation and have **not** cited as evidence for any specific claim. **I have not read the community's chart critiques** — a follow-up pass with a browser session on `/r/dataisbeautiful/top/?t=year` and on the "battle" / critique threads would materially strengthen B2. -- **https://informationisbeautiful.net/ — transport-level failure (DNS/TLS).** Not reached. **No Information is Beautiful or IIB Awards claims are made.** -- **https://www.edwardtufte.com/tufte/books_sparklines/ — HTTP 404.** I have **no primary Tufte source** in this pass. Where I invoke small multiples and sparklines above, I am relying on the terms as used in the reachable sources (data-to-viz, D3 gallery) and on the existing `Sparkline.tsx` in the repo — **not** on Tufte's own text. Treat the Tufte attributions as conventional, not sourced. -- **https://www.visualcinnamon.com/portfolio/** rendered only the book blurb; **https://observablehq.com/@d3/gallery** and **https://flowingdata.com/category/visualization/** returned JS shells / raw CSS. For the D3 gallery I used the `<meta name="description">` content, which is authored by Mike Bostock and states the Animation/Interaction/Analysis framing verbatim; I did not see the gallery items themselves. -- **Motion in live financial/trading dashboards** — not fetched. My advice on liveness (object constancy, transition-don't-jump) is grounded in D3's own description and in the NASA DSN / Flightradar24 material from the previous pass, **not** in any trading-dashboard primary source. -- **Verified vs. opinion:** section A2 is verified from fetched bytes and from files read in `/var/home/jorge/src/documentation`. Section F is my opinionated recommendation; the form-level caveats (pie, spaghetti, streamgraph, chord, heatmap, sankey) are all sourced to data-to-viz/d3-graph-gallery, but the judgment that a given form suits Bluefin's data is mine. The claim that `echarts` is already available and tree-shaken — the load-bearing implementation fact — is verified at `package.json:54` and `FactoryCharts.tsx:1-25`. - -[Turn 3] -[Message] -STRONG MAINTAINER PREFERENCE, treat as a design constraint: **"I LOVE SPARKLINES."** - -Elevate sparklines from one bullet in section F to a **first-class research thread with its own section**. The repo already ships `src/components/Sparkline.tsx`, so this is a form we can deploy immediately and everywhere. - -Research and report: - -1. **Tufte's original definition and rules** — sparklines as "small, high-resolution, datawords" embedded in text and tables. Get his actual guidance from primary sources (Beautiful Evidence, and his site https://www.edwardtufte.com/). Cover: intended size, aspect ratio, when to show axes (usually not), how to mark min/max/current, the normal-range shaded band technique, and embedding sparklines _inline within a sentence or table cell_ rather than as standalone charts. - -2. **Sparkline variants worth using**, with real examples of each done well: - - Line sparklines with a highlighted end-point dot and min/max markers - - Win/loss and up/down bar sparklines (good for pass/fail build history) - - Bullet graphs (Stephen Few's alternative to gauges — excellent for "actual vs target", e.g. an SLA target) - - Sparkbars / micro bar charts - - Banded sparklines with a shaded acceptable range - - Horizon charts for dense multi-series comparison in tight vertical space - - Inline sparklines inside table rows and inside headline metric cards - -3. **Where sparklines beat full charts**, and importantly where they FAIL — when a sparkline is misleading (no axis, unlabeled scale, too few data points, irregular time intervals, misleading autoscaling). The maintainer wants many of them, so the report must be honest about failure modes and how to avoid them. - -4. **Concrete Bluefin application.** Propose specific sparklines for this page's real data, being concrete about which series feeds each: - - Build success rate over the rolling window, per image lane - - Build duration trend (the factory site already flags "duration trending up +42%" — a sparkline shows this instantly) - - Pass/fail win-loss bars for the last N builds per lane - - Issue and PR volume over time (hive data already includes issueHistory and prHistory time series) - - Merge-time trend against the sub-30-minute SLA target (a bullet graph is ideal here) - - Contributor activity per contributor, inline in a leaderboard table row - - Token usage / agent activity over 24h - - Image release freshness cadence per lane - - Live agent activity pulse - -5. **Small multiples of sparklines** — a grid of identical sparklines across all image lanes, which is both extremely Tufte and extremely beautiful. Assess this specifically as a candidate centerpiece for the page. - -Add a dedicated section: - -### G) Sparkline system - -Propose a coherent sparkline _system_ for the page rather than scattered one-offs: consistent size, color semantics (including a colour-blind-safe palette), min/max/current marking, how to label scale honestly, hover/tooltip behavior, and accessibility (what a screen reader should announce, given a sparkline is decorative-looking but information-bearing). - -Note for implementation feasibility: this is React 19 + TypeScript + Docusaurus with an existing lightweight `Sparkline.tsx`; the project prefers minimal dependencies, and sparklines are very achievable as hand-rolled inline SVG. Favour that over pulling in a charting library, and say so where a library is genuinely warranted. - -[Response] -I found something important in the repo while researching this: **there are four separate sparkline implementations already shipping, with two mutually incompatible scaling semantics.** That reframes this section from "add sparklines" to "unify the ones you have, then scale out." - ---- - -# Sparkline Research Thread + Section G - -## A3) Sources for this thread - -| Source | What it actually says | -| ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| https://en.wikipedia.org/wiki/Sparkline | Definition: "a very small line chart, **typically drawn without axes or coordinates**. It presents the general shape of a variation (typically over time)… in a simple and highly condensed way." The contrast with normal charts is stated explicitly: "Whereas a typical chart is designed to professionally show as much data as possible, and is **set off from the flow of text**, sparklines are intended to be **succinct, memorable, and located where they are discussed**." Tufte's own phrasings are quoted: he "documented a compact style in 1983 called '**intense continuous time-series**'"; he "introduced the term _sparkline_ in 2006 for '**small, high resolution graphics embedded in a context of words, numbers, images**'", which are "'**data-intense, design-simple, word-sized graphics**'". Sizing rule: "**The sparkline should be about the same height as the text around it.**" Grouping: "Sparklines are small enough to be embedded in text, or **several sparklines may be grouped together as elements of a small multiple**." Provenance: first software sparkline was Peter Zelchenko's 1999 "inline-chart" feature in Medved QuoteTracker; Tufte later attributed the idea to Knuth's _METAFONTbook_. | -| https://en.wikipedia.org/wiki/Bullet_graph | "A bullet graph is a variation of a bar graph developed by **Stephen Few**… the bullet graph serves as a **replacement for dashboard gauges and meters**. Bullet graphs were developed to overcome the fundamental issues of gauges and meters: they typically **display too little information, require too much space, and are cluttered with useless and distracting decorations.**" Anatomy: "features a **single, primary measure**… compares that measure to one or more other measures to enrich its meaning (for example, **compared to a target**), and displays it in the context of **qualitative ranges of performance, such as poor, satisfactory, and good**." Decoding key: "The thick, horizontal center line represents the **actual value**. The thin, black vertical line represents a **target value**. The colored or grey scale bands represent **ranges**." And the crucial accessibility clause: "The qualitative ranges are displayed as **varying intensities of a single hue** to make them **discernible by those who are color blind** and to restrict the use of colors on the dashboard to a minimum." Also: "may be horizontal or vertical and may be **stacked to allow comparisons of several measures at once**." Source book: Few, _Information Dashboard Design_, 2006. | -| **Repo:** `src/components/Sparkline.tsx:26-35` | Min–max normalized: `const min = Math.min(...data); const max = Math.max(...data); const range = max - min \|\| 1;` then `y = height - pad - ((v - min) / range) * (height - pad*2)`. Defaults `width=120, height=28`, `color="#58a6ff"`. `aria-hidden="true"` (line 47). Comment at line 8: "CSS custom properties not supported in inline SVG". | -| **Repo:** `HiveFactoryDashboard.tsx:923-945` (`Sparkline`) | A _second_ implementation. `W=120, H=36`, `const max = Math.max(...data, 1)` — **zero-baseline, not min-max**. | -| **Repo:** `HiveFactoryDashboard.tsx:949-978` (`MiniSparkline`) | A _third_. `W=100, H=24`, again zero-baseline `Math.max(...data, 1)`, `y = H - 2 - (v/max)*(H-6)`. Colors via CSS classes with a 4-value union `type SparkColor = "default" \| "green" \| "amber" \| "purple"` (line 947). `aria-hidden="true"` (line 972). | -| **Repo:** `HiveFactoryDashboard.tsx:1539-1541, 1589-1606` (`sparkPoints` + inline `historySparkline`) | A _fourth_. `W=160, H=36`, zero-baseline. Six metrics defined at `:1559-1566` with hardcoded GitHub-dark hexes: ACMM Level `#d29922`, Budget Used `#f85149`, Queue Depth `#58a6ff`, Advisories `#bc8cff`, Merged/Cycle `#3fb950`, Median Merge `#f0883e`. | -| **Repo:** `HiveFactoryDashboard.tsx:1605` | **An existing good pattern:** when `hasData` is false it renders the string `"accumulating data"` instead of an empty chart. | -| **Repo:** `HiveFactoryDashboard.tsx:836, 2638` | **An existing bad pattern:** `spark && spark.some((v) => v > 0) && (...)` — an all-zero series makes the sparkline **vanish entirely**. | -| **Repo:** `FactoryCharts.tsx:324-328` | **The accessibility pattern to copy:** each chart container carries `role="img"` plus a full prose `aria-label`, e.g. `aria-label="Build time: LTS 11 to 15 minutes, Dakota 114 to 273 minutes."` | -| **Repo:** `static/data/registry-data.json:242` (`issueHistory`), `:2932` (`prHistory`) | Both are `{t, v}` arrays; `t` is **epoch seconds** at roughly 960 s (16 min) spacing (e.g. `1785073403 → 1785074363 → 1785075323`). | -| **Repo:** `static/data/hive-history.json` | 2-hourly `{t, acmmLevel, govMode, queue, agents, runningAgents, advisories, medianMergeMins}`. | - -**Primary-source gap, stated plainly:** I could not reach Tufte's own site. `edwardtufte.com/tufte/books_sparklines/`, `/notebook/sparklines/`, and `/tufte/beautiful_evidence` all returned **404**, and `edwardtufte.com/bboard/q-and-a-fetch-msg?msg_id=0001OR` redirected to a cookie-consent notice with no content. **Every Tufte quotation above is taken from the Wikipedia article, which cites _Beautiful Evidence_ — I have not read Tufte's text directly in this pass.** Likewise Stephen Few: `perceptualedge.com/articles/misc/Bullet_Graph_Design_Spec.pdf` fetched but returned undecodable binary PDF; the bullet-graph specifics come from Wikipedia's summary of _Information Dashboard Design_. - ---- - -## B3) What the sources actually establish - -**1. A sparkline is a word, not a chart.** "Word-sized graphics"; "located where they are discussed"; "about the same height as the text around it" (https://en.wikipedia.org/wiki/Sparkline). The design implication is that a sparkline belongs **inside a sentence or a table cell**, next to the number it describes — not floating in its own bordered card with a title. Bluefin's current `historyTrendCard` treatment (`HiveFactoryDashboard.tsx:1583-1607`) is label-above / number-above / chart-below, which is a mini-chart, not a sparkline. Inline is both more Tufte and more compact. - -**2. No axes is the default, not a compromise.** "typically drawn without axes or coordinates" — because the sparkline's job is **shape**, not lookup. This is licence to strip everything, _provided_ the current value is printed as a number next to it. The number carries the scale; the line carries the trend. Never ship one without the other. - -**3. Small multiples are the sanctioned grouping.** "several sparklines may be grouped together as elements of a **small multiple**" — the Wikipedia definition puts this in the first paragraph. A grid of identical sparklines across image lanes is not a stylistic choice; it is the form's canonical plural. - -**4. Bullet graphs exist specifically to kill gauges.** Few's stated motivation — gauges "display too little information, require too much space, and are cluttered with useless and distracting decorations" (https://en.wikipedia.org/wiki/Bullet_graph) — is a direct argument against the "speedometer" treatment that dashboards reach for. For "merge time vs. the <30 min SLA", a bullet graph is the correct form and a gauge is the wrong one. - -**5. Few solved the colour-blindness problem structurally, not with a palette swap.** Bullet graph ranges use "**varying intensities of a single hue**… to make them discernible by those who are color blind and to **restrict the use of colors on the dashboard to a minimum**." That is the single best design rule available for this page, and it generalizes: encode severity by _lightness_, reserve _hue_ for category, and never let hue alone carry a verdict. - ---- - -## Where sparklines fail — and the failures already present in this repo - -This is the honest part the maintainer asked for. Every one of these is live in the codebase today. - -**F1. Two incompatible scaling semantics, same-looking glyph.** `src/components/Sparkline.tsx:26-28` normalizes to **min–max** (the lowest value sits at the bottom of the box). `MiniSparkline` at `HiveFactoryDashboard.tsx:953` and `sparkPoints` at `:1539` normalize to a **zero baseline**. These produce dramatically different pictures from identical data: min–max turns a series that wobbles 800→810 into a mountain range; zero-baseline turns it into a flat line. **Two glyphs that look the same but mean different things is the worst possible outcome**, and a reader has no way to tell which is which. Pick one per _use case_, label it, and never mix within a grid. - -**F2. Misleading autoscaling — the "flat data looks dramatic" failure.** Min–max normalization always fills the box, so **noise is indistinguishable from signal**. `hive-history.json` shows `agents: 6, runningAgents: 5` essentially constant across the whole window; a min–max sparkline of that renders as a jagged, alarming zigzag over a range of ±1. Fix: min–max only when you also print the min and max as numbers, or clamp to a minimum domain span. - -**F3. Silent disappearance.** `HiveFactoryDashboard.tsx:836` and `:2638` gate on `spark.some((v) => v > 0)`. A genuinely-zero series (zero advisories — good news!) makes the sparkline **vanish**, and the panel silently renders less. This is precisely what ADR 0002 forbids: "a dashboard that silently renders less is indistinguishable from a healthy one with less to report" (`adr/0002-factory-page.md`). The fix already exists twenty lines away — `:1605` renders `"accumulating data"` instead. Use that everywhere, and add a distinct `"steady at zero"` state. - -**F4. Irregular time intervals rendered as regular.** Every implementation maps index to x (`x = (i / (data.length - 1)) * W`), ignoring `t` entirely. `registry-data.json`'s `issueHistory` is ~960 s spacing but `hive-history.json` intervals visibly vary (sampled gaps of 6,116 s, 8,546 s, 8,915 s). Any pipeline hiccup silently compresses real time. Fix: either map x from `t` on a true time domain, or bucket into fixed intervals before rendering, and say which. - -**F5. Too few data points.** With 3–4 points a "sparkline" is a jagged polyline that implies a trend that isn't there. Set a floor (I'd say **≥12 points**) and fall back to `"accumulating data"` below it. The existing guard is only `data.length < 2` (`Sparkline.tsx:24`). - -**F6. Colour carrying meaning alone.** `HiveFactoryDashboard.tsx:1560-1566` pairs `#f85149` (red, Budget Used) against `#3fb950` (green, Merged/Cycle) — the classic red/green deuteranopia collision. Compounded by `Sparkline.tsx`'s own note that "CSS custom properties not supported in inline SVG" (line 8), so these are baked hexes that don't respond to light mode either. - -**F7. Invisible to screen readers.** All four implementations are `aria-hidden` (`Sparkline.tsx:47`, `HiveFactoryDashboard.tsx:972`, `:1592`). That's defensible when the number is adjacent and the line is pure decoration — but the moment a sparkline carries the _trend_ claim, hiding it removes information. Meanwhile `FactoryCharts.tsx:324-328` already does this right with `role="img"` + a prose `aria-label`. - -**F8. Where sparklines simply beat full charts** — and this is the case _for_ the maintainer's preference: when you need **many series compared at a glance in a tight vertical space**, when the trend matters more than any individual value, and when the graphic must sit inline with the number it annotates. When you need to read a specific value, compare precise magnitudes, or show more than one series in the same box, use a real chart. - ---- - -## G) Sparkline system - -A coherent system rather than scattered one-offs. Concretely: **one component, four variants, three sizes, one palette, one honesty contract.** - -### G.1 Consolidate first - -Replace all four implementations with one exported `<Sparkline>` from `src/components/Sparkline.tsx` (extend it; don't start over). Delete `HiveFactoryDashboard.tsx:923-945`, `:949-978`, and the inline `sparkPoints`/`historySparkline` at `:1539-1606`. This is a prerequisite, not a nice-to-have — while four implementations exist, no visual rule can be enforced. - -Proposed API: - -```ts -type SparkVariant = "line" | "winloss" | "bar" | "bullet"; -type SparkScale = "zero" | "minmax" | "domain"; -type SparkSize = "inline" | "cell" | "card"; // 60×14 | 100×20 | 160×32 - -interface SparklineProps { - data: number[]; - t?: number[]; // epoch seconds — enables true time-domain x-axis - variant?: SparkVariant; // default "line" - scale?: SparkScale; // default "zero"; "domain" for small multiples - domain?: [number, number]; // shared scale across a small-multiple grid - band?: [number, number]; // shaded normal/acceptable range - target?: number; // bullet-graph reference line - size?: SparkSize; // default "cell" - showMinMax?: boolean; // default true at "cell"/"card" - showEnd?: boolean; // default true — the current-value dot - label: string; // REQUIRED. drives aria-label. no anonymous sparklines. - unit?: string; - emptyText?: string; // default "accumulating data" - minPoints?: number; // default 12 -} -``` - -Two deliberate API decisions: **`label` is required** (an unlabelled sparkline is the failure mode, so make it unrepresentable), and **`scale` is explicit with no silent default switch** (fixes F1). - -### G.2 Sizes — three, and only three - -| Token | Dimensions | Where | -| -------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `inline` | 60 × 14 | Inside a sentence or a table cell. Matches surrounding text height, per "The sparkline should be about the same height as the text around it" (https://en.wikipedia.org/wiki/Sparkline). | -| `cell` | 100 × 20 | Leaderboard rows, per-lane rows. Roughly today's `MiniSparkline` (`:951-952`). | -| `card` | 160 × 32 | Headline metric cards and small-multiple grids. Roughly today's `historyTrends` (`:1568-1569`). | - -Aspect ratio ~4:1 to ~5:1 throughout. Do not let a sparkline exceed 200 px wide — past that it is a chart and should get axes. - -### G.3 Colour semantics — single-hue intensity, per Few - -Adopt Few's structural solution: "varying intensities of a **single hue**… to make them discernible by those who are **color blind**" (https://en.wikipedia.org/wiki/Bullet_graph). - -- **One neutral hue for all ordinary sparklines.** The existing `#58a6ff` blue is fine. A sparkline's default state is _informational_, not _judgmental_ — most of the six metrics at `:1560-1566` do not need six different colours, and giving them six implies six different meanings that don't exist. -- **Colour is reserved for verdict, and only when there is one.** Two states: `nominal` (neutral blue) and `attention` (amber `#d29922`). **Drop the red/green pair entirely** — `#f85149` vs `#3fb950` is the deuteranopia collision, and green-on-a-line-chart reads as "good" even when the line is going the wrong way. -- **Shape is the second channel, always.** Amber sparklines additionally get a thicker stroke and a filled end-dot with a ring; nominal ones get a plain dot. Verdict must survive greyscale. -- **Band shading is 12% of the line hue**, never a second hue. -- **Theme-reactive.** Given `Sparkline.tsx:8` ("CSS custom properties not supported in inline SVG"), resolve colours in the _parent_ component from CSS vars and pass hex strings down as props. Don't re-hardcode. - -### G.4 Marking min / max / current - -Tufte's convention, and the one thing that makes an axis-less line honest: - -- **Current value:** a filled dot at the right end, always on (`showEnd`). This is what makes the eye land on "now". -- **Min and max:** two small open dots, plus the **numbers printed at the ends of the graphic in the same colour**. This is the axis substitute. Without it, an axis-less min–max-scaled sparkline is unreadable. -- **Normal-range band:** a light shaded horizontal band behind the line for series with a known acceptable range (queue depth, build duration). The line leaving the band is then self-evidently the story, with no colour change needed. -- **Zero line:** draw a hairline at y=0 when `scale="zero"` and the data approaches it. - -### G.5 The honesty contract — every sparkline, no exceptions - -1. **Never alone.** Always accompanied by the current value as a printed number. The line is the trend; the number is the scale. -2. **Scale is declared.** Small multiples share `domain`; a `minmax` sparkline must print min and max. The current mixed regime (F1) is the bug this fixes. -3. **Missing data is visible, never hidden.** Remove the `spark.some(v => v > 0)` gates at `:836` and `:2638`. States: `"accumulating data"` (< `minPoints`), `"steady at zero"` (all zeros — good news, not absence), `"unavailable — last seen 2h ago"` (fetch failed). Directly implements ADR 0002's per-panel visible unavailability. -4. **Window is stated once per grid**, not per sparkline: "last 14 days · 168 snapshots · 2h resolution". The existing `panelMeta` at `:1578` already does this — keep it. -5. **Gaps are drawn as gaps.** Break the polyline on missing buckets rather than interpolating across them. - -### G.6 Hover / tooltip - -- Native `<title>` inside the SVG for the whole sparkline: `"Queue depth: 58 now, 19–175 over 14 days"`. Zero JS, works on SSG, survives hydration. -- On `card` size only, a crosshair on pointer-move showing `date · value`. Not on `inline`/`cell` — a word shouldn't have a tooltip UI. -- **Never** a hover-only sparkline. The trend must be readable without interaction, per the "screenshot-able" requirement from the previous section. - -### G.7 Accessibility - -Copy the pattern already working at `FactoryCharts.tsx:324-328` (`role="img"` + prose `aria-label`): - -- **Decorative case** (a number is adjacent and states the trend in text): keep `aria-hidden="true"`. Correct and cheap. -- **Information-bearing case** (the sparkline _is_ the claim): `role="img"` with a generated sentence — `aria-label="Queue depth over 14 days: started at 175, now 58, lowest 19, highest 175. Trending down."` Generate this from the same props that draw the line, so it can never drift out of sync. -- Focusable (`tabindex="0"`) only where a tooltip exists. -- Contrast ≥3:1 for the stroke against the panel background in both themes. -- `@media (prefers-reduced-motion: reduce)` on any animated draw-in. **There are currently zero occurrences of this in `src/`** — verified by grep. - ---- - -## G.8 Concrete Bluefin sparklines — what feeds each - -Ordered by impact. - -| # | Sparkline | Variant / scale | Feeding series | Notes | -| ------ | ----------------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **1** | **Build success rate, per image lane** — the small-multiple centerpiece | `line`, `scale="domain"` `[0,100]` | Per-lane CI history (not yet in the shipped JSON — **this series does not exist today and needs to be produced**) | Five identical sparklines on a **shared 0–100 domain** is the whole flex. Shared domain is non-negotiable; per-lane autoscaling would make a 99% lane and a 60% lane look identical. | -| **2** | **Pass/fail win-loss bars, last 40 builds per lane** | `winloss` | Same per-lane CI history | The classic form for binary outcomes: up-tick above the midline for pass, down-tick below for fail, equal height, no scale needed. **Genuinely dense and instantly readable** — 40 builds in 100 px. This is the highest-impact new variant and it is ~15 lines of `<rect>`s. | -| **3** | **Merge time vs. the <30 min SLA** | **`bullet`** | `hive-history.json` `medianMergeMins` (currently 800–1086 in the sampled window) | Few's form exactly: actual as the thick bar, **target as a thin vertical line at 30**, qualitative bands behind (https://en.wikipedia.org/wiki/Bullet_graph). The <30 min SLA is documented at `docs/agentic-contributing.md:144-160`. **Be warned this will read as a dramatic miss** — median is currently ~13–18× the target. That is exactly the Backblaze "publish your own worsening numbers" move (https://www.backblaze.com/blog/backblaze-drive-stats-for-q1-2025/), and it is more credible than hiding it. If the SLA applies to a narrower class of issue than `medianMergeMins` measures, **say so in the label** rather than dropping the panel. | -| **4** | **Build duration trend per lane** | `line`, `scale="zero"`, with `band` for acceptable range | `FactoryCharts.tsx:135-152` currently hardcodes LTS `11 → 15` and Dakota `114 → 273` | A sparkline shows the drift instantly where two dots cannot. **Note:** I could not find a "+42%" string anywhere in the repo (grepped `trending\|duration\|42%` across `src/`, `docs/`, `static/`); the committed numbers are +36% for LTS and +139% for Dakota. Whatever figure ships, derive it from the series, don't hardcode it. | -| **5** | **Issue and PR volume** | `line`, `scale="zero"`, `t` supplied | `registry-data.json:242` `issueHistory`, `:2932` `prHistory`, both `{t,v}` at ~960 s | Already half-wired: `HiveFactoryDashboard.tsx:1911-1912` does `issueHistory.slice(-48).map(e => e.v)`. **Pass `t` too** — 48 points at 16 min is 12.8 hours, and the label should say so. `prHistory` appears unused for sparklines today; pair them in one row. | -| **6** | **Contributor activity, inline per leaderboard row** | `inline` (60×14) | Per-contributor task history from `registry-data.json` `leaderboard[]` (only cumulative `tasks_completed`/`tasks_failed` today — **a per-contributor time series does not exist and would need to be produced**) | This is the most Tufte-correct placement on the whole page: a word-sized graphic in a table cell next to the name and the number. Pairs with the live `current_task` string. Until the series exists, use a win-loss bar of that contributor's recent PR outcomes from `queue.json` `check_state`. | -| **7** | **Token burn over 24h** | `line`, `scale="zero"` | `totalTokens24h` (596,755,619) is a **scalar snapshot, not a series** — needs a 24h history to be added | Once the series exists this is a great one, because it should visibly bulge when `governorMode` hits `SURGE`. Annotate the SURGE windows on it. | -| **8** | **Queue depth, ACMM level, advisories, budget** | `line`, `scale="zero"` | `hive-history.json` — already wired at `:1559-1566` | Keep, but restyle to one hue and fix the scaling. Queue depth 175→19 is a genuinely good-looking series. | -| **9** | **Image release freshness cadence per lane** | `bar` | Days-since-release per lane; `FactoryCharts.tsx:184-196` currently hardcodes `[0,0,3,6,6]` | Micro bar chart of the last N release intervals per lane shows cadence _regularity_, which a single "6 days old" number cannot. | -| **10** | **Live agent activity pulse** | `winloss` or `bar`, `inline` | `registry-data.json` `agents[].lastActivityAt` bucketed | An inline 20-bucket activity strip per agent row, beside the agent's name and live task. Cheap, dense, and it makes "the factory never sleeps" visible per-agent. | - -### G.9 Assessment: the small-multiple grid as the page centerpiece - -**Yes — with one hard condition.** A 5-across grid of identical sparklines, one per image lane (bluefin, bluefin-lts, dakota, aurora, bazzite), each with lane name, current value, and the same 30-day window, is: - -- **Canonically correct.** The Wikipedia definition names small multiples as the sanctioned plural form of the sparkline in its first paragraph (https://en.wikipedia.org/wiki/Sparkline). -- **Genuinely beautiful and screenshot-able.** Repetition with variation is the thing the eye enjoys; the odd lane out announces itself with no colour, no legend, no annotation. -- **Achievable now.** It is `<Sparkline>` × 5 in a CSS grid. **Zero new dependencies.** No ECharts, no D3. -- **Consistent with ADR 0002** — it is dense on purpose, and each cell can independently render "unavailable" without the grid collapsing. - -**The hard condition: a shared domain.** `Sparkline.tsx` today normalizes each series to its own min/max (`:26-28`), which would make all five lanes look _identical_ regardless of their actual values — the grid would be beautiful and completely false. Add `domain` support before shipping this. That one prop is the difference between the best panel on the page and the most misleading. - -Pair it with a one-sentence caption underneath — the pattern that makes panels shareable (Cloudflare's key findings are full sentences, https://blog.cloudflare.com/radar-2024-year-in-review/): _"Five images, thirty days, one shared scale. Dakota is the one to watch."_ - ---- - -## G.10 Library or hand-rolled? - -**Hand-roll all of it.** Every variant here — line, win/loss, micro-bar, bullet, banded, small multiples — is inline SVG `<polyline>` and `<rect>` with linear scaling. `Sparkline.tsx` already proves the approach works in SSG (its own doc comment: "Zero-dependency inline SVG sparkline. Works in SSR/SSG contexts"). Total estimated surface: one component, ~200 lines. Hand-rolled sparklines also **server-render**, which the current ECharts panels do not (`FactoryCharts.tsx:42-57` initializes inside `useEffect`, so they are blank in the static HTML and in social previews). - -**The one genuine exception is the horizon chart.** Horizon charts (bands folded and layered to show many series in tight vertical space) need band-splitting and layered clipping, and — more importantly — **most readers cannot decode them without a legend**, which contradicts the "word-sized, no axes" premise. For Bluefin's five lanes, the small-multiple grid (G.9) is strictly better. **Recommend: skip horizon charts.** If you later have 30+ series, revisit, and at that point ECharts (already in `package.json:54`) covers it. - ---- - -## Gaps for this thread - -- **Tufte's own site is unreachable for this material.** `edwardtufte.com/tufte/books_sparklines/` → 404; `edwardtufte.com/notebook/sparklines/` → 404; `edwardtufte.com/tufte/beautiful_evidence` → 404; `edwardtufte.com/bboard/q-and-a-fetch-msg?msg_id=0001OR` → redirected to a cookie-consent page with no article content. **All Tufte quotations in this section are secondhand via https://en.wikipedia.org/wiki/Sparkline**, which cites _Beautiful Evidence_ (2006). I have not verified the exact wording against the book. The specific claims I'd flag as needing verification if precision matters: the aspect-ratio guidance and the min/max/current dot conventions, which I stated from convention and from Wikipedia's "Tufte offers some useful design principles for the sizing of sparklines" — a sentence that asserts the principles exist without listing them. -- **Stephen Few's bullet graph design spec** (`perceptualedge.com/articles/misc/Bullet_Graph_Design_Spec.pdf`) fetched but returned raw undecodable PDF bytes. The bullet-graph anatomy, motivation, and the single-hue colour-blindness rationale come from https://en.wikipedia.org/wiki/Bullet_graph summarizing _Information Dashboard Design_ (2006). I did not read Few's spec directly, so **the exact recommended band count and proportions are not sourced** — I'd verify before finalizing pixel specs. -- **Data series that do not exist yet** and would need to be produced for my recommendations #1, #2, #6, and #7: per-lane CI pass/fail history, per-contributor activity time series, and a token-burn time series. `totalTokens24h` is a scalar in `registry-data.json`, not a series. I verified their absence by reading the shipped JSON, but I could not check the OAuth-gated hive API (`hosted-projectbluefin-knuckle-gjvq.hive.kubestellar.io/api/status`), so they may exist upstream. -- **The "+42%" duration figure** the maintainer referenced: I grepped `trending|duration|buildTime|successRate` and `42%` across the repo and **found no such string**. The committed build-time data at `FactoryCharts.tsx:135-152` is LTS 11→15 min and Dakota 114→273 min. It may come from the internal lab site, which I have not accessed. -- **Verified vs. opinion:** the four-implementation finding, the two-scaling-semantics conflict, the `spark.some(v => v > 0)` gates, the hardcoded palettes, the `aria-hidden` usage, the `"accumulating data"` fallback, the `role="img"` pattern, and the `{t,v}` data shapes are all **verified by reading the files cited**. The proposed API, sizes, palette, and honesty contract in G are **my recommendations**, informed by the two Wikipedia sources. diff --git a/adr/template.md b/adr/template.md deleted file mode 100644 index 9be8b30e5..000000000 --- a/adr/template.md +++ /dev/null @@ -1,32 +0,0 @@ -# NNNN. Short title - -- **Status:** Proposed -- **Date:** YYYY-MM-DD -- **Deciders:** - -## Context - -What situation forces a decision? Include the constraints that actually bind, and -the facts verified in the repository rather than assumed. - -## Decision - -What we are doing, stated so an implementer can build it without re-deriving the -reasoning. - -## Scope - -What this record authorizes, explicitly. Anything not listed here is not -authorized by this record. - -**In scope:** - -**Out of scope:** - -## Consequences - -What becomes easier, what becomes harder, and what we accept as a cost. - -## Alternatives considered - -Each option, and why it lost. diff --git a/scripts/countme-worker.test.js b/scripts/countme-worker.test.js index 408850eeb..a6dcd23ef 100644 --- a/scripts/countme-worker.test.js +++ b/scripts/countme-worker.test.js @@ -39,17 +39,22 @@ function stubDb(rows, { throws = false } = {}) { env: { DB: { prepare(sql) { + statements.push({ sql, args: [] }); + const result = { + async all() { + if (throws) throw new Error("no such table"); + return { results: rows }; + }, + async run() { + if (throws) throw new Error("no such table"); + }, + }; return { + ...result, bind(...args) { - statements.push({ sql, args }); + statements[statements.length - 1] = { sql, args }; return { - async all() { - if (throws) throw new Error("no such table"); - return { results: rows }; - }, - async run() { - if (throws) throw new Error("no such table"); - }, + ...result, }; }, }; @@ -227,7 +232,10 @@ test("counts.json aggregates weekly records per first-party repo", async () => { ]); assert.ok(db.statements[0].sql.includes("telemetry_events")); - assert.deepEqual(db.statements[0].args, [...counts.COUNTED_REPO_IDS]); + // No repo filter in SQL: filtering to bare family ids there discarded every + // hardware variant before the counting rules saw it. + assert.deepEqual(db.statements[0].args, []); + assert.ok(!db.statements[0].sql.includes("repo IN")); }); test("a repo missing from a week is null, never zero", async () => { @@ -883,3 +891,54 @@ test("the chart title is inked, not left to default black", async () => { globalThis.fetch = restore; } }); + +test("every published image variant counts under its family", async () => { + // Clients send the published image name, so the id on the wire is + // `bluefin-lts-hwe-nvidia`, not `bluefin-lts`. Matching bare family ids threw + // every hardware variant away, which is why LTS counted 2 while its images + // were reporting. + const n = (repo, gm = 0) => counts.normalizeCountmeRepo(repo, gm); + + assert.deepEqual(n("bluefin-lts-hwe"), { repo: "bluefin-lts", gaming: false }); + assert.deepEqual(n("bluefin-lts-hwe-nvidia"), { + repo: "bluefin-lts", + gaming: false, + }); + assert.deepEqual(n("bluefin-lts-nvidia"), { + repo: "bluefin-lts", + gaming: false, + }); + assert.deepEqual(n("bluefin-nvidia"), { repo: "bluefin", gaming: false }); + assert.deepEqual(n("dakota-nvidia"), { repo: "dakota", gaming: false }); + assert.deepEqual(n("dakota-nvidia-gaming"), { repo: "dakota", gaming: true }); + + // An LTS image must never be counted as flagship: longest family wins. + assert.equal(n("bluefin-lts").repo, "bluefin-lts"); + assert.equal(n("bluefin-lts-hwe").repo, "bluefin-lts"); + + // Still ours only. + assert.equal(n("eos"), null); + assert.equal(n("fedora"), null); + assert.equal(n(""), null); + + // Idempotent. + assert.deepEqual(n(n("bluefin-lts-hwe").repo), { + repo: "bluefin-lts", + gaming: false, + }); +}); + +test("an LTS hardware variant lands in the LTS total, not flagship", async () => { + const db = stubDb([ + { week: "2026-09-07", repo: "bluefin-lts", gamemode: 0, hits: 2 }, + { week: "2026-09-07", repo: "bluefin-lts-hwe", gamemode: 0, hits: 40 }, + { week: "2026-09-07", repo: "bluefin-lts-hwe-nvidia", gamemode: 0, hits: 11 }, + { week: "2026-09-07", repo: "bluefin-nvidia", gamemode: 0, hits: 7 }, + ]); + + const body = await (await get("/counts.json", db.env)).json(); + const week = body.weeks[body.weeks.length - 1]; + + assert.equal(week["bluefin-lts"], 53, "2 + 40 + 11 all count as LTS"); + assert.equal(week.bluefin, 7, "an nvidia flagship build is still flagship"); +}); diff --git a/workers/countme-proxy/counts.mjs b/workers/countme-proxy/counts.mjs index eb2f7add9..8f7e9c504 100644 --- a/workers/countme-proxy/counts.mjs +++ b/workers/countme-proxy/counts.mjs @@ -20,17 +20,14 @@ const WEEK_MS = 7 * 24 * 60 * 60 * 1000; */ export const GAMING_SUFFIX = "-gaming"; -/** Every repo id the service accepts, in both spellings. */ -export const COUNTED_REPO_IDS = [ - ...PROJECTBLUEFIN_REPOS, - ...PROJECTBLUEFIN_REPOS.map((repo) => `${repo}${GAMING_SUFFIX}`), -]; - /** - * Weekly counts per reported repo and game-mode flag, Monday-anchored. + * Weekly counts per reported image and game-mode flag, Monday-anchored. + * + * `weekday 0` advances to that week's Sunday, so `-6 days` lands on its Monday. * - * `weekday 0` advances to that week's Sunday, so `-6 days` lands on its Monday - * — the same week key the published series has always used. + * No repo filter here on purpose. Filtering to bare family ids in SQL discarded + * every hardware variant before the counting rules ever saw it; + * `normalizeCountmeRepo` decides what is ours, and it folds variants in. */ export const WEEKLY_COUNTS_SQL = `SELECT date(received_at, 'weekday 0', '-6 days') AS week, repo, @@ -38,25 +35,57 @@ export const WEEKLY_COUNTS_SQL = `SELECT date(received_at, 'weekday 0', '-6 days COUNT(*) AS hits FROM telemetry_events WHERE received_at >= date('now', '-${WEEK_WINDOW_DAYS} days') - AND repo IN (${COUNTED_REPO_IDS.map(() => "?").join(", ")}) GROUP BY week, repo, gamemode ORDER BY week ASC`; /** - * Fold a reported id into a first-party repo plus a game-mode flag. + * Hardware and driver variants, stripped before the family is matched. + * + * Clients send the published image name, so + * the id on the wire is the whole thing — `bluefin-lts-hwe-nvidia`, not + * `bluefin-lts`. Matching bare family ids threw every one of these away, which + * is why LTS counted 2 while its images were reporting. + * + * A driver or hardware build is the same population on different silicon, so it + * folds into its family. Game mode is a genuine attribute and is kept. + */ +const VARIANT_SUFFIXES = ["-nvidia", "-hwe"]; + +/** + * Fold a reported image name into a first-party repo plus a game-mode flag. + * + * Idempotent: a result fed back through is unchanged, because every suffix is + * already gone and the flag is already set. Returns null for ids that are not + * ours, which is how anything outside PROJECTBLUEFIN_REPOS drops. * - * Idempotent: feeding a result back through changes nothing, because the - * suffix is already gone and the flag is already set. Returns null for ids - * that are not ours, which is how anything outside PROJECTBLUEFIN_REPOS drops. + * `bluefin-lts` is matched before `bluefin`: longest family first, or every LTS + * image is counted as flagship. */ export function normalizeCountmeRepo(repo, gamemode) { - const id = String(repo ?? ""); - const suffixed = id.endsWith(GAMING_SUFFIX); - const base = suffixed ? id.slice(0, -GAMING_SUFFIX.length) : id; + let id = String(repo ?? "") + .trim() + .toLowerCase(); + if (!id) return null; + + const gaming = id.endsWith(GAMING_SUFFIX) || Number(gamemode) === 1; + if (id.endsWith(GAMING_SUFFIX)) id = id.slice(0, -GAMING_SUFFIX.length); + + let stripped = true; + while (stripped) { + stripped = false; + for (const suffix of VARIANT_SUFFIXES) { + if (id.endsWith(suffix) && id.length > suffix.length) { + id = id.slice(0, -suffix.length); + stripped = true; + } + } + } - if (!PROJECTBLUEFIN_REPOS.includes(base)) return null; + const family = [...PROJECTBLUEFIN_REPOS] + .sort((a, b) => b.length - a.length) + .find((repoId) => id === repoId); - return { repo: base, gaming: suffixed || Number(gamemode) === 1 }; + return family ? { repo: family, gaming } : null; } function countsMeta() { diff --git a/workers/countme-proxy/index.mjs b/workers/countme-proxy/index.mjs index d75b611f0..93a433929 100644 --- a/workers/countme-proxy/index.mjs +++ b/workers/countme-proxy/index.mjs @@ -10,7 +10,6 @@ import { renderRepoChartSvg, } from "./render.mjs"; import { - COUNTED_REPO_IDS, WEEKLY_COUNTS_SQL, buildCountsDocument, pendingCountsDocument, @@ -110,9 +109,7 @@ async function aggregateWeeklyCounts(env) { if (!env || !env.DB) return pendingCountsDocument(); try { - const query = await env.DB.prepare(WEEKLY_COUNTS_SQL) - .bind(...COUNTED_REPO_IDS) - .all(); + const query = await env.DB.prepare(WEEKLY_COUNTS_SQL).all(); return buildCountsDocument((query && query.results) || []); } catch (err) { console.error("Failed to aggregate countme records:", err);