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:
@@ -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,
|
someone with database superuser access; that's an operational control,
|
||||||
out of scope here and named explicitly, not silently assumed away.
|
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
|
## When in doubt
|
||||||
Ask before: changing the pinned stack, adding a new external dependency
|
Ask before: changing the pinned stack, adding a new external dependency
|
||||||
that pulls in a large transitive tree, or making an architectural decision
|
that pulls in a large transitive tree, or making an architectural decision
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user