Raxx · internal docs

internal · gated

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:

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:

  1. No secret values in API responses. The side panel may link to /dashboard/secrets for a surface's rotation history, but must not inline any secret value or rotation credential.
  2. Every force-poll action writes an audit row (action = "force_poll").
  3. Every per-site flag toggle writes an audit row (action = "site_flag.toggle").
  4. Vendor API failures must not blank the grid. A failing probe degrades one tile; all others render normally.
  5. Audit trail: any action that writes state (force-poll, flag toggle, public note) is audited via write_audit() before the response is returned.
  6. 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 ]

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:


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


13. Open Questions

None blocking sub-card dispatch. The following are deferred decisions:

  1. 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.
  2. 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).
  3. <768px panel 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.