Document Phase 5: finalize design-system.md, add runbook, exit criteria

design-system.md was still describing a mid-Phase-5 state (charting,
dashboard panels, query/search, and alerting UI all listed as "not
built yet"); added sections for all of them plus a real Accessibility
section, and fixed color-token values that had drifted from the actual
tokens.css since the contrast fixes.

phase-5-runbook.md documents what was actually verified against a live
docker-compose stack with real seeded data, including the five real
bugs that live-verification caught -- two of them backend bugs with no
connection to the frontend redesign, only surfaced because getting
real dashboard/alert data required exercising write paths nothing had
exercised since Phase 4's tenant_id migrations landed.

CLAUDE.md gets Phase 5's exit criteria, matching every prior phase's
"what done looks like" section.
This commit is contained in:
2026-08-16 12:36:31 -07:00
parent 9862bcccae
commit 595d1fe0fd
3 changed files with 591 additions and 0 deletions
+65
View File
@@ -383,6 +383,71 @@ Non-goals for this phase (same discipline as every phase so far):
someone with database superuser access; that's an operational control,
out of scope here and named explicitly, not silently assumed away.
## What "done" looks like for Phase 5
**Status: shipped.** A ground-up frontend redesign — visual direction,
a real design system, navigation/IA, charting, dashboard panels,
query/search, and alerting UI — plus an accessibility pass, all verified
against a live docker-compose stack with real seeded data, not just
`npm run check`/`npm run build` passing. See `/docs/design-system.md`
for the token system and component library, and
`/docs/phase-5-runbook.md` for the full verification log, including five
real bugs this phase's live-verification discipline caught that a
type-checked, successfully-building frontend would not have surfaced on
its own.
The visual direction ("Signal": near-neutral grayscale UI, color rationed
to the four-tier severity system plus a single interactive accent, real
dark-mode-as-default) was picked from three proposed directions before
any token or component work started, per an explicit stop point in this
phase's brief. The charting library (ECharts, over Observable Plot and
raw D3 — see the design-system doc for the reasoning and the verified
bundle-size/perf numbers) was likewise confirmed before being wired into
every panel type, the second explicit stop point.
Two of the five bugs this phase's verification caught were backend bugs
with no connection to the frontend redesign itself, only surfaced
because getting real dashboard/alert data to verify the new UI against
required actually exercising write paths nothing had exercised since
Phase 4's `tenant_id` migrations landed:
- `alerting`'s `rulestore.Create`/`ApplyTransition` never populated the
`tenant_id` column Phase 4 added to `alert_state`/`delivery_log` (with
a `NOT NULL` constraint) — every alert rule created against a
Phase-4-or-later database silently failed. Existing rows all had a
value from Phase 4's backfill migration, which is exactly why this
went uncaught: Phase 4's own verification never created a *new* rule
post-migration, and its runbook already discloses that Docker access
was lost partway through that phase.
- `dashboard_panels`'s `viz_type` CHECK constraint was never updated
alongside `heatmap`'s addition to the Go/TS validators — a three-place
change (Go validator, TS union, DB constraint), not two.
Both are fixed (`alerting/internal/rulestore/store.go`,
`metadata/migrations/0035_add_heatmap_viz_type.sql`) and confirmed
against a live stack: rule creation → evaluation → firing → a real
(failed, to an intentionally fake webhook) delivery attempt, and a
heatmap panel created, persisted, and rendered end to end. See the
runbook for the other three findings (one more real product bug — a
`findIndex`/nullish-coalescing bug in the chart-pivoting logic that made
every `single_stat` panel render `0` — and two real accessibility
findings caught by axe-core against live-rendered pages with real data,
not fixture data or empty states).
Non-goals for this phase (same discipline as every phase so far):
- No query-language or data-model changes beyond the one narrowly
justified exception: `heatmap` as a `VizType`, needed to feed a new
visualization, not a new query capability.
- No changes to tenant isolation, RBAC, SSO, or audit logging — Phase 4's
surface area is untouched; this phase is presentation-layer only.
- No mobile-phone-width layout — responsive verification stops at
tablet-landscape width, per the brief's explicit scope ("laptop/
tablet-landscape," not phone-width).
- No fuzzy search in the command palette, no data-grid virtualization for
very large result sets, no chart types beyond the five built
(time-series, bar, single-stat, heatmap, top-N) — real, disclosed
future work, not oversights.
## When in doubt
Ask before: changing the pinned stack, adding a new external dependency
that pulls in a large transitive tree, or making an architectural decision
+371
View File
@@ -0,0 +1,371 @@
# Sentry Design System (Phase 5)
Direction: **Signal** — cockpit/ICU-monitor instrumentation logic. Color
is a rationed resource: the UI is near-neutral grayscale everywhere, so
the four severity tiers land as genuinely the most saturated colors
anywhere on screen, not one saturated color competing with a dozen
decorative ones. See the Phase 5 design-direction review (three options
were presented; Signal was the one picked) for the two rejected
directions and the full rationale.
This doc is the source of truth for the token system and component
library `/web/src/lib/components/ui` implements — read it before adding
a new color, spacing value, or component, rather than reaching for a
literal hex or one-off markup the way Phase 0-3's pages did.
## Status
**Status: Phase 5 shipped.** All ten tasks are built: the token system,
the `ui/` component library, the persistent sidebar nav + command
palette, a real ECharts-based charting layer (5 chart types, drill-down,
zoom), the dashboard panel rebuild (GridStack drag-and-drop + live-preview
panel editor), the query/search redesign (CodeMirror syntax highlighting
+ autocomplete, sortable/resizable/expandable results), the alerting UI
redesign (severity-state pill + delivery timeline), and an accessibility
pass driven by real axe-core runs against the live app (not static
analysis) — see the Accessibility section below for what that actually
caught. See `/docs/phase-5-runbook.md` for the full verification log.
Verified in a real browser against a live docker-compose stack with real
seeded data (not just fixtures): dark/light/system theme and
comfortable/compact density switching persisting across navigation and
reload; command palette open/filter/keyboard-navigate/go-to; every nav
route including dashboard/alert detail pages with real panels and real
alert history; all 6 panel viz types rendering real query results; a
firing alert rule's full state-history timeline; keyboard-only operation
of the query editor, results table (sort/expand), and dashboard grid.
## Fonts
Self-hosted (not a Google Fonts CDN link — no runtime dependency on a
third party for the app to render correctly): `web/static/fonts/`,
licensed under the SIL Open Font License (see that directory's
`LICENSE.txt`).
| Role | Typeface | Why |
|---|---|---|
| UI (nav, labels, headings, body) | Overpass | Originally drawn for U.S. highway signage — engineered to be read correctly, fast, under bad conditions. A more honest reason to pick a typeface for an incident-response tool than "it looks modern." |
| Data (log tables, code, query bar, numbers) | Overpass Mono | Same family as the UI face — one typeface end to end removes even the small cognitive cost of a font pairing, matching the direction's overall restraint. |
Both are variable fonts (one file covers the full weight range), loaded
via `@font-face` in `web/src/lib/styles/fonts.css`.
## Color tokens
Defined in `web/src/lib/styles/tokens.css`. Dark is the literal default
`:root` defines the dark palette directly, light is the override
(both `@media (prefers-color-scheme: light)` for an unset preference and
`[data-theme="light"]` for an explicit one) — not a retrofit where light
is `:root` and dark is bolted on. Component CSS must only ever read a
token, never a literal hex; that's what makes the theme/density toggles
a token swap instead of a per-component rewrite.
| Token | Dark | Light | Use |
|---|---|---|---|
| `--color-bg` | `#0a0a0b` | `#f7f7f8` | Page background |
| `--color-surface` | `#17181a` | `#ffffff` | Cards, tables, inputs |
| `--color-surface-raised` | `#1e2023` | `#ffffff` | Hover states, popovers |
| `--color-border` / `--color-border-strong` | `#2a2c2f` / `#3a3d41` | `#dfe0e2` / `#c7c9cc` | Dividers, input borders |
| `--color-text` / `--color-text-muted` / `--color-text-faint` | `#f0f0f1` / `#85888d` / `#8a8d92` | `#101113` / `#6b6e73` / `#75787d` | Body text hierarchy |
| `--color-accent` / `--color-accent-strong` | `#3fb6ff` | `#0b84d6` | Interactive elements only — links, primary buttons, focus rings, active nav. Never reused for severity (see below); a semantic color competing with the brand accent defeats the point of "color means something." |
### Severity tiers
The schema carries seven OTel severities
(`TRACE`/`DEBUG`/`INFO`/`WARN`/`ERROR`/`FATAL`/`UNSPECIFIED`, see
`/storage/README.md`). Seven colors would be seven things to memorize at
a glance; `web/src/lib/severity.ts`'s `severityTier()` collapses them to
four accent tiers plus one "quiet" state:
| OTel severity | Tier | Dark | Light |
|---|---|---|---|
| `TRACE`, `DEBUG`, `UNSPECIFIED` | quiet | `#85888d` | `#6b6e73` |
| `INFO` | info | `#4c8dff` | `#1a63d6` |
| `WARN` | warn | `#f5c242` | `#8c6800` |
| `ERROR` | error | `#ff6a39` | `#c94b1e` |
| `FATAL` | critical | `#ff2d78` | `#c21362` |
Chosen as a blue → amber → orange → magenta progression — hue *and*
lightness both shift at every step, so no two adjacent tiers rely on
red-vs-green to be told apart, and the sequence should survive grayscale
and protanopia/deuteranopia simulation. That reasoning hasn't been
verified with an actual simulator yet — do that before treating it as
confirmed accessible, not just plausible.
Each tier also has a translucent `-bg` token (e.g. `--color-sev-warn-bg`)
for chip/pill backgrounds. These are tuned independently of the solid
foreground colors above, not derived from them by a fixed formula: a
translucent color tints *toward* its own hue as alpha increases, which
for these saturated, low-luminance hues (critical's magenta especially)
*lowers* contrast against the foreground text the higher the alpha goes
— the opposite of the intuitive "more opaque background is safer"
assumption. `--color-sev-warn` itself was darkened in light mode
(`#9c7300``#8c6800`) for the same underlying reason: axe-core caught
the *plain* light-mode warn text failing AA (4.32:1) against white
before any background was even involved. See the Accessibility section.
Use `<SeverityBadge severity={row.severity} />`
(`ui/SeverityBadge.svelte`) to render one of these — it owns the OTel
string → tier mapping so call sites can't invent a sixth color by hand.
For non-log-severity status (success/danger/neutral/accent — e.g. a
"saved" confirmation), use `<Badge tone="...">` instead; the two are
deliberately separate components so a form-validation color can never
accidentally collide with a log-severity one.
## Type scale
14px base (dense-first — this is a tool for reading log tables, not
marketing copy), 1.2 modular ratio:
`--text-xs` 11px · `--text-sm` 13px · `--text-base` 14px · `--text-md`
16px · `--text-lg` 20px · `--text-xl` 28px · `--text-2xl` 40px
`--font-weight-normal` 400, `--font-weight-medium` 600,
`--font-weight-bold` 700. `font-variant-numeric: tabular-nums` is set
globally on `body` so columns of numbers/timestamps align.
## Spacing, radius, shadow
8px-based spacing scale: `--space-1` through `--space-8` (4px, 8px,
12px, 16px, 24px, 32px, 48px, 64px). Radius: `--radius-sm` 4px,
`--radius-md` 6px, `--radius-lg` 10px, `--radius-full` (pills). Shadow
is used sparingly, matching Signal's restraint — only real overlays
(modals, the command palette, tooltips) get one (`--shadow-sm/md/lg`);
inline UI never does.
## Density
Two presets, one token swap — `html.density-compact` overrides
`--row-height`, `--row-padding-y/x`, `--panel-padding`, `--control-height`,
and drops `--text-base` to `--text-sm`. Comfortable (the default) suits
dashboards and forms; compact suits log tables and query results. Toggle
via `$lib/density.svelte.ts`'s `setDensity()`/`toggleDensity()` — a
global, persisted (`localStorage['sentry.density']`) preference, not a
per-page setting, so switching it on one page carries to the next.
`web/src/app.html` has a synchronous inline script that applies the
stored value before first paint, so there's no flash of the wrong
density on reload; keep that script's storage key/values in sync with
`density.svelte.ts` if either changes.
## Theme
`$lib/theme.svelte.ts`, same persisted/synchronous-apply shape as
density. Three states — `'dark' | 'light' | 'system'` — but unlike most
apps, the *unset* default is `'dark'`, not `'system'`. That's the actual
point of "real dark mode as the default, not an afterthought": a
first-time visitor on a light-OS machine still lands in dark. `'system'`
is available as a deliberate opt-in for anyone who wants their OS
setting to win instead.
## Components (`web/src/lib/components/ui/`)
Import from the barrel: `import { Button, Input, ... } from
'$lib/components/ui';`
| Component | Notes |
|---|---|
| `Button` | `variant`: `primary`/`secondary`/`ghost`/`danger`. `size`: `sm`/`md`. Renders `<a>` when `href` is passed, `<button>` otherwise. |
| `Input` | Thin styled wrapper over `<input>`; `bind:value`, `invalid` sets `aria-invalid` + a danger border. |
| `Select` | Styled wrapper over native `<select>` (real `<option>` children) — not a custom listbox, so it keeps native keyboard/screen-reader behavior for free. |
| `Badge` | Generic status pill. `tone`: `neutral`/`success`/`danger`/`accent`. |
| `SeverityBadge` | `severity` prop takes a raw OTel string; maps to a tier internally. Use this instead of `Badge` for anything log-severity-shaped. |
| `Table` | A styling wrapper around real `<table>` markup (native semantics matter for screen readers) — pass real `<thead>`/`<tbody>` as children. Density-aware via the row tokens above. Not a data grid; sort/resize (Phase 5 task 6) layers on top later. |
| `Card` | `title`/`actions` (a snippet) header, `padded` toggle. |
| `Modal` | Built on native `<dialog>` — real focus trap, Escape-to-close, and top-layer stacking, not hand-rolled. `bind:open`, `title`, `footer` snippet. |
| `Tooltip` | Pure-CSS hover/focus tooltip, `role="tooltip"` + `aria-describedby`. |
| `Tabs` | Renders the tab list only (roving tabindex, arrow-key nav); the caller renders each panel's content on `bind:active` and owns `id="panel-{id}"`/`aria-labelledby="tab-{id}"`. |
`CommandPalette.svelte` and `NavSidebar.svelte`
(`web/src/lib/components/`, not under `ui/`) are app-shell components,
not general-purpose library pieces — mounted once in `+layout.svelte`.
## Navigation & tenant switching
Persistent sidebar (`NavSidebar.svelte`): Search / Dashboards / Alerts /
Data Sources / Settings, active-route highlighting, a command-palette
hint, and the theme/density quick toggles. The tenant indicator calls
`getCurrentSession()` (`$lib/api.ts`), which wraps `POST
/internal/authorize` — an endpoint that already existed (`api/authz.
HTTPAuthorizer` and `alerting` already call it) and was already reachable
from the browser (`enterprise-auth`'s whole mux is behind
`WithCredentialedCORS`, not just the tenant-picker routes), so this
needed zero backend changes.
**Known limitation, not yet solved at the API level**: there is no
"list my other tenant memberships while already logged in" endpoint —
`GET /auth/memberships` only works during the short-lived pending-login
window (see `enterprise/internal/loginhandler`'s doc comment), not for
an established session. So "Switch tenant" in the sidebar re-triggers
login (`/auth/oidc/login`) rather than offering an inline dropdown —
logging in again is the only tenant-switching mechanism this product
actually has today, and if there's more than one membership it
naturally lands back on `/select-tenant`. A real inline switcher would
need a new `GET /auth/my-memberships`-shaped endpoint (or equivalent);
that's real, disclosed future work, not a bug in what's built.
## Command palette
`⌘K`/`Ctrl+K` anywhere in the app. Indexes the five static nav
destinations plus live-fetched dashboards and alert rules
(`listDashboards()`/`listRules()`, via `Promise.allSettled` so one
failing endpoint doesn't blank the other's results or the static items).
Arrow keys + Enter to navigate, Escape to close (native `<dialog>`,
same reasoning as `Modal`). Substring filter, not fuzzy — no new
dependency pulled in for it, matching this repo's "boring,
well-understood dependencies" convention.
## Charting (`web/src/lib/charts/`)
Built on **ECharts**, via modular imports (`echarts/core` plus only the
chart/component modules actually used — `LineChart`, `BarChart`,
`HeatmapChart`, `TooltipComponent`, `GridComponent`, `LegendComponent`,
`DataZoomComponent`, `VisualMapComponent`, `MarkLineComponent`,
`CanvasRenderer`), not the full bundle. Picked over Observable Plot (SVG
rendering hits a real performance ceiling at volume, and no built-in
zoom/pan or legend-toggle) and raw D3 (too much hand-rolled engineering
for chart types this standard). Verified, not assumed: the lazy-loaded
chart chunk is 211,975 bytes gzipped, and a synthetic 30,006-row/6-series
stress fixture (`/dev/charts`, an unlisted dev-only route) renders its
first two frames in ~50ms on a production build — `npm run dev`'s
~3.1s figure for the same fixture is pure dev-mode/unminified-JS
overhead, not a real perf number, and was confirmed as such before being
discarded.
Five chart components, all consuming the same `{columns, rows}`
`QueryResult` shape Phase 2's query endpoint has always returned:
`TimeSeriesChart` (multi-series line, legend toggle), `BarChart`
(including stacked), `SingleStat` (big number + sparkline + trend),
`Heatmap`, `TopN` (ranked horizontal bars). `EChart.svelte` is the shared
base wrapper (init/resize/dispose lifecycle).
**No query-language change was needed for multi-series or drill-down**
both are pure frontend reshaping of the existing tabular output:
- `pivot.ts`'s `pivot(columns, rows, config)` turns a "long" result (one
row per series+x pair, e.g. `stats count by service, timestamp`) into
one series per distinct value of the grouping column. Its value-column
auto-detection has one sharp edge worth knowing if you touch it: when
a result has only one non-x column (a bare `stats count`, `single_stat`'s
most common shape), `Array.prototype.findIndex` returning `-1` for "not
found" must be checked explicitly — `-1 ?? fallback` never falls back,
because `-1` isn't `null`/`undefined`. This was a real, shipped bug
(every `single_stat` panel silently rendered `0`) caught by seeding a
live dashboard with real query results rather than only fixture data,
not by any static check.
- `drilldown.ts` strips a panel's query down to its pre-`stats` filter
portion, appends a clicked series/x-value as a new filter term, and
computes a tight time window around a clicked timestamp — then
navigates to the Search page via URL params (`?q=&earliest=&latest=`).
`theme.ts`'s `readChartTokens()` reads real computed CSS custom-property
values (`getComputedStyle`) so charts render in the actual active
theme's colors rather than a hardcoded palette — guarded with an
`SSR_FALLBACK` object for adapter-static's prerender pass, where
`document` doesn't exist.
**The one backend change in this phase**: `heatmap` as a `VizType`
(`api/dashboards/types.go`'s `validVizType()`, `web/src/lib/api.ts`'s
`VizType` union, and the `dashboard_panels` table's `viz_type` CHECK
constraint — three places, not two; the DB constraint mirrors the Go
validator and was originally missed, which meant a heatmap panel passed
API validation and then failed on insert. See
`metadata/migrations/0035_add_heatmap_viz_type.sql`). Justified under
the brief's "no query-language or data-model changes except what's
strictly needed to feed richer visualizations" exception — a heatmap is
a viz type, not a new query capability.
## Dashboard panels
Drag-and-drop grid is **GridStack**, already a Phase 3 dependency — no
new library needed, satisfying the brief's "a maintained library is
fine, don't hand-roll grid physics." `PanelEditor.svelte` (a `Modal`)
replaces the old inline add-panel form: a debounced live preview reuses
`PanelViz` directly, so what you see while editing is pixel-identical to
what renders on save, not a separate preview renderer that can drift
from the real thing. Empty/loading/error states use `EmptyState` and
`Skeleton` rather than a blank panel or a raw error string.
## Query & search (`web/src/lib/query-editor/`)
**CodeMirror 6**, not a hand-rolled textarea-plus-overlay highlighter —
picked specifically because autocomplete needs real cursor-aware popup
positioning, which a plain textarea can't give you. `language.ts` is a
`StreamLanguage` tokenizer for the pipe grammar; its `token()` function
must return real `@lezer/highlight` tag names looked up by string
(`'controlKeyword'`, `'operatorKeyword'`, `'name.function'` for
tag+modifier pairs) — a custom `Tag.define()` object's `.toString()`
looks plausible but silently fails to highlight anything, a real bug hit
and fixed while building this. `completions.ts` provides context-aware
suggestions: stage keywords after `|`, stats functions after `stats`,
field names elsewhere.
`ResultsTable.svelte`: sortable columns (click header — a real `<button>`
inside the `<th>`, not a clickable `<th>` itself, so it's keyboard-operable
for free), resizable columns (pointer-drag handles, mouse-only by
design — the `role="separator"` handle is intentionally not in the tab
order, matching how most apps treat column resize as a mouse affordance
rather than a keyboard one), and expandable rows. The row-expand
interaction was originally a bare `<tr onclick=...>` with no keyboard
equivalent at all — a real gap the accessibility pass caught (not via
axe, which doesn't flag missing keyboard handlers on custom widgets;
caught by manually tabbing through the page), fixed by adding
`tabindex="0"`, `role="button"`, `aria-expanded`, and an `Enter`/`Space`
`onkeydown` handler alongside the existing click handler.
"Add as panel to dashboard" (`AddToDashboardModal.svelte`) lets a query
built on the Search page become a saved panel without hand-copying the
query string into the dashboard editor.
## Alerting UI
`AlertStatePill.svelte` reuses the log-severity color tiers rather than
inventing a second color vocabulary: `ok → quiet`, `pending → warn`,
`firing → critical`. `DeliveryTimeline.svelte` reframes the existing
`delivery_log` data (no new backend fields) as a vertical timeline —
"why didn't I get paged" is a chronological question, and a flat table
answered it less directly than a timeline does.
## Accessibility
Automated checks ran with **axe-core**, injected into the live app in a
real browser and driven against a real seeded docker-compose stack
(dashboards with all 6 panel viz types populated with real query
results, a firing alert rule with real delivery history) — not just
static markup or empty/loading states, since several of the real
findings below only appear once real content renders. Real violations
found and fixed:
- `color-contrast`: `AlertStatePill`'s critical tier at 4.04:1 (below
the 4.5:1 AA threshold) when rendered on an opaque `--color-surface`
card, not the plain page background most severity chips happen to sit
on — see the Severity tiers section above for why translucent
backgrounds made this worse, not better, and why quiet/info (dark) and
quiet/warn/error (light) were fixed alongside it as the same latent
bug, not yet triggered elsewhere only because no page had rendered
those specific tiers on an opaque surface yet.
- `landmark-main-is-top-level` / `landmark-no-duplicate-main` /
`landmark-unique`: the layout shell's content wrapper was a second
`<main>` nested around each page's own top-level `<main>`.
- `landmark-one-main` / `region`: `/data-sources` was the one page using
a plain `<div>` instead of `<main>` as its top-level element.
- `heading-order`: `Card` and `EmptyState` both jumped from `<h1>` to
`<h3>`, skipping `<h2>`.
- `aria-input-field-name`: the CodeMirror query editor's `.cm-content`
had no accessible name — fixed via `EditorView.contentAttributes.of({
'aria-label': ... })`.
- `empty-table-header`: the alerts list table's trailing actions column
had a bare `<th></th>` — fixed with visually-hidden text (`.sr-only`,
now a shared utility in `app.css`) rather than a visible "Actions"
label that would've added a fifth, less-necessary heading.
Keyboard-only operation was verified by tabbing through, not just
inferred from markup: sidebar nav → command-palette hint → theme/density
controls → into main content, with a visible focus ring
(`--focus-ring`, a two-layer box-shadow so it's visible on both light
and dark surfaces) at every stop; the query editor is reachable and
labeled; sort buttons are real `<button>`s; the results table's row
expansion works via `Enter`/`Space` after the fix above. Responsive:
the sidebar collapses to an off-canvas drawer under 860px
(`NavSidebar.svelte`'s `mobileOpen`/`onCloseMobile`, a transform-based
slide-in with a backdrop button), verified down to tablet-landscape
width, not just described in CSS.
+155
View File
@@ -0,0 +1,155 @@
# Phase 5 runbook
Extends `/docs/phase-0-runbook.md` through `/docs/phase-4-runbook.md`.
Read those first. Phase 5 touched only `/web` and the two narrow,
justified backend changes called out below — no new services, no new
pinned-stack components.
## What's actually been verified
Every claim below was checked against a live `docker compose up`
stack with real seeded data (not fixture files, not mocked stores),
in a real browser (Claude in Chrome), not just `npm run check` and
`npm run build` passing. Five real bugs were found this way — two of
them backend bugs unrelated to the frontend redesign itself, only
surfaced because getting real chart/panel/alert data required actually
exercising the write paths those bugs were in. That's the same
"a passing check and a working feature are not the same claim"
discipline every prior phase's runbook has held to.
## 1. Bring up the stack
```sh
docker compose up -d --build
cd web && npm run dev # localhost:5183, talks to localhost:8080/8081 by default
```
No new services this phase. `docker compose ps` should show the same
set as Phase 4.
## 2. Seed real data
`hack/benchmark-fixture` (Phase 2) still works unchanged:
```sh
cd hack/benchmark-fixture
go run . -count=5000 -batch-size=500 -concurrency=4
```
Create a dashboard covering all 6 panel viz types and an alert rule
with a real notification target, either through the web UI or via the
API directly. One thing worth knowing if you script this the way this
runbook's own verification did: the query language requires a leading
base term before any pipe stage — `stats count by host` alone is a
syntax error (`unexpected COMMA after query` or similar), because
`Parse()` always parses a base filter/free-text term before it'll
accept a `|`. `earliest=-1h | stats count by host` is the idiomatic
"match everything in the current window" form (see
`/docs/query-language-reference.md`'s `earliest=-24h severity=ERROR |
stats count by service` example) — this isn't new to Phase 5, but is
easy to trip over when scripting panel creation instead of using the
query bar, which always has a real base term from its own default
query.
## 3. Dashboard panels — all 6 viz types
Time-series line, bar, single-stat, top-N, table were all real Phase 3
capabilities re-rendered on the new chart layer; heatmap is the one new
type this phase added. Verify each renders against real query results,
not just the `/dev/charts` synthetic fixture route (unlisted, dev-only —
useful for perf testing, not a substitute for exercising the real panel
CRUD + query path).
**Real bug caught here**: `single_stat` panels rendered `0` for every
query, including ones the API confirmed returned real data (`{"columns":
["count"],"rows":[[5000]]}` from `POST /query` showed `5000`, the panel
showed `0`). Root cause in `web/src/lib/charts/pivot.ts`'s value-column
auto-detection: `Array.prototype.findIndex` returns `-1` for "not
found," and the fallback used `??`, which only substitutes on
`null`/`undefined``-1 ?? fallback` evaluates to `-1`, not the
fallback. For a single-column result (`stats count`, single-stat's most
common shape), the x-column fallback (index 0) excludes the only column
from the search, `findIndex` always returns `-1`, and the panel silently
read `row[-1]` (`undefined`) as its value. Fixed by checking for `-1`
explicitly instead of relying on `??`. This would not have been caught
by `/dev/charts`'s synthetic multi-column fixture data — it only
reproduces with a genuinely single-column result, which only a real
`stats count`-shaped query against the live API produces.
**Real bug caught here, backend**: creating a heatmap panel returned
`{"error":"viz_type must be one of table, line, bar, single_stat,
top_n, heatmap, got \"heatmap\""}`-shaped failures at two different
layers in sequence. First, the running `api` container was stale
relative to the Phase 5 source change adding `heatmap` to
`validVizType()` — rebuilding (`docker compose build api`) fixed that.
Second, after rebuilding, panel creation still failed with a Postgres
`23514` check-constraint violation: `dashboard_panels`'s `viz_type`
CHECK constraint (`metadata/migrations/0002_create_dashboard_panels.sql`)
was never updated alongside the Go validator, so `heatmap` passed API
validation and then failed on insert. Fixed with a new migration,
`metadata/migrations/0035_add_heatmap_viz_type.sql` (drop and recreate
the constraint — Postgres has no `ALTER CHECK`). Worth remembering for
any future `VizType` addition: it's a three-place change (Go validator,
TS union, DB constraint), not two.
## 4. Alert rule creation
**Real bug caught here, backend, unrelated to the Phase 5 redesign
itself**: creating any alert rule against a freshly migrated database
failed with `inserting initial alert_state: ERROR: null value in
column "tenant_id" of relation "alert_state" violates not-null
constraint`. Phase 4 added `tenant_id` to `alert_state` and
`delivery_log` (migrations `0022`/`0023`, backfilled via a join through
`alert_rules.id`, then set `NOT NULL`) but
`alerting/internal/rulestore/store.go`'s `Create` and `ApplyTransition`
were never updated to populate it on new inserts — every existing row
had a value from the backfill, so this was invisible until the first
rule created *after* that migration ran, which nothing in Phase 4's own
verification happened to do (Phase 4's Docker access was lost partway
through, per its own runbook's disclosed gap). Fixed: `Create`'s
`alert_state` insert now passes `r.TenantID` explicitly;
`ApplyTransition`'s `delivery_log` insert resolves it via `(SELECT
tenant_id FROM alert_rules WHERE id = $1)` since that function only
receives a rule ID, not a full `Rule`. Confirmed fixed against the live
stack: rule creation, evaluation, firing, and a real delivery attempt
(to a fake Slack webhook URL — a real HTTP 404 back, logged in
`delivery_log`, which is itself the intended behavior for an
unreachable target) all completed end to end.
This means **every core-mode alert rule created against a Phase-4- or
Phase-5-migrated database before this fix was silently broken** — worth
knowing if debugging an environment provisioned between those two
points.
## 5. Accessibility sweep
Automated: axe-core, injected into the live app via a temporary
`web/static/axe-test-temp.js` script tag (removed before commit — do
not check it in; recreate from `node_modules/axe-core/axe.min.js` if you
need to re-run this). Checked every route, including the ones that only
render meaningfully with real backend data: `/`, `/dashboards`,
`/dashboards/[id]` (with all 6 panel types populated), `/alerts`,
`/alerts/[id]` (with a real firing rule and delivery history),
`/alerts/new`, `/settings`, `/data-sources`, `/select-tenant`,
`/dev/charts`. Real findings and fixes are cataloged in
`/docs/design-system.md`'s Accessibility section — don't duplicate that
list here; the summary is zero outstanding violations across all of the
above, in both themes, confirmed by re-running axe after each fix
rather than assuming a fix worked.
Manual: keyboard-only tab-order walk through the sidebar, command
palette, query editor, and results table (see the design-system doc's
Accessibility section for the one real gap this caught that axe
structurally can't — a custom interactive element with no keyboard
handler at all doesn't trip any axe rule, since axe checks markup/ARIA
correctness, not "does this thing respond to Enter").
## 6. What's not verified
Same category of gap as every prior phase's disclosed limitations, not
a new one: real external IdP login, a real multi-container Kubernetes
deployment, and load-testing the chart layer against ClickHouse-scale
(millions-of-rows) result sets rather than the 30K-row synthetic
`/dev/charts` stress fixture — that fixture validates client-side
render performance, not query performance at that scale, which is
already covered by Phase 2's own benchmark.