Console Dashboard V2 — Option B Split-View Design
Status: In design — sub-cards filed 2026-05-16 UTC
Parent epic: #146 (raxx-console operator admin console)
Design PR: Refs #146
ADR: 0096
Source mockup: docs/design/console-redesign-2026-05-16/mockups/option-b.html
Operator decision: Kristerpher selected Option B on 2026-05-16 UTC (PR #2264)
1. Context
The current /dashboard renders a full-width flip-card grid (4-up on desktop). Each
tile is a roughly 4×6 card that carries status badge, build icon, liveness summary,
and a configurable back face (FLAG_CONSOLE_STATUS_CONFIG). A separate bottom tile-drawer
(P5 epic, FLAG_CONSOLE_TILE_DRAWER) opens below the grid when a tile is clicked.
Problems with the current model:
- The flip-card 3D transform (FLAG_CONSOLE_STATUS_CONFIG) creates z-index and perspective bugs that required two emergency patches (PR #1152, PR #2221).
- The hover-popover collides with the flip animation — the popover was already disabled by flag.
- The bottom drawer competes for vertical space with the activity feed and flag-drift widget; on 13" laptops it pushes audit content off screen.
- Navigating to the per-surface detail page (
/dashboard/sites/<id>) loses the at-a-glance grid context.
Option B resolves all of these with a persistent 60/40 split: a compact tile list on the left and a sticky side panel on the right. No flip, no hover popover, no bottom drawer. Click a tile to pin its details to the panel; click another to swap.
2. Invariants
All platform invariants apply. Dashboard-specific constraints carried forward:
- No secret values in API responses. The side panel may link to
/dashboard/secretsfor a surface's rotation history, but must not inline any secret value or rotation credential. - Every force-poll action writes an audit row (
action = "force_poll"). - Every per-site flag toggle writes an audit row (
action = "site_flag.toggle"). - Vendor API failures must not blank the grid. A failing probe degrades one tile; all others render normally.
- Audit trail: any action that writes state (force-poll, flag toggle, public note)
is audited via
write_audit()before the response is returned. - RBAC: read-only and support roles see the panel in read mode. Ops and superadmin see write controls (force-poll, flag toggles). Superadmin-only actions are gated server-side; the UI hides them via role context var.
3. Layout
3.1 Two-Column Shell
CSS Grid (not Flexbox) with two named areas: tiles and panel.
.dashboard-split {
display: grid;
grid-template-columns: 3fr 2fr; /* 60% / 40% */
grid-template-areas: "tiles panel";
gap: 1.5rem;
min-height: 0; /* allows children to scroll independently */
}
The panel column is position: sticky; top: var(--nav-height); height: calc(100vh - var(--nav-height) - 1rem); overflow-y: auto; so it scrolls independently from the tile list.
The tiles column scrolls with the page (natural document flow).
3.2 Viewport behaviour
| Viewport | Behaviour |
|---|---|
| >= 1024px | Full 60/40 split as designed |
| 768–1023px | 50/50 split (1fr 1fr); panel still sticky |
| < 768px | Stack vertically: tiles on top, panel below; panel loses sticky |
Mobile fallback is explicit stack — no hide/collapse. The side panel is still accessible on mobile; it just scrolls with the page below the tile list.
This is intentional: the console is an operator tool. Mobile is a "read status" use-case, not a write use-case. The stack layout gives full information without a hamburger or tab-switch.
4. Tile Compaction
4.1 One-line tile anatomy
Each tile is a single row (height ~40px on desktop):
[ STATUS_DOT ] [ HOSTNAME (mono, truncate) ] [ BUILD_ICON ] ........... [ ACTION_BTN ]
- STATUS_DOT: 8px filled circle, same color classes as current grid (green/amber/red/slate).
- HOSTNAME:
font-mono text-xs, truncated with ellipsis. Full hostname intitleattr. - BUILD_ICON: 12px SVG icon (reuse existing build-icon CSS classes). Hidden when no build data.
- Right-aligned single ACTION_BTN: one button per tile, always visible.
4.2 Single tile action (always-visible)
The tile carries exactly one action button, chosen by priority:
| Surface state | Button label | Endpoint |
|---|---|---|
| DEGRADED or DOWN | Investigate |
POST /dashboard/_force_poll then opens side panel |
| Build in progress | Deploy |
opens deploy modal (existing deploy_btn flow) |
| Deploy frozen | Frozen |
disabled; links to /deploy-freeze |
| All other HEALTHY | Detail |
pins side panel to this surface |
Investigate = force-poll + pin. Detail = pin only.
Sparklines: removed from the one-line tile entirely. They belong in the side panel's
telemetry section (see §5). The data-* attributes on the tile div remain — they are
consumed by the side panel JS, not rendered inline.
4.3 Tile sort order
Unchanged from current: DEGRADED → FAILED/DOWN → UNKNOWN/STALE → HEALTHY.
The HTMX 30s partial swap (hx-get, hx-swap="outerHTML") is preserved.
5. Side Panel Content
The side panel renders in response to a tile pin event. Before any tile is selected, it shows a "Select a surface" empty state.
5.1 Panel sections (top to bottom)
┌─────────────────────────────────────┐
│ SURFACE HEADER │ hostname + status badge + provider·env
│ [ Force-poll btn ] [ Detail link ] │ ops-only write controls; readonly sees Detail only
├─────────────────────────────────────┤
│ LIVENESS │ HTTP code · latency · checked_at (relative)
├─────────────────────────────────────┤
│ LAST DEPLOY │ deployed_at · author · build conclusion
│ BUILD STRIP │ in-flight progress bar (existing CSS classes)
├─────────────────────────────────────┤
│ 48H SPARKLINE │ 48-bucket availability SVG (lazy fetch on pin)
│ │ fetched from /dashboard/api/sites/<id>/probe-history
├─────────────────────────────────────┤
│ SITE FLAGS │ list of ConsoleSiteFlag rows; toggle ops+
├─────────────────────────────────────┤
│ SECRETS POINTER │ link to /secrets filtered to this surface
├─────────────────────────────────────┤
│ AUDIT TRAIL (last 5) │ recent audit_log rows for this surface
│ │ fetched from /dashboard/api/sites/<id>/audit
└─────────────────────────────────────┘
5.2 New endpoint required
GET /dashboard/api/sites/<id>/audit?limit=5 — returns the 5 most recent
audit_log rows where resource_id = site_id. JSON only. Ops+ required.
Read-only role sees the section but with a "ops role required" placeholder.
Existing endpoints reused without change:
- GET /dashboard/api/sites/<id>/probe-history — sparkline data (already exists)
- POST /dashboard/_force_poll — force-poll (already exists, ops-gated)
- POST /dashboard/sites/<id>/flags/<name> — site flag toggle (already exists)
6. Side Panel State Machine
stateDiagram-v2
[*] --> Empty : page load
Empty --> Pinned : click any tile
Pinned --> Pinned : click different tile (swap content, no animation)
Pinned --> Empty : ESC key or explicit clear
Pinned --> Pinned : HTMX 30s grid refresh (panel content re-fetches probe data)
6.1 State persistence
The pinned surface ID is stored in sessionStorage as dashboard_pinned_surface.
On page reload, if the stored ID is still in the grid, the panel re-pins to it
automatically. If the surface is no longer in the grid (env switch, surface removed),
the panel reverts to Empty.
URL state (?surface=api-prod) is NOT used. Reason: the dashboard URL is already
used for HTMX partial targets; adding query params would require careful coordination
with the HTMX swap. sessionStorage is simpler and sufficient for the
single-operator console use case. See ADR 0096 for the decision.
6.2 Swap behaviour
No animation on swap. The panel content is replaced synchronously on click. The
spec explicitly forbids flip animation and the Option B mockup shows a clean swap.
prefers-reduced-motion has no additional effect here (no animation to suppress).
6.3 HTMX grid refresh interaction
The HTMX 30s swap replaces #status-grid outerHTML. The side panel is NOT inside
#status-grid, so it survives the swap. After each swap, the JS re-reads the
updated data-* attributes from the newly-rendered tile for the pinned surface and
re-populates the non-sparkline sections of the panel. The sparkline is NOT
re-fetched on every grid swap (it was lazy-fetched on pin; it refreshes on
explicit re-pin or manual force-poll).
7. Action Button Disposition
| Current action | Option B location | Notes |
|---|---|---|
| Deploy button (tile) | Tile ACTION_BTN (when build in progress or default HEALTHY) | Unchanged trigger; opens existing deploy modal |
| Investigate button (tile) | Tile ACTION_BTN (DEGRADED/DOWN only) | Force-poll + pin |
| Flip config (tile back face) | Removed | Replaced by site flags section in panel |
| Hover popover | Removed | Was already flag-disabled |
| Re-check (drawer) | Panel header force-poll button | Ops+ only; same endpoint |
| View logs link (drawer) | Panel "Detail" link → /dashboard/sites/<id> |
Full detail page preserved |
| Vault rotate link (drawer) | Panel secrets pointer section | Link to /secrets |
| Public note (drawer) | Deferred to post-V2 iteration | Not in Option B mockup |
The per-surface detail page (/dashboard/sites/<id>) is PRESERVED. The side panel
is a quick-access surface; the full detail page remains for deep drill-down. The
panel's "Detail" link navigates there.
8. Feature Flag Strategy
Flag key: console_dashboard_v2
Env var: FLAG_CONSOLE_DASHBOARD_V2=1
Default: false
When OFF: existing flip-card grid + tile-drawer renders (current behaviour). When ON: Option B split-view renders. The old grid template, flip CSS, hover-popover CSS, and tile-drawer JS are not loaded at all (not just hidden — the Jinja branches exclude them from the rendered HTML).
Hard cut is not used at launch. The flag allows the operator to A/B between old and new during the initial rollout period. Once Option B is confirmed stable, a follow-up PR removes the old code paths and the flag (see §7 of the sub-card list, SC-DB-6).
The flag is surface: console, risk: high (console-facing). The B1 migration row
must be in the same PR as the YAML entry (per feedback_new_flag_needs_b1_migration_same_pr).
Flag interaction matrix
| Flag | Interaction |
|---|---|
FLAG_CONSOLE_DASHBOARD_V2=1 |
Enables split view; disables flip-card grid, tile-drawer, hover-popover |
FLAG_CONSOLE_DASHBOARD_HOME |
Still required; when off, placeholder "Welcome." renders regardless of V2 flag |
FLAG_CONSOLE_DEPLOY_UI |
Deploy button on tile still gated by this flag |
FLAG_CONSOLE_STATUS_CONFIG |
Ignored when V2 is on (flip-card is removed) |
FLAG_CONSOLE_TILE_DRAWER |
Ignored when V2 is on (drawer is replaced by side panel) |
FLAG_CONSOLE_INVESTIGATE_FROM_STATUS |
Side panel force-poll button replaces this; flag is deprecated when V2 is GA |
FLAG_CONSOLE_TILE_HOVER_SPARKLINE |
Ignored when V2 is on (sparkline lives in panel, not tile hover) |
9. Deletions
When FLAG_CONSOLE_DASHBOARD_V2 is ON, the following are excluded from rendering
(not deleted from the codebase until SC-DB-6, the cleanup PR):
CSS files / classes:
- Flip-card 3D: .flip-card-outer, .flip-card-inner, transform-style:preserve-3d
transition:transform 0.5s, .is-flipped — entire CSS class group
- Hover popover: .tile-popover, .tile-popover-wrap, .pop-header, .pop-body,
.pop-sparkline-wrap, .pop-sparkline-loading — entire surface-tray.css and
the inline styles in _status_grid.html
- Tile-drawer bottom panel: tile-drawer.css, tile-drawer.open, .pinned-drawer,
.tile-hovered halo styles
JS files:
- tile-drawer.js — open/close/sparkline state machine for bottom drawer
- tile-drawer-a11y.js — a11y layer for bottom drawer
- drawer-columns-783.js — drawer column population
- drawer-actions.js — drawer action button wiring
- p5_drawer.js — P5 hover/pin state machine for bottom drawer
Templates:
- dashboard/_tile_drawer.html — bottom drawer shell
- Flip-card Jinja branches in _status_grid.html (the {% if status_config_enabled %} blocks)
- Hover-popover Jinja block (the <div class="tile-popover-wrap"> and all children)
Preserved in all cases:
- Server-side probe data and SiteStatus shape
- site_probes.py, status_poller.py, background polling thread
- All /dashboard/api/sites/<id>/* JSON endpoints
- Per-surface detail page (/dashboard/sites/<id>)
- Public note POST endpoint (moved to panel in V2; preserved for backward-compat)
- Audit log writes
- Deploy modal and deploy_btn flow
- Build-strip CSS (build-strip.css) — reused on compact tiles
10. Migrations
No database schema changes. The only persistence change is:
sessionStorage.dashboard_pinned_surface— client-side only. No migration needed.- A B1 migration row for
console_dashboard_v2inconsole_flag_promotions(same PR as the YAML entry, per feedback constraint).
11. Rollout Plan
| Phase | Condition | Action |
|---|---|---|
| Dark | FLAG_CONSOLE_DASHBOARD_V2=0 (default) |
Old grid live; V2 code ships but is not rendered |
| Beta | FLAG_CONSOLE_DASHBOARD_V2=1 on staging |
Operator verifies V2 on staging console |
| GA | FLAG_CONSOLE_DASHBOARD_V2=1 on prod |
V2 is the live dashboard |
| Cleanup | Post-GA soak >= 2 weeks | SC-DB-6: delete old flip-card code + flag entry |
12. Security Considerations
- No new PII collected. The dashboard surfaces operational metadata (hostnames, deploy timestamps, HTTP codes, latency). None of this is PII.
- The new audit trail section in the side panel fetches
audit_logrows. Access is read-only, ops+ gated. No PII is exposed (audit rows contain resource IDs and action names, not user data rows). Consistent with the full-visibility posture locked 2026-05-12 (project_audit_log_full_visibility.md). - The new endpoint
GET /dashboard/api/sites/<id>/auditmust be decorated@require_role("ops"). The feature-developer must not mark it readonly-accessible. - Force-poll writes an audit row before returning. This is the same invariant as the existing force-poll endpoint — preserved.
- No credentials are surfaced. The secrets pointer section links to
/secrets; it does not inline secret metadata, values, or rotation history inline in the panel. - Session storage key
dashboard_pinned_surfacecontains only a surface ID string (e.g."api-prod"). No PII. No credential. No risk from XSS read (it is not sensitive). No retention concern.
13. Open Questions
None blocking sub-card dispatch. The following are deferred decisions:
- Public note in panel: The public-note POST endpoint is preserved but not wired into Option B's panel UI. A future sub-card can add it. Not blocking GA.
- Panel audit section for read-only role: Current design shows a "ops role required" placeholder. If the operator wants read-only to see audit, that is a policy change requiring a separate decision (see project_audit_log_full_visibility.md).
<768pxpanel visibility: The stacked layout shows the panel below tiles. If the operator determines mobile use is frequent enough to warrant a tab-switch pattern, that is a post-launch UX iteration.