From 93a8d03a1a6aeb55b760988e6fd2223f6b5f5837 Mon Sep 17 00:00:00 2001
From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com>
Date: Wed, 29 Apr 2026 20:37:48 +0000
Subject: [PATCH 1/4] feat(dashboard): render `dashboard view` with OpenTUI on
the Bun binary
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replaces the framebuffer + grid composition logic in
`formatters/dashboard.ts` with an OpenTUI React app for the
Bun-compiled binary. The plain-text framebuffer is preserved
intact for the npm/Node distribution (where OpenTUI's Zig
bindings can't load), so existing tests + Node users see no
behavior change.
## What changed
- `src/lib/formatters/dashboard-app.tsx` (new) — React tree built
from OpenTUI primitives (`` / ``). Lays out the
dashboard header, then groups widgets by their grid `y` row
and renders each row as a `flexDirection="row"` flex container
with proportional widget widths (`(layout.w / 6) * termWidth`).
Each widget is a rounded bordered box with the title in the
border and content rendered via OpenTUI's flex layout — no more
manual framebuffer composition.
- `src/lib/formatters/dashboard-tui.ts` (new) — Bridge between
the React tree and a string. Uses OpenTUI's
`createTestRenderer()` (from `@opentui/core/testing`) to mount
the React tree off-screen, awaits a microtask for the
reconciler's async commit phase, calls `renderOnce()`, and
captures the rendered character grid via `captureCharFrame()`.
Trailing blank lines are stripped so the output doesn't pad
scrollback.
- `src/lib/formatters/dashboard.ts` — minor changes:
- `DashboardViewData` gains an optional `rendered?: string`
field (excluded from JSON output via `jsonExclude`) for
stashing the OpenTUI-rendered string.
- `renderContentLines()` is now exported so the OpenTUI app
can reuse the per-widget content helpers (sparklines,
big-number ASCII, table layout, markdown text) without
duplicating them. ANSI escape codes from these helpers are
stripped by the OpenTUI app before rendering — colors get
reapplied via OpenTUI's `fg` prop at the row level.
- `createDashboardViewRenderer` checks `data.rendered` first
and uses it directly; otherwise falls through to the
original `formatDashboardWithData` framebuffer path.
- `src/commands/dashboard/view.ts` — pre-renders the dashboard
with OpenTUI inside the async `func()` (before yielding the
`CommandOutput`) so the synchronous `HumanRenderer.render()`
can just return the pre-baked string. Lazy-imports
`dashboard-tui.js` via dynamic `import()` so its module-level
`with { type: "file" }` resolution + heavy OpenTUI deps don't
load when this command isn't being run — important because
tests walk the Stricli route map (via `app.ts`) and would
otherwise eagerly evaluate the OpenTUI side effects.
Falls back to the plain-text formatter on import or render
failure (Node distribution).
- `script/build.ts` + `script/bundle.ts` — extended sidecar
cleanup to include `dashboard-app.tsx` alongside the existing
`opentui-app.tsx`. The text-import-plugin copies both files
into the output directory at build time; both are embedded
into the Bun binary and unused on Node, so the local
`dist*/` cleanup just keeps things tidy.
## Trade-offs
The OpenTUI `` primitive doesn't honor embedded ANSI
escape codes — its content string is plain text. The plain-text
renderer's per-row mixed coloring (e.g. cyan label + magenta
sparkline + bold value on the same line) collapses to one
dominant color per row in the OpenTUI version. Tables get bold
headers + muted separators + plain body rows; sparkline rows
get accent purple; big-number rows get green. Loses some
granularity on tables/sparklines but the layout — which is what
OpenTUI is buying us — comes out clean.
The grid composition is approximate. The plain-text framebuffer
does true 2D composition where a tall widget can span multiple
row groups. The OpenTUI app groups widgets by their starting
`y` and renders each row group flush — widgets with non-uniform
`h` within a row render at their own height but don't overlap
the next row. Most dashboards we've seen are uniform-height per
row, so the approximation lands clean.
## Verification
- `bun run typecheck` (clean)
- `bun x ultracite check` (1 pre-existing warning, no new ones)
- `bun test --isolate test/lib/init/ test/lib/formatters/dashboard*.test.ts test/commands/dashboard` (458 pass)
- `SENTRY_CLIENT_ID=test bun run build` (binary 118.29 MB,
unchanged from before)
- `SENTRY_CLIENT_ID=test bun run bundle` (npm 3.21 MB,
unchanged)
- `./dist-bin/sentry-linux-x64 dashboard view --help` (renders
cleanly)
- Visual smoke test rendering a 3-widget dashboard confirms
bordered widget boxes, vertical bar chart, big-number, and
table all lay out correctly with OpenTUI's flex engine.
## Tests added
- `test/lib/formatters/dashboard-tui.test.ts` — 10 coarse
end-to-end tests asserting the renderer produces a
non-empty string with the dashboard title, period badge,
environment badge, widget titles, box-drawing characters,
trailing-blank-line trimming, and graceful handling of
empty widget lists, orphan widgets, and error widgets.
---
script/build.ts | 8 +-
script/bundle.ts | 29 +-
src/commands/dashboard/view.ts | 85 ++++-
src/lib/formatters/dashboard-app.tsx | 373 ++++++++++++++++++++++
src/lib/formatters/dashboard-tui.ts | 202 ++++++++++++
src/lib/formatters/dashboard.ts | 31 +-
test/lib/formatters/dashboard-tui.test.ts | 152 +++++++++
7 files changed, 855 insertions(+), 25 deletions(-)
create mode 100644 src/lib/formatters/dashboard-app.tsx
create mode 100644 src/lib/formatters/dashboard-tui.ts
create mode 100644 test/lib/formatters/dashboard-tui.test.ts
diff --git a/script/build.ts b/script/build.ts
index d1ad7532e..42fdee0b6 100644
--- a/script/build.ts
+++ b/script/build.ts
@@ -508,11 +508,11 @@ async function build(): Promise {
await uploadSourcemapToSentry();
// Clean up intermediate bundle (only the binaries are artifacts).
- // The `opentui-app.tsx` copy comes from the text-import-plugin's
- // `with { type: "file" }` handling — it gets embedded into the
- // compiled binary, so the sidecar copy is no longer needed once
+ // The `*-app.tsx` copies come from the text-import-plugin's
+ // `with { type: "file" }` handling — they get embedded into the
+ // compiled binary, so the sidecar copies aren't needed once
// every target has compiled.
- await $`rm -f ${BUNDLE_JS} ${SOURCEMAP_FILE} dist-bin/opentui-app.tsx`;
+ await $`rm -f ${BUNDLE_JS} ${SOURCEMAP_FILE} dist-bin/opentui-app.tsx dist-bin/dashboard-app.tsx`;
// Summary
console.log(`\n${"=".repeat(40)}`);
diff --git a/script/bundle.ts b/script/bundle.ts
index c88eaba69..39eeb2db4 100644
--- a/script/bundle.ts
+++ b/script/bundle.ts
@@ -293,17 +293,24 @@ await Bun.write("./dist/index.d.cts", TYPE_DECLARATIONS);
console.log(" -> dist/bin.cjs (CLI wrapper)");
console.log(" -> dist/index.d.cts (type declarations)");
-// Clean up the `opentui-app.tsx` sidecar that the text-import-plugin
-// drops into `dist/` when it sees the `with { type: "file" }` import
-// in `src/lib/init/ui/opentui-ui.ts`. The npm distribution doesn't
-// run the OpenTuiUI factory at all (it's gated to the Bun binary),
-// so the sidecar is unused — and it's not in `package.json#files`
-// either, so it wouldn't ship even without this cleanup. Removing
-// it just keeps the local `dist/` directory tidy.
-try {
- await unlink("./dist/opentui-app.tsx");
-} catch {
- // Sidecar may not exist (e.g. plugin path not exercised) — fine.
+// Clean up the `*-app.tsx` sidecars that the text-import-plugin
+// drops into `dist/` when it sees the `with { type: "file" }`
+// imports in `src/lib/init/ui/opentui-ui.ts` (wizard) and
+// `src/lib/formatters/dashboard-tui.ts` (dashboard view). The npm
+// distribution gates both factories to the Bun binary, so the
+// sidecars are unused at runtime — and they're not in
+// `package.json#files` either, so they wouldn't ship even without
+// this cleanup. Removing them just keeps the local `dist/`
+// directory tidy.
+for (const sidecar of [
+ "./dist/opentui-app.tsx",
+ "./dist/dashboard-app.tsx",
+]) {
+ try {
+ await unlink(sidecar);
+ } catch {
+ // Sidecar may not exist (e.g. plugin path not exercised) — fine.
+ }
}
// Calculate bundle size (only the main bundle, not source maps)
diff --git a/src/commands/dashboard/view.ts b/src/commands/dashboard/view.ts
index 41a9383c3..9a2588a62 100644
--- a/src/commands/dashboard/view.ts
+++ b/src/commands/dashboard/view.ts
@@ -97,6 +97,11 @@ function abortableSleep(ms: number, signal: AbortSignal): Promise {
/**
* Build the DashboardViewData from a dashboard and its widget query results.
+ *
+ * The returned object is also passed through {@link tryPreRenderTui}
+ * before being yielded so the OpenTUI string lives on the data
+ * itself — keeps the human renderer synchronous while letting us
+ * await the async OpenTUI rendering in the command body.
*/
function buildViewData(
dashboard: {
@@ -134,6 +139,53 @@ function buildViewData(
};
}
+/**
+ * Try to pre-render the dashboard with OpenTUI. Returns the
+ * passed-in data with `rendered` populated on success; returns
+ * the original data unchanged on failure (e.g. on the npm/Node
+ * distribution where OpenTUI's native bindings can't load).
+ *
+ * Skipped entirely in JSON mode — JSON output uses the raw data
+ * shape, so there's no point spending the render cycles.
+ *
+ * **Lazy import.** `dashboard-tui.js` is loaded via dynamic
+ * `await import()` (rather than a top-level `import` statement)
+ * so its module-level `with { type: "file" }` resolution and
+ * heavy OpenTUI dependencies never load when this command isn't
+ * the one being run. Tests that walk the Stricli route map (via
+ * `app.ts`) would otherwise eagerly evaluate the OpenTUI side
+ * effects and fail with module-cache-collision errors the same
+ * way the wizard's `OpenTuiUI` did before its `?bridge=1` fix.
+ */
+async function tryPreRenderTui(
+ data: DashboardViewData,
+ flags: ViewFlags
+): Promise {
+ if (flags.json) {
+ return data;
+ }
+ try {
+ const { renderDashboardTui } = await import(
+ "../../lib/formatters/dashboard-tui.js"
+ );
+ const rendered = await renderDashboardTui(data);
+ return { ...data, rendered };
+ } catch (err) {
+ // Fall back to the plain-text formatter. The human renderer
+ // checks `data.rendered === undefined` and uses
+ // `formatDashboardWithData` in that case. We log at debug
+ // level so a missing-binding diagnosis is recoverable; we
+ // don't surface to the user because the fallback is fully
+ // functional.
+ logger.debug(
+ `OpenTUI dashboard render unavailable, using plain-text fallback: ${
+ err instanceof Error ? err.message : String(err)
+ }`
+ );
+ return data;
+ }
+}
+
/**
* Resolve the effective time range for a dashboard view.
*
@@ -172,6 +224,11 @@ export const viewCommand = buildCommand({
},
output: {
human: createDashboardViewRenderer,
+ // `rendered` is the pre-baked OpenTUI ANSI string that the
+ // human renderer prints directly. Strip it from JSON output —
+ // machine consumers want the structured widget data, not a
+ // pre-formatted screen capture.
+ jsonExclude: ["rendered"],
},
parameters: {
positional: {
@@ -277,11 +334,18 @@ export const viewCommand = buildCommand({
{ ...widgetTimeOpts, periodSeconds }
);
- // Build output data before clearing so clear→render is instantaneous
- const viewData = buildViewData(dashboard, widgetData, widgets, {
- period: formatTimeRangeFlag(timeRange),
- url,
- });
+ // Build output data before clearing so clear→render is
+ // instantaneous. `tryPreRenderTui` runs the OpenTUI
+ // pipeline (Bun binary) or short-circuits to the
+ // plain-text fallback (Node) — either way we yield a
+ // `CommandOutput` carrying ready-to-print state.
+ const viewData = await tryPreRenderTui(
+ buildViewData(dashboard, widgetData, widgets, {
+ period: formatTimeRangeFlag(timeRange),
+ url,
+ }),
+ flags
+ );
if (!isFirstRender) {
yield new ClearScreen();
@@ -309,10 +373,13 @@ export const viewCommand = buildCommand({
);
yield new CommandOutput(
- buildViewData(dashboard, widgetData, widgets, {
- period: formatTimeRangeFlag(timeRange),
- url,
- })
+ await tryPreRenderTui(
+ buildViewData(dashboard, widgetData, widgets, {
+ period: formatTimeRangeFlag(timeRange),
+ url,
+ }),
+ flags
+ )
);
return { hint: `Dashboard: ${url}` };
},
diff --git a/src/lib/formatters/dashboard-app.tsx b/src/lib/formatters/dashboard-app.tsx
new file mode 100644
index 000000000..798c0f2e3
--- /dev/null
+++ b/src/lib/formatters/dashboard-app.tsx
@@ -0,0 +1,373 @@
+/**
+ * Dashboard view — OpenTUI React App
+ *
+ * Renders a `DashboardViewData` snapshot as a React tree built from
+ * OpenTUI primitives. Used by `dashboard view` on the Bun-compiled
+ * binary; the npm/Node distribution can't load OpenTUI's Zig
+ * bindings so it falls back to the plain-text renderer in
+ * `dashboard.ts` (the same one that's been there pre-TUI).
+ *
+ * Layout strategy:
+ *
+ * The Sentry dashboard grid is 6 columns wide with widgets at
+ * `(x, y, w, h)` positions in grid units. We approximate it with
+ * nested flex boxes:
+ *
+ * - Outer column (`flexDirection="column"`)
+ * - One row group per distinct `y`
+ * - Inner row (`flexDirection="row"`)
+ * - Widgets sorted by `x`, each sized to
+ * `(w / 6) * terminal_width`
+ *
+ * This handles the common case where all widgets in a row share
+ * the same `y` and `h`. Widgets with a `y` that doesn't match
+ * any other widget render at their own height in their own row
+ * group — visually they don't overlap with the next row group
+ * the way the plain-text framebuffer's true 2D composition does.
+ * The trade-off: simpler code, slightly different layout for
+ * dashboards that depend on a tall widget spanning multiple row
+ * groups. Most dashboards we've seen are uniform-height per row,
+ * so the approximation lands clean.
+ *
+ * Per-widget content reuses `renderContentLines()` from
+ * `dashboard.ts` — the same helper the plain-text renderer uses,
+ * which already handles sparklines, big-number ASCII fonts,
+ * tables, and markdown text. OpenTUI's `` strips ANSI from
+ * its content string, so we `stripAnsi()` the helper output before
+ * dropping it into the React tree and apply colors via the `fg`
+ * prop at the per-row level. Colors get less granular (one per
+ * row instead of per-segment) but the layout — which is what
+ * OpenTUI is buying us — comes out right.
+ */
+
+import {
+ type DashboardViewData,
+ type DashboardViewWidget,
+ renderContentLines,
+} from "./dashboard.js";
+import { stripAnsi } from "./plain-detect.js";
+
+// ────────────────────────── Visual constants ─────────────────────────
+
+/** Sentry brand purple — matches the wizard. */
+const ACCENT = "#A77DC3";
+/** Muted gray for borders + secondary text. */
+const MUTED = "#6E6C7E";
+/** Foreground/body text. */
+const FOREGROUND = "#E8E6F0";
+/** Cyan for series labels and badges. */
+const CYAN = "#7DD3FC";
+/** Green for big numbers, success states, and bar fills. */
+const GREEN = "#86EFAC";
+/** Yellow for environment badges. */
+const YELLOW = "#FBBF24";
+/** Red for errors. */
+const ERROR = "#F87171";
+
+/** Sentry dashboard grid columns — must stay in sync with the API. */
+const GRID_COLS = 6;
+
+/** Terminal lines per grid height unit (matches the Sentry web grid). */
+const LINES_PER_UNIT = 6;
+
+/** Bold attribute bit for OpenTUI's `attributes` prop. */
+const BOLD = 1;
+
+// ────────────────────────────── App entry ────────────────────────────
+
+export type AppProps = {
+ data: DashboardViewData;
+ /** Total terminal width to lay out within. */
+ termWidth: number;
+};
+
+/**
+ * Root component. Renders the dashboard header, then the widget
+ * grid stacked underneath.
+ */
+export function App({ data, termWidth }: AppProps): React.ReactNode {
+ return (
+
+
+
+
+ );
+}
+
+// ─────────────────────────────── Header ──────────────────────────────
+
+/**
+ * Compact dashboard header: bold title, cyan period badge, optional
+ * yellow environment badge, then a muted underline rule the full
+ * width of the terminal.
+ */
+function Header({
+ data,
+ termWidth,
+}: {
+ data: DashboardViewData;
+ termWidth: number;
+}): React.ReactNode {
+ const hasEnv = Boolean(data.environment?.length);
+ const envText = hasEnv ? `env: ${data.environment?.join(", ") ?? ""}` : "";
+ return (
+
+
+
+ {data.title}
+
+ {" "}
+ {`[${data.period}]`}
+ {hasEnv ? (
+ <>
+ {" "}
+ {envText}
+ >
+ ) : null}
+
+ {"─".repeat(termWidth)}
+
+ );
+}
+
+// ──────────────────────────── Widget grid ────────────────────────────
+
+/**
+ * Group widgets by their grid `y` row, sort each row by `x`, and
+ * render the rows top-to-bottom. Widgets without a `layout` are
+ * appended at the end as full-width rows — covers older
+ * dashboards that pre-date Sentry's grid layout.
+ */
+function WidgetGrid({
+ widgets,
+ termWidth,
+}: {
+ widgets: DashboardViewWidget[];
+ termWidth: number;
+}): React.ReactNode {
+ // Bucket widgets by their starting y position.
+ const rows = new Map();
+ const orphans: DashboardViewWidget[] = [];
+
+ for (const widget of widgets) {
+ if (widget.layout) {
+ const key = widget.layout.y;
+ const bucket = rows.get(key);
+ if (bucket) {
+ bucket.push(widget);
+ } else {
+ rows.set(key, [widget]);
+ }
+ } else {
+ orphans.push(widget);
+ }
+ }
+
+ // Sort row keys ascending; within a row, widgets sort by x.
+ const sortedRowKeys = [...rows.keys()].sort((a, b) => a - b);
+
+ return (
+
+ {sortedRowKeys.map((y) => {
+ const rowWidgets = (rows.get(y) ?? []).sort(
+ (a, b) => (a.layout?.x ?? 0) - (b.layout?.x ?? 0)
+ );
+ return (
+
+ );
+ })}
+ {orphans.map((widget, i) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * One row group (all widgets sharing a `y` start position) laid
+ * out side-by-side with proportional widths. `marginBottom={1}`
+ * leaves a one-row gap before the next row group so adjacent
+ * widget borders don't fuse together.
+ */
+function WidgetRow({
+ widgets,
+ termWidth,
+}: {
+ widgets: DashboardViewWidget[];
+ termWidth: number;
+}): React.ReactNode {
+ return (
+
+ {widgets.map((widget) => {
+ const w = widget.layout?.w ?? GRID_COLS;
+ const widgetWidth = Math.floor((w / GRID_COLS) * termWidth);
+ return (
+
+ );
+ })}
+
+ );
+}
+
+// ────────────────────────────── Widget ───────────────────────────────
+
+/**
+ * One widget rendered inside a rounded bordered box. The border's
+ * `title` prop carries the widget title (Ink-style). Content height
+ * is `layout.h * LINES_PER_UNIT` to mirror the Sentry web grid's
+ * vertical units.
+ */
+function Widget({
+ widget,
+ width,
+}: {
+ widget: DashboardViewWidget;
+ width: number;
+}): React.ReactNode {
+ const layoutH = widget.layout?.h ?? 1;
+ const totalHeight = layoutH * LINES_PER_UNIT;
+ // Border accounts for 2 rows; padding of 1 cell on each side
+ // matches the visual breathing room the plain-text border
+ // wrapper provides.
+ const innerWidth = Math.max(0, width - 4);
+ const contentHeight = Math.max(0, totalHeight - 2);
+
+ // Get the per-widget content lines from the shared helper. ANSI
+ // is stripped because OpenTUI's `` doesn't honor embedded
+ // escape codes. Colors come back via the `fg` prop on the row's
+ // wrapper component.
+ const rawLines = renderContentLines({
+ widget,
+ innerWidth,
+ contentHeight,
+ });
+ const lines = rawLines.map(stripAnsi);
+
+ return (
+
+
+
+ );
+}
+
+/**
+ * Render the per-widget content lines with colors chosen based on
+ * the widget's data type:
+ *
+ * - `timeseries` / `table` / `text`: foreground for body, with
+ * the first row of tables drawn bold (the header) and the
+ * second row muted (the separator rule).
+ * - `scalar`: green throughout (matches the chalk-styled output
+ * of the plain-text renderer's big-number font).
+ * - `error`: every row red.
+ * - `unsupported`: every row muted.
+ *
+ * The plain-text renderer can color individual cells within a row
+ * (e.g. cyan label + magenta sparkline + bold value on the same
+ * line). OpenTUI's `` is one color per text node, so we lose
+ * that per-segment richness here. The trade-off is OpenTUI handles
+ * border drawing + grid layout automatically — the win is bigger
+ * than the loss.
+ */
+function WidgetContentRows({
+ lines,
+ type,
+ widget,
+}: {
+ lines: string[];
+ type: DashboardViewWidget["data"]["type"];
+ widget: DashboardViewWidget;
+}): React.ReactNode {
+ const styling = rowStylingFor(type);
+ return (
+
+ {lines.map((line, i) => {
+ const { fg, attrs } = styling(i, widget);
+ return (
+ // Content rows are positionally stable for a given widget
+ // data snapshot — `renderContentLines` is pure of a given
+ // input, so the index makes a fine key.
+ // biome-ignore lint/suspicious/noArrayIndexKey: positional rows
+
+ {line}
+
+ );
+ })}
+
+ );
+}
+
+/**
+ * Per-widget-type row styling function. Returns a callback that
+ * resolves the `fg` color and `attributes` for a given row index.
+ * Some types (e.g. `table`) have row-position-dependent styling
+ * (header bold, separator muted, body plain).
+ */
+function rowStylingFor(type: DashboardViewWidget["data"]["type"]): (
+ idx: number,
+ widget: DashboardViewWidget
+) => {
+ fg: string;
+ attrs: number;
+} {
+ if (type === "table") {
+ return (idx) => {
+ if (idx === 0) {
+ return { fg: FOREGROUND, attrs: BOLD };
+ }
+ if (idx === 1) {
+ return { fg: MUTED, attrs: 0 };
+ }
+ return { fg: FOREGROUND, attrs: 0 };
+ };
+ }
+ if (type === "scalar") {
+ return () => ({ fg: GREEN, attrs: BOLD });
+ }
+ if (type === "error") {
+ return () => ({ fg: ERROR, attrs: 0 });
+ }
+ if (type === "unsupported") {
+ return () => ({ fg: MUTED, attrs: 0 });
+ }
+ if (type === "timeseries") {
+ // First row of a vertical-bar timeseries is the header
+ // (`
@@ -137,55 +375,63 @@ function Header({
* render the rows top-to-bottom. Widgets without a `layout` are
* appended at the end as full-width rows — covers older
* dashboards that pre-date Sentry's grid layout.
+ *
+ * Threads `focusedIndex` (an index into the original `widgets`
+ * array, before bucketing) down to each `Widget` so the focused
+ * one gets the accent border treatment.
*/
function WidgetGrid({
widgets,
termWidth,
+ focusedIndex,
}: {
widgets: DashboardViewWidget[];
termWidth: number;
+ focusedIndex: number;
}): React.ReactNode {
- // Bucket widgets by their starting y position.
- const rows = new Map();
- const orphans: DashboardViewWidget[] = [];
+ // Bucket widgets by their starting y position. Track the
+ // original index alongside each entry so we can pass it to the
+ // Widget component for focus comparison.
+ type Indexed = { widget: DashboardViewWidget; index: number };
+ const rows = new Map();
+ const orphans: Indexed[] = [];
- for (const widget of widgets) {
+ for (const [i, widget] of widgets.entries()) {
+ const indexed: Indexed = { widget, index: i };
if (widget.layout) {
const key = widget.layout.y;
const bucket = rows.get(key);
if (bucket) {
- bucket.push(widget);
+ bucket.push(indexed);
} else {
- rows.set(key, [widget]);
+ rows.set(key, [indexed]);
}
} else {
- orphans.push(widget);
+ orphans.push(indexed);
}
}
- // Sort row keys ascending; within a row, widgets sort by x.
const sortedRowKeys = [...rows.keys()].sort((a, b) => a - b);
return (
{sortedRowKeys.map((y) => {
const rowWidgets = (rows.get(y) ?? []).sort(
- (a, b) => (a.layout?.x ?? 0) - (b.layout?.x ?? 0)
+ (a, b) => (a.widget.layout?.x ?? 0) - (b.widget.layout?.x ?? 0)
);
return (
);
})}
- {orphans.map((widget, i) => (
+ {orphans.map(({ widget, index }) => (
@@ -203,18 +449,21 @@ function WidgetGrid({
function WidgetRow({
widgets,
termWidth,
+ focusedIndex,
}: {
- widgets: DashboardViewWidget[];
+ widgets: { widget: DashboardViewWidget; index: number }[];
termWidth: number;
+ focusedIndex: number;
}): React.ReactNode {
return (
- {widgets.map((widget) => {
+ {widgets.map(({ widget, index }) => {
const w = widget.layout?.w ?? GRID_COLS;
const widgetWidth = Math.floor((w / GRID_COLS) * termWidth);
return (
@@ -231,13 +480,20 @@ function WidgetRow({
* `title` prop carries the widget title (Ink-style). Content height
* is `layout.h * LINES_PER_UNIT` to mirror the Sentry web grid's
* vertical units.
+ *
+ * Focused widgets get the accent purple border + bold title so
+ * the user can tell at a glance which widget Tab will operate on
+ * next. Unfocused widgets stay muted gray — the contrast is the
+ * key affordance.
*/
function Widget({
widget,
width,
+ focused,
}: {
widget: DashboardViewWidget;
width: number;
+ focused: boolean;
}): React.ReactNode {
const layoutH = widget.layout?.h ?? 1;
const totalHeight = layoutH * LINES_PER_UNIT;
@@ -258,16 +514,19 @@ function Widget({
});
const lines = rawLines.map(stripAnsi);
+ const borderColor = focused ? ACCENT : MUTED;
+ const titleText = focused ? `▸ ${widget.title}` : widget.title;
+
return (
@@ -371,3 +630,219 @@ function rowStylingFor(type: DashboardViewWidget["data"]["type"]): (
// text / default — plain foreground.
return () => ({ fg: FOREGROUND, attrs: 0 });
}
+
+// ───────────────────────────── Drilldown ─────────────────────────────
+
+/**
+ * Full-screen detail view of a single widget. Replaces the grid
+ * when active; the user gets:
+ *
+ * - Full terminal width for content (vs. the proportional
+ * fraction the grid view allotted).
+ * - More vertical space for the per-widget content lines —
+ * useful for tables (more rows visible) and timeseries
+ * (taller bar charts).
+ * - The original query info from `widget.queries` rendered
+ * beneath the body so the user can see what's being shown.
+ */
+function Drilldown({
+ widget,
+ termWidth,
+}: {
+ widget: DashboardViewWidget | undefined;
+ termWidth: number;
+}): React.ReactNode {
+ if (!widget) {
+ return (
+
+ No widget selected.
+
+ );
+ }
+
+ // Drilldown gets the full main-area width minus 4 cells for the
+ // outer border + padding, and a generous height (the runtime
+ // sizes the renderer to fit). `renderContentLines` will return
+ // as many lines as content needs up to `contentHeight`.
+ const innerWidth = Math.max(0, termWidth - 4);
+ const contentHeight = 24;
+ const rawLines = renderContentLines({
+ widget,
+ innerWidth,
+ contentHeight,
+ });
+ const lines = rawLines.map(stripAnsi);
+ const queryLines = formatQueryLines(widget);
+
+ return (
+
+
+
+
+ {queryLines.length > 0 ? (
+
+ {queryLines.map((line, i) => (
+ // Query lines are positional and pure of widget input.
+ // biome-ignore lint/suspicious/noArrayIndexKey: positional query rows
+
+ {line}
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+/**
+ * Format the widget's queries into compact one-liners for the
+ * drilldown query panel. Each entry shows the optional name, the
+ * conditions, and the comma-separated aggregates / fields. Empty
+ * fields are skipped so noisy "name: " prefixes don't show up
+ * for unnamed queries.
+ */
+function formatQueryLines(widget: DashboardViewWidget): string[] {
+ if (!widget.queries || widget.queries.length === 0) {
+ return [];
+ }
+ return widget.queries.map((q, i) => {
+ const parts: string[] = [];
+ if (q.name) {
+ parts.push(`[${q.name}]`);
+ } else {
+ parts.push(`[query ${i + 1}]`);
+ }
+ if (q.conditions) {
+ parts.push(q.conditions);
+ }
+ const aggregates = (q.aggregates ?? []).filter(Boolean).join(", ");
+ if (aggregates) {
+ parts.push(`aggregates: ${aggregates}`);
+ }
+ const columns = (q.columns ?? []).filter(Boolean).join(", ");
+ if (columns) {
+ parts.push(`columns: ${columns}`);
+ }
+ return parts.join(" ");
+ });
+}
+
+// ──────────────────────────── Help overlay ───────────────────────────
+
+/**
+ * Help overlay listing the keybindings. Rendered in-flow at the
+ * bottom of the App (rather than absolutely positioned) because
+ * OpenTUI's flex layout doesn't have a portal primitive. Visually
+ * acts as a status panel that pops up when `?` is pressed.
+ */
+function HelpOverlay(): React.ReactNode {
+ const bindings: { key: string; action: string }[] = [
+ { key: "Tab / →", action: "Next widget" },
+ { key: "Shift+Tab / ←", action: "Previous widget" },
+ { key: "Enter", action: "Drill into focused widget" },
+ { key: "Esc", action: "Back / quit" },
+ { key: "t", action: "Cycle time period" },
+ { key: "r", action: "Refresh now" },
+ { key: "R", action: "Toggle auto-refresh" },
+ { key: "o", action: "Open in browser" },
+ { key: "?", action: "Toggle this help" },
+ { key: "q / Ctrl+C", action: "Quit" },
+ ];
+ // The longest key column drives the alignment; +2 cells of
+ // padding so the action text breathes.
+ const keyWidth = Math.max(...bindings.map((b) => b.key.length)) + 2;
+ return (
+
+ {bindings.map((b) => (
+
+ {b.key.padEnd(keyWidth)}
+ {b.action}
+
+ ))}
+
+ );
+}
+
+// ───────────────────────────── Status bar ────────────────────────────
+
+/**
+ * Compact one-line status bar at the bottom of the screen showing
+ * the most useful keybindings. Appears in both grid and drilldown
+ * modes (the bindings list adapts).
+ *
+ * Why pin it to the bottom? Discoverability. Without a visible
+ * cue, first-time users won't know they can press `?` to learn
+ * about the rest of the keys, and the dashboard would feel like
+ * a static page that just happens to take stdin.
+ */
+function StatusBar({
+ snapshot,
+}: {
+ snapshot: {
+ drilldownActive: boolean;
+ helpOverlayActive: boolean;
+ autoRefreshEnabled: boolean;
+ fetchError: string | null;
+ };
+}): React.ReactNode {
+ let hint: string;
+ if (snapshot.helpOverlayActive) {
+ hint = "Esc / ? to close";
+ } else if (snapshot.drilldownActive) {
+ hint = "Esc to return · q to quit";
+ } else {
+ hint =
+ "Tab focus · Enter drill · t period · r refresh · R auto · o browser · ? help · q quit";
+ }
+ return (
+
+ {hint}
+ {snapshot.fetchError ? (
+ {` ✖ ${snapshot.fetchError}`}
+ ) : null}
+
+ );
+}
diff --git a/src/lib/formatters/dashboard-runtime.ts b/src/lib/formatters/dashboard-runtime.ts
new file mode 100644
index 000000000..cb2e2e30a
--- /dev/null
+++ b/src/lib/formatters/dashboard-runtime.ts
@@ -0,0 +1,340 @@
+/**
+ * Dashboard view — interactive runtime.
+ *
+ * Mounts the React App from `dashboard-app.tsx` into a long-lived
+ * `createCliRenderer` (alternate-screen mode) and drives the
+ * imperative side of interactivity:
+ *
+ * - User actions (refresh, cycle-period, toggle-auto-refresh,
+ * open-in-browser, quit) are dispatched from the App via the
+ * `DashboardStore`'s action dispatcher slot. This module
+ * services them.
+ * - Auto-refresh schedules a fetch every `autoRefreshIntervalMs`
+ * while enabled. Cancels cleanly on quit / manual refresh /
+ * toggle-off.
+ * - On quit (`q`, Ctrl+C, or Esc when no overlay is up), the
+ * renderer is destroyed, the React tree unmounted, and the
+ * `runInteractiveDashboard` promise resolves so the wizard
+ * runner can return to the shell.
+ *
+ * Bun-binary only — same gating as the static `renderDashboardTui`
+ * in `dashboard-tui.ts`. Callers in `dashboard view` should
+ * `try { await runInteractiveDashboard(...) } catch { fall back }`
+ * so the npm/Node distribution lands on the plain-text formatter.
+ *
+ * This module imports `dashboard-app.tsx` via the embedded
+ * `with { type: "file" }` indirection (same trick as the wizard's
+ * `OpenTuiUI`) so Bun.compile doesn't attempt to bundle the
+ * React tree's static React imports — which fails the same way
+ * the wizard's static-bundle path failed before the embedding
+ * trick was introduced.
+ */
+
+import { openBrowser } from "../browser.js";
+import { logger } from "../logger.js";
+import type { DashboardViewData } from "./dashboard.js";
+// @ts-expect-error: `with { type: "file" }` is Bun-specific and not yet typed in @types/bun
+import dashboardAppPath from "./dashboard-app.tsx" with { type: "file" };
+import {
+ type DashboardAction,
+ DashboardStore,
+ PERIOD_CYCLE,
+} from "./dashboard-store.js";
+
+/**
+ * Inputs to a single fetch. Mirrors the relevant subset of the
+ * existing `view.ts` fetch path so the caller can hand it through
+ * to `queryAllWidgets` without this module knowing about Sentry's
+ * widget query options.
+ */
+export type DashboardFetchOptions = {
+ /** Period in `1h` / `24h` / `7d` / `30d` / `90d` form. */
+ period: string;
+};
+
+/**
+ * Caller-supplied callback that re-fetches widget data for the
+ * given period and returns a fresh {@link DashboardViewData}. The
+ * runtime calls this on `refresh`, `cycle-period`, and
+ * auto-refresh ticks.
+ *
+ * Should resolve to the new view data on success or reject with an
+ * Error whose `.message` will be surfaced in the status bar.
+ */
+export type DashboardFetcher = (
+ options: DashboardFetchOptions
+) => Promise;
+
+/** Runtime config + dependencies. */
+export type RunInteractiveDashboardOptions = {
+ /** Initial dashboard view data — already fetched by the caller. */
+ initialData: DashboardViewData;
+ /** Initial period (e.g. "24h"). Drives the cycle-period action. */
+ initialPeriod: string;
+ /** Re-fetch callback. See {@link DashboardFetcher}. */
+ fetch: DashboardFetcher;
+ /** Org slug — used for `o` (open in browser) action. */
+ orgSlug: string;
+ /**
+ * Auto-refresh interval in milliseconds. `undefined` to disable
+ * auto-refresh entirely; otherwise the user can toggle it on
+ * with `R` and the runtime will fetch every `interval` ms.
+ */
+ autoRefreshIntervalMs?: number;
+ /** Whether auto-refresh starts enabled (default false). */
+ initialAutoRefresh?: boolean;
+};
+
+/**
+ * Mount the interactive dashboard, drive the keyboard event loop,
+ * and resolve when the user quits. Cleans up the renderer on
+ * every exit path (success, throw, external abort).
+ *
+ * The fetched-data lifecycle:
+ *
+ * 1. Caller fetches initial data + passes it in via
+ * `initialData`. The store starts with this snapshot.
+ * 2. User actions or auto-refresh ticks call the registered
+ * action dispatcher, which fires the `fetch` callback.
+ * 3. Successful fetches replace the data via `store.setData`;
+ * React re-renders the App.
+ * 4. On `quit`, the renderer is destroyed and the function
+ * returns.
+ */
+export async function runInteractiveDashboard(
+ options: RunInteractiveDashboardOptions
+): Promise {
+ // Lazy-import the heavy dependencies. Same imports as the
+ // wizard's `OpenTuiUI` factory — keeps the npm/Node bundle
+ // free of OpenTUI references at static-analysis time.
+ const core = await import("@opentui/core");
+ const reactBindings = await import("@opentui/react");
+ const react = await import("react");
+ const app = (await import(
+ `${dashboardAppPath}?bridge=1`
+ )) as typeof import("./dashboard-app.js");
+
+ const renderer = await core.createCliRenderer({
+ exitOnCtrlC: false,
+ screenMode: "alternate-screen",
+ });
+
+ const store = new DashboardStore({
+ data: options.initialData,
+ currentPeriod: options.initialPeriod,
+ autoRefreshIntervalMs: options.autoRefreshIntervalMs,
+ autoRefreshEnabled: options.initialAutoRefresh ?? false,
+ });
+
+ // Promise that resolves when the user signals quit. The
+ // dispatcher resolves it; `runInteractiveDashboard` awaits it
+ // and falls through to teardown.
+ let resolveQuit: () => void = () => {
+ // populated synchronously below
+ };
+ const quitPromise = new Promise((resolve) => {
+ resolveQuit = resolve;
+ });
+
+ // Auto-refresh timer state. The interval ID is held in this
+ // closure so the dispatcher can clear it on quit / disable.
+ let autoRefreshTimer: ReturnType | undefined;
+ const stopAutoRefresh = (): void => {
+ if (autoRefreshTimer !== undefined) {
+ clearInterval(autoRefreshTimer);
+ autoRefreshTimer = undefined;
+ }
+ };
+
+ // Track whether a fetch is currently in flight so we can avoid
+ // overlapping requests when the user mashes `r` or auto-refresh
+ // collides with a manual trigger.
+ let fetchInFlight = false;
+
+ /**
+ * Run a fetch and apply the result to the store. Sets
+ * `fetching: true` while the request is open; on success, swaps
+ * in the fresh data; on failure, surfaces the error in the
+ * status bar via `setFetchError`. Always clears the in-flight
+ * flag.
+ */
+ const performFetch = async (period: string): Promise => {
+ if (fetchInFlight) {
+ // Skip overlapping fetch — the in-flight request will
+ // produce data shortly.
+ return;
+ }
+ fetchInFlight = true;
+ store.setFetching(true);
+ try {
+ const fresh = await options.fetch({ period });
+ store.setData(fresh, period);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ store.setFetchError(message);
+ logger.debug(`dashboard refresh failed: ${message}`);
+ } finally {
+ fetchInFlight = false;
+ }
+ };
+
+ /**
+ * Reset the auto-refresh interval. Called when the user
+ * manually refreshes (so we don't fire again immediately) and
+ * when toggling auto-refresh on. Honors the store's current
+ * `autoRefreshEnabled` flag.
+ */
+ const restartAutoRefresh = (): void => {
+ stopAutoRefresh();
+ if (!store.getSnapshot().autoRefreshEnabled) {
+ return;
+ }
+ const intervalMs = store.getSnapshot().autoRefreshIntervalMs;
+ autoRefreshTimer = setInterval(() => {
+ // Fire-and-forget — `performFetch` swallows its own errors
+ // and surfaces them via `store.setFetchError`.
+ performFetch(store.getSnapshot().currentPeriod).catch(() => {
+ // unreachable — performFetch never rejects
+ });
+ }, intervalMs);
+ };
+
+ // Wire the action dispatcher. The App invokes
+ // `store.dispatch({...})`; the store routes here.
+ store.setActionDispatcher((action: DashboardAction) => {
+ switch (action.kind) {
+ case "refresh":
+ performFetch(store.getSnapshot().currentPeriod).catch(() => {
+ // unreachable — performFetch never rejects
+ });
+ // Also restart the auto-refresh interval so the next tick
+ // is `intervalMs` from now, not from the previous one.
+ restartAutoRefresh();
+ break;
+ case "cycle-period": {
+ const current = store.getSnapshot().currentPeriod;
+ const idx = PERIOD_CYCLE.indexOf(current);
+ const nextIdx = (idx + 1) % PERIOD_CYCLE.length;
+ const next = PERIOD_CYCLE[nextIdx] ?? PERIOD_CYCLE[0] ?? "24h";
+ // Optimistically update the period in the header so the
+ // user sees feedback immediately; the actual data follows.
+ store.setCurrentPeriod(next);
+ performFetch(next).catch(() => {
+ // unreachable — performFetch never rejects
+ });
+ // Reset the auto-refresh interval so the next tick is
+ // `intervalMs` from now, not from the previous tick —
+ // matches the `refresh` action's behavior so user-driven
+ // fetches always get a fresh interval window.
+ restartAutoRefresh();
+ break;
+ }
+ case "toggle-auto-refresh":
+ store.setAutoRefreshEnabled(!store.getSnapshot().autoRefreshEnabled);
+ restartAutoRefresh();
+ break;
+ case "open-in-browser": {
+ // Reuse the URL the runtime already has on the data
+ // snapshot rather than re-deriving it from the org +
+ // dashboard id.
+ const url = store.getSnapshot().data.url;
+ // Use `openBrowser` directly (not `openInBrowser`) so we
+ // skip the helper's "Opening in browser..." message and
+ // QR-fallback prints — those write to `process.stdout`
+ // which would corrupt the alternate-screen TUI. Browser
+ // launch is fire-and-forget; failures get a debug log
+ // entry and don't surface in the UI (they're rarely
+ // actionable from a TUI keystroke).
+ openBrowser(url).catch((err: unknown) => {
+ logger.debug(
+ `open-in-browser failed: ${
+ err instanceof Error ? err.message : String(err)
+ }`
+ );
+ });
+ break;
+ }
+ case "quit":
+ resolveQuit();
+ break;
+ default: {
+ // Exhaustiveness check; if a new action kind is added the
+ // type checker will flag this.
+ const _: never = action;
+ return _;
+ }
+ }
+ });
+
+ // Mount the React tree.
+ const termWidth = renderer.terminalWidth ?? 100;
+ const root = reactBindings.createRoot(renderer);
+ root.render(
+ react.createElement(app.App, {
+ store,
+ termWidth,
+ })
+ );
+
+ // Re-render on terminal resize so widget widths track. The
+ // `useTerminalDimensions` hook in App could pick this up, but
+ // we don't currently use it — keep an explicit listener so
+ // sizing stays correct.
+ const onResize = (): void => {
+ // Re-render with the new width prop. Since termWidth flows
+ // as a prop (not via a hook), we have to re-render the root
+ // explicitly to propagate the change.
+ const newWidth = renderer.terminalWidth ?? 100;
+ root.render(
+ react.createElement(app.App, {
+ store,
+ termWidth: newWidth,
+ })
+ );
+ };
+ // OpenTUI's renderer emits "resize" via its event interface.
+ // The exact emitter shape varies between versions; we prefer
+ // `on("resize", ...)` if available, fallback to `process.stdout`.
+ const rendererEvents = renderer as unknown as {
+ on?: (event: string, cb: () => void) => void;
+ off?: (event: string, cb: () => void) => void;
+ };
+ if (typeof rendererEvents.on === "function") {
+ rendererEvents.on("resize", onResize);
+ } else {
+ process.stdout.on("resize", onResize);
+ }
+
+ // Start auto-refresh if it was enabled at construction time.
+ restartAutoRefresh();
+
+ // Block until the quit action fires.
+ try {
+ await quitPromise;
+ } finally {
+ // Clear the dispatcher first so any stray keystroke that
+ // races teardown can't re-enter the action loop. Then stop
+ // the auto-refresh timer (libuv interval handle) before
+ // unmounting the React tree, then destroy the renderer
+ // last — order matters because `renderer.destroy()` releases
+ // the alternate-screen buffer + raw mode, which must happen
+ // AFTER React commits its final unmount paint.
+ store.setActionDispatcher(undefined);
+ stopAutoRefresh();
+ if (typeof rendererEvents.off === "function") {
+ rendererEvents.off("resize", onResize);
+ } else {
+ process.stdout.off("resize", onResize);
+ }
+ try {
+ root.unmount();
+ } catch {
+ // Ignore — disposal must never throw.
+ }
+ try {
+ renderer.destroy();
+ } catch {
+ // Ignore.
+ }
+ }
+}
diff --git a/src/lib/formatters/dashboard-store.ts b/src/lib/formatters/dashboard-store.ts
new file mode 100644
index 000000000..8c089dbab
--- /dev/null
+++ b/src/lib/formatters/dashboard-store.ts
@@ -0,0 +1,272 @@
+/**
+ * Dashboard view — interactive UI state store.
+ *
+ * Tiny external store that bridges the imperative runtime (data
+ * fetches, period cycling, refresh) to React's render loop. The
+ * `App` component in `dashboard-app.tsx` subscribes via
+ * `useSyncExternalStore`; the runtime in `dashboard-runtime.ts`
+ * mutates the store as data arrives or the user issues actions.
+ *
+ * Mirrors the pattern the wizard's `WizardStore` established (see
+ * `src/lib/init/ui/wizard-store.ts`) — same reasoning applies:
+ * imperative state lives outside React, snapshots are immutable
+ * objects, listeners fire synchronously, and `Object.is` reference
+ * checks are enough for change detection.
+ *
+ * Two state classes:
+ *
+ * - **Data state**: the resolved {@link DashboardViewData} and a
+ * transient `fetching` / `fetchError` pair signalling an
+ * in-flight refresh. Replaced wholesale on each successful
+ * fetch.
+ * - **UI state**: focused widget, drilldown / help overlay
+ * toggles, current period (so the header can stay accurate
+ * when the user cycles), auto-refresh enabled flag. Owned by
+ * this module — pure UX state, not business data.
+ *
+ * The store doesn't perform fetches itself; it exposes an
+ * `actionDispatcher` slot the runtime fills with a callback that
+ * routes user actions (refresh, cycle-period, etc.) to its async
+ * orchestration layer.
+ */
+
+import type { DashboardViewData } from "./dashboard.js";
+
+/**
+ * Discrete user actions that change data or trigger side effects.
+ * Actions that only mutate UI state (focus, drilldown, help) are
+ * called directly on the store and don't go through the dispatcher.
+ */
+export type DashboardAction =
+ /** Re-fetch widget data with the current period. */
+ | { kind: "refresh" }
+ /** Move to the next time period (1h → 24h → 7d → 30d → 90d → 1h). */
+ | { kind: "cycle-period" }
+ /** Toggle auto-refresh on/off. */
+ | { kind: "toggle-auto-refresh" }
+ /** Open the dashboard (or focused widget) URL in the user's browser. */
+ | { kind: "open-in-browser" }
+ /** Cooperatively shut down the interactive dashboard. */
+ | { kind: "quit" };
+
+/** Standard period cycle for the `t` keybinding. */
+export const PERIOD_CYCLE: readonly string[] = [
+ "1h",
+ "24h",
+ "7d",
+ "30d",
+ "90d",
+];
+
+/** Default auto-refresh interval in milliseconds (60 s). */
+export const DEFAULT_AUTO_REFRESH_INTERVAL_MS = 60_000;
+
+export type DashboardSnapshot = {
+ /** Resolved widget data + dashboard meta. Replaced on each fetch. */
+ data: DashboardViewData;
+ /** True while a re-fetch is in flight. Drives the spinner glyph in the status bar. */
+ fetching: boolean;
+ /** Last error from a failed fetch, or null when the most recent fetch succeeded. */
+ fetchError: string | null;
+ /**
+ * Index into `data.widgets` of the currently-focused widget. `-1` means
+ * no widget has focus (initial state — user hasn't navigated yet).
+ * The focused widget gets the accent border treatment.
+ */
+ focusedWidgetIndex: number;
+ /** True when the focused widget is expanded into a full-screen detail view. */
+ drilldownActive: boolean;
+ /** True while the help overlay is visible. */
+ helpOverlayActive: boolean;
+ /** Current effective period, kept in sync with the data fetch. */
+ currentPeriod: string;
+ /** True when auto-refresh is currently scheduling fetches. */
+ autoRefreshEnabled: boolean;
+ /** Interval (ms) used by auto-refresh. */
+ autoRefreshIntervalMs: number;
+};
+
+export type DashboardListener = () => void;
+
+/**
+ * Initial values for a new store. `data`, `currentPeriod` are
+ * required (they come from the first fetch the runtime performs
+ * before mounting the App); everything else has sane defaults.
+ */
+export type DashboardStoreInit = {
+ data: DashboardViewData;
+ currentPeriod: string;
+ autoRefreshIntervalMs?: number;
+ autoRefreshEnabled?: boolean;
+};
+
+/**
+ * Minimal external store with the React 18+ `useSyncExternalStore`
+ * subscription contract. Same shape as `WizardStore`.
+ */
+export class DashboardStore {
+ private snapshot: DashboardSnapshot;
+ private readonly listeners = new Set();
+ private actionDispatcher: ((action: DashboardAction) => void) | undefined;
+
+ constructor(init: DashboardStoreInit) {
+ this.snapshot = {
+ data: init.data,
+ fetching: false,
+ fetchError: null,
+ focusedWidgetIndex: -1,
+ drilldownActive: false,
+ helpOverlayActive: false,
+ currentPeriod: init.currentPeriod,
+ autoRefreshEnabled: init.autoRefreshEnabled ?? false,
+ autoRefreshIntervalMs:
+ init.autoRefreshIntervalMs ?? DEFAULT_AUTO_REFRESH_INTERVAL_MS,
+ };
+ }
+
+ getSnapshot = (): DashboardSnapshot => this.snapshot;
+
+ subscribe = (listener: DashboardListener): (() => void) => {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ };
+
+ // ── Action dispatch ───────────────────────────────────────────────
+
+ /**
+ * Register the action dispatcher. The runtime calls this once at
+ * mount time; the App then invokes
+ * `store.dispatch({ kind: "refresh" })` etc. without knowing
+ * how the runtime services the action.
+ */
+ setActionDispatcher(
+ dispatcher: ((action: DashboardAction) => void) | undefined
+ ): void {
+ this.actionDispatcher = dispatcher;
+ }
+
+ dispatch(action: DashboardAction): void {
+ this.actionDispatcher?.(action);
+ }
+
+ // ── Data mutators ─────────────────────────────────────────────────
+
+ setData(data: DashboardViewData, currentPeriod?: string): void {
+ this.update({
+ data,
+ fetching: false,
+ fetchError: null,
+ ...(currentPeriod !== undefined ? { currentPeriod } : {}),
+ });
+ }
+
+ setFetching(fetching: boolean): void {
+ if (this.snapshot.fetching === fetching) {
+ return;
+ }
+ this.update({ fetching });
+ }
+
+ setFetchError(message: string): void {
+ this.update({ fetching: false, fetchError: message });
+ }
+
+ // ── UI mutators ───────────────────────────────────────────────────
+
+ /**
+ * Move focus to a specific widget index. Clamped to the valid
+ * range; `-1` means "no focus" and is allowed (it's the initial
+ * state). Out-of-range indices are silently clamped to the
+ * nearest valid value rather than being rejected — keeps the
+ * keyboard handler simple.
+ */
+ setFocusedWidget(index: number): void {
+ const widgetCount = this.snapshot.data.widgets.length;
+ if (widgetCount === 0) {
+ return;
+ }
+ const clamped = Math.max(-1, Math.min(widgetCount - 1, index));
+ if (clamped === this.snapshot.focusedWidgetIndex) {
+ return;
+ }
+ this.update({ focusedWidgetIndex: clamped });
+ }
+
+ /**
+ * Cycle focus forward (Tab / ArrowRight / ArrowDown) or backward
+ * (Shift+Tab / ArrowLeft / ArrowUp). Wraps at the ends.
+ */
+ cycleFocus(direction: "forward" | "backward"): void {
+ const widgetCount = this.snapshot.data.widgets.length;
+ if (widgetCount === 0) {
+ return;
+ }
+ const current = this.snapshot.focusedWidgetIndex;
+ let next: number;
+ if (direction === "forward") {
+ next = current === -1 ? 0 : (current + 1) % widgetCount;
+ } else {
+ next = current <= 0 ? widgetCount - 1 : current - 1;
+ }
+ this.update({ focusedWidgetIndex: next });
+ }
+
+ /**
+ * Toggle the drilldown view. Only meaningful when a widget is
+ * focused — without focus there's nothing to drill into, so the
+ * call is a no-op.
+ */
+ toggleDrilldown(): void {
+ if (
+ this.snapshot.focusedWidgetIndex < 0 &&
+ !this.snapshot.drilldownActive
+ ) {
+ return;
+ }
+ this.update({ drilldownActive: !this.snapshot.drilldownActive });
+ }
+
+ /** Force exit drilldown (used by the Esc handler). */
+ exitDrilldown(): void {
+ if (!this.snapshot.drilldownActive) {
+ return;
+ }
+ this.update({ drilldownActive: false });
+ }
+
+ /** Toggle the help overlay (`?` key). */
+ toggleHelp(): void {
+ this.update({ helpOverlayActive: !this.snapshot.helpOverlayActive });
+ }
+
+ /** Force exit help overlay. */
+ exitHelp(): void {
+ if (!this.snapshot.helpOverlayActive) {
+ return;
+ }
+ this.update({ helpOverlayActive: false });
+ }
+
+ setAutoRefreshEnabled(enabled: boolean): void {
+ if (this.snapshot.autoRefreshEnabled === enabled) {
+ return;
+ }
+ this.update({ autoRefreshEnabled: enabled });
+ }
+
+ setCurrentPeriod(period: string): void {
+ if (this.snapshot.currentPeriod === period) {
+ return;
+ }
+ this.update({ currentPeriod: period });
+ }
+
+ // ── Internal ──────────────────────────────────────────────────────
+
+ private update(patch: Partial): void {
+ this.snapshot = { ...this.snapshot, ...patch };
+ for (const listener of this.listeners) {
+ listener();
+ }
+ }
+}
diff --git a/src/lib/formatters/dashboard-tui.ts b/src/lib/formatters/dashboard-tui.ts
index 57225575e..3dad3d106 100644
--- a/src/lib/formatters/dashboard-tui.ts
+++ b/src/lib/formatters/dashboard-tui.ts
@@ -39,7 +39,6 @@
*/
import type { DashboardViewData } from "./dashboard.js";
-
/**
* Embed `dashboard-app.tsx` as a Bun-compile file resource.
*
@@ -54,6 +53,7 @@ import type { DashboardViewData } from "./dashboard.js";
*/
// @ts-expect-error: `with { type: "file" }` is Bun-specific and not yet typed in @types/bun
import dashboardAppPath from "./dashboard-app.tsx" with { type: "file" };
+import { DashboardStore } from "./dashboard-store.js";
/** Default rendering width when stdout dimensions can't be detected. */
const DEFAULT_WIDTH = 100;
@@ -175,9 +175,20 @@ export async function renderDashboardTui(
useThread: false,
});
+ // The static path uses the same React App as the interactive
+ // path (`dashboard-runtime.ts`). Construct a single-shot store
+ // wrapping the data; `useKeyboard` hooks register against the
+ // test renderer's key bus but never fire because we don't drive
+ // any keystrokes — the App renders its initial snapshot and we
+ // capture that.
+ const store = new DashboardStore({
+ data,
+ currentPeriod: data.period,
+ });
+
try {
const root = reactBindings.createRoot(renderer);
- root.render(react.createElement(app.App, { data, termWidth: width }));
+ root.render(react.createElement(app.App, { store, termWidth: width }));
// React's reconciler commits asynchronously and the OpenTUI
// adapter may queue layout work on top of that. Render twice
// with a microtask wait between calls — the first
From 4e12f8e9b7dd14e8e25c80d5310b36f662a4d235 Mon Sep 17 00:00:00 2001
From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com>
Date: Thu, 30 Apr 2026 19:02:19 +0000
Subject: [PATCH 4/4] test(dashboard): cover store mutators + keyboard dispatch
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two new test files for the interactive dashboard:
- `dashboard-store.test.ts` (19 tests) — initial state,
cycleFocus wraparound (forward from -1, backward from -1,
wrap at ends), setFocusedWidget clamping (-1 allowed,
out-of-range clamped to nearest valid), toggleDrilldown
no-op when no focus, exitDrilldown idempotency, data
mutations clearing fetching/fetchError, action dispatcher
registration / clearing, subscriber notification on real
changes only.
- `dashboard-app.handlers.test.ts` (25 tests) — keyboard
dispatch state machine: universal keys (Ctrl+C, Esc with
staged dismissal), grid mode (Tab / Shift+Tab / backtab /
arrows / Enter / ? / q / t / r / Shift+R / R-via-sequence /
o / unknown), help overlay mode (? toggles, q quits, other
keys swallowed), drilldown mode (Enter exits, q quits,
navigation swallowed).
`handleKey` and friends are exported from `dashboard-app.tsx`
specifically so unit tests can exercise them as pure functions
— driving real keystrokes through `useKeyboard` requires a
raw-mode TTY which `bun test` can't reliably allocate in a
sandboxed PTY.
A `setup()` helper pre-mutates the store to match the test's
declared overlay state and reads the live snapshot on each
`fire()` call, so individual tests don't need to thread snapshot
mutations between events — matches the real `useKeyboard` hook
in App which closes over the latest store snapshot via
`useSyncExternalStore`.
Note on test isolation: when run together via `bun test path1
path2`, the handlers test fails to import `handleKey` from the
.tsx file because `dashboard-tui.test.ts` triggers a `with {
type: "file" }` resource load of the same path which collides
with module resolution. The `bun run test:unit` script uses
`--isolate` (separate module graph per file), where all 6382
tests pass — same isolation pattern the wizard's OpenTUI tests
already rely on.
---
.../formatters/dashboard-app.handlers.test.ts | 287 ++++++++++++++++++
test/lib/formatters/dashboard-store.test.ts | 232 ++++++++++++++
2 files changed, 519 insertions(+)
create mode 100644 test/lib/formatters/dashboard-app.handlers.test.ts
create mode 100644 test/lib/formatters/dashboard-store.test.ts
diff --git a/test/lib/formatters/dashboard-app.handlers.test.ts b/test/lib/formatters/dashboard-app.handlers.test.ts
new file mode 100644
index 000000000..31a9a3edb
--- /dev/null
+++ b/test/lib/formatters/dashboard-app.handlers.test.ts
@@ -0,0 +1,287 @@
+/**
+ * Unit tests for the dashboard App's keyboard dispatch.
+ *
+ * The `handleKey` family is a pure state machine over store
+ * mutations + action dispatches; we exercise it directly here
+ * rather than driving a live OpenTUI renderer (which `bun test`
+ * can't easily do in a sandboxed PTY).
+ *
+ * Each test constructs a `DashboardStore`, registers an action
+ * collector, fires synthetic `KeyEventLike` objects, and asserts
+ * on the resulting store state + dispatched actions.
+ */
+
+import { describe, expect, test } from "bun:test";
+import type { DashboardViewData } from "../../../src/lib/formatters/dashboard.js";
+import {
+ handleKey,
+ type KeyboardSnapshot,
+ type KeyEventLike,
+} from "../../../src/lib/formatters/dashboard-app.js";
+import {
+ type DashboardAction,
+ DashboardStore,
+} from "../../../src/lib/formatters/dashboard-store.js";
+
+const SAMPLE_DATA: DashboardViewData = {
+ id: "1",
+ title: "Test",
+ period: "24h",
+ fetchedAt: "2024-01-01T00:00:00Z",
+ url: "https://sentry.io/test",
+ widgets: [
+ {
+ title: "A",
+ displayType: "big_number",
+ layout: { x: 0, y: 0, w: 2, h: 1 },
+ data: { type: "scalar", value: 1, unit: null },
+ },
+ {
+ title: "B",
+ displayType: "line",
+ layout: { x: 2, y: 0, w: 4, h: 1 },
+ data: {
+ type: "timeseries",
+ series: [{ label: "x", unit: null, values: [] }],
+ },
+ },
+ ],
+};
+
+function setup(snapshot?: Partial): {
+ store: DashboardStore;
+ actions: DashboardAction[];
+ /**
+ * Fire a synthetic keystroke. The `KeyboardSnapshot` view is
+ * read fresh from `store.getSnapshot()` on every call so tests
+ * don't need to thread snapshot mutations between events —
+ * matches the real `useKeyboard` hook in `App` which closes
+ * over the latest store snapshot.
+ */
+ fire: (event: KeyEventLike) => void;
+} {
+ const store = new DashboardStore({
+ data: SAMPLE_DATA,
+ currentPeriod: "24h",
+ });
+ // Pre-mutate the store to match the requested overlay state.
+ // The real `App` reads these from `useSyncExternalStore`, so
+ // the test's `view` parameter must agree with store reality.
+ if (snapshot?.helpOverlayActive) {
+ store.toggleHelp();
+ }
+ if (snapshot?.drilldownActive) {
+ store.setFocusedWidget(0); // drilldown requires a focused widget
+ store.toggleDrilldown();
+ }
+ const actions: DashboardAction[] = [];
+ store.setActionDispatcher((a) => actions.push(a));
+ return {
+ store,
+ actions,
+ fire: (event) => {
+ const live = store.getSnapshot();
+ handleKey(
+ event,
+ {
+ drilldownActive: live.drilldownActive,
+ helpOverlayActive: live.helpOverlayActive,
+ },
+ store
+ );
+ },
+ };
+}
+
+describe("handleKey — universal", () => {
+ test("Ctrl+C quits regardless of overlay", () => {
+ const grid = setup();
+ grid.fire({ name: "c", ctrl: true });
+ expect(grid.actions).toEqual([{ kind: "quit" }]);
+
+ const drilldown = setup({ drilldownActive: true });
+ drilldown.fire({ name: "c", ctrl: true });
+ expect(drilldown.actions).toEqual([{ kind: "quit" }]);
+
+ const help = setup({ helpOverlayActive: true });
+ help.fire({ name: "c", ctrl: true });
+ expect(help.actions).toEqual([{ kind: "quit" }]);
+ });
+
+ test("Esc closes drilldown first when both overlays could apply", () => {
+ const ctx = setup({ drilldownActive: true, helpOverlayActive: true });
+ ctx.fire({ name: "escape" });
+ // Drilldown goes first (vim-style staged dismissal).
+ expect(ctx.store.getSnapshot().drilldownActive).toBe(false);
+ expect(ctx.store.getSnapshot().helpOverlayActive).toBe(true);
+ expect(ctx.actions).toEqual([]);
+ });
+
+ test("Esc closes help when no drilldown", () => {
+ const ctx = setup({ helpOverlayActive: true });
+ ctx.fire({ name: "escape" });
+ expect(ctx.store.getSnapshot().helpOverlayActive).toBe(false);
+ expect(ctx.actions).toEqual([]);
+ });
+
+ test("Esc quits when no overlays are up", () => {
+ const ctx = setup();
+ ctx.fire({ name: "escape" });
+ expect(ctx.actions).toEqual([{ kind: "quit" }]);
+ });
+});
+
+describe("handleKey — grid mode", () => {
+ test("Tab cycles focus forward", () => {
+ const ctx = setup();
+ ctx.fire({ name: "tab" });
+ expect(ctx.store.getSnapshot().focusedWidgetIndex).toBe(0);
+ ctx.fire({ name: "tab" });
+ expect(ctx.store.getSnapshot().focusedWidgetIndex).toBe(1);
+ });
+
+ test("Shift+Tab cycles backward", () => {
+ const ctx = setup();
+ ctx.fire({ name: "tab", shift: true });
+ // From -1, backward wraps to last (index 1).
+ expect(ctx.store.getSnapshot().focusedWidgetIndex).toBe(1);
+ });
+
+ test("backtab cycles backward (terminal alias for shift+tab)", () => {
+ const ctx = setup();
+ ctx.fire({ name: "backtab" });
+ expect(ctx.store.getSnapshot().focusedWidgetIndex).toBe(1);
+ });
+
+ test("arrow keys cycle focus", () => {
+ const ctx = setup();
+ ctx.fire({ name: "right" });
+ expect(ctx.store.getSnapshot().focusedWidgetIndex).toBe(0);
+ ctx.fire({ name: "down" });
+ expect(ctx.store.getSnapshot().focusedWidgetIndex).toBe(1);
+ ctx.fire({ name: "left" });
+ expect(ctx.store.getSnapshot().focusedWidgetIndex).toBe(0);
+ ctx.fire({ name: "up" });
+ expect(ctx.store.getSnapshot().focusedWidgetIndex).toBe(1);
+ });
+
+ test("Enter toggles drilldown when a widget is focused", () => {
+ const ctx = setup();
+ ctx.store.setFocusedWidget(0);
+ ctx.fire({ name: "return" });
+ expect(ctx.store.getSnapshot().drilldownActive).toBe(true);
+ });
+
+ test("Enter is a no-op when no widget is focused", () => {
+ const ctx = setup();
+ ctx.fire({ name: "return" });
+ expect(ctx.store.getSnapshot().drilldownActive).toBe(false);
+ });
+
+ test("? toggles help overlay", () => {
+ const ctx = setup();
+ ctx.fire({ name: "?" });
+ expect(ctx.store.getSnapshot().helpOverlayActive).toBe(true);
+ ctx.fire({ name: "?" });
+ expect(ctx.store.getSnapshot().helpOverlayActive).toBe(false);
+ });
+
+ test("? recognised via sequence (terminals that don't fill `name`)", () => {
+ const ctx = setup();
+ ctx.fire({ name: "/", sequence: "?" });
+ expect(ctx.store.getSnapshot().helpOverlayActive).toBe(true);
+ });
+
+ test("q quits", () => {
+ const ctx = setup();
+ ctx.fire({ name: "q" });
+ expect(ctx.actions).toEqual([{ kind: "quit" }]);
+ });
+
+ test("t dispatches cycle-period", () => {
+ const ctx = setup();
+ ctx.fire({ name: "t" });
+ expect(ctx.actions).toEqual([{ kind: "cycle-period" }]);
+ });
+
+ test("r dispatches refresh", () => {
+ const ctx = setup();
+ ctx.fire({ name: "r" });
+ expect(ctx.actions).toEqual([{ kind: "refresh" }]);
+ });
+
+ test("Shift+R dispatches toggle-auto-refresh", () => {
+ const ctx = setup();
+ ctx.fire({ name: "r", shift: true });
+ expect(ctx.actions).toEqual([{ kind: "toggle-auto-refresh" }]);
+ });
+
+ test("R sequence dispatches toggle-auto-refresh", () => {
+ // Some terminals report capital R as `name: "r"` without
+ // shift but with `sequence: "R"`. The handler accepts either.
+ const ctx = setup();
+ ctx.fire({ name: "r", sequence: "R" });
+ expect(ctx.actions).toEqual([{ kind: "toggle-auto-refresh" }]);
+ });
+
+ test("o dispatches open-in-browser", () => {
+ const ctx = setup();
+ ctx.fire({ name: "o" });
+ expect(ctx.actions).toEqual([{ kind: "open-in-browser" }]);
+ });
+
+ test("unknown keys are ignored", () => {
+ const ctx = setup();
+ ctx.fire({ name: "x" });
+ ctx.fire({ name: "f1" });
+ expect(ctx.actions).toEqual([]);
+ expect(ctx.store.getSnapshot().focusedWidgetIndex).toBe(-1);
+ });
+});
+
+describe("handleKey — help overlay mode", () => {
+ test("? toggles help off", () => {
+ const ctx = setup({ helpOverlayActive: true });
+ ctx.fire({ name: "?" });
+ expect(ctx.store.getSnapshot().helpOverlayActive).toBe(false);
+ });
+
+ test("q still quits", () => {
+ const ctx = setup({ helpOverlayActive: true });
+ ctx.fire({ name: "q" });
+ expect(ctx.actions).toEqual([{ kind: "quit" }]);
+ });
+
+ test("other keys are swallowed", () => {
+ const ctx = setup({ helpOverlayActive: true });
+ ctx.fire({ name: "t" });
+ ctx.fire({ name: "r" });
+ ctx.fire({ name: "o" });
+ ctx.fire({ name: "tab" });
+ expect(ctx.actions).toEqual([]);
+ expect(ctx.store.getSnapshot().focusedWidgetIndex).toBe(-1);
+ });
+});
+
+describe("handleKey — drilldown mode", () => {
+ test("Enter exits drilldown", () => {
+ const ctx = setup({ drilldownActive: true });
+ ctx.fire({ name: "return" });
+ expect(ctx.store.getSnapshot().drilldownActive).toBe(false);
+ });
+
+ test("q still quits", () => {
+ const ctx = setup({ drilldownActive: true });
+ ctx.fire({ name: "q" });
+ expect(ctx.actions).toEqual([{ kind: "quit" }]);
+ });
+
+ test("navigation keys are swallowed (focus doesn't change)", () => {
+ const ctx = setup({ drilldownActive: true });
+ // setup() set focus to widget 0 to make drilldown legal.
+ expect(ctx.store.getSnapshot().focusedWidgetIndex).toBe(0);
+ ctx.fire({ name: "tab" });
+ ctx.fire({ name: "right" });
+ expect(ctx.store.getSnapshot().focusedWidgetIndex).toBe(0);
+ });
+});
diff --git a/test/lib/formatters/dashboard-store.test.ts b/test/lib/formatters/dashboard-store.test.ts
new file mode 100644
index 000000000..2d3f603c0
--- /dev/null
+++ b/test/lib/formatters/dashboard-store.test.ts
@@ -0,0 +1,232 @@
+/**
+ * Tests for the interactive dashboard's external state store.
+ *
+ * Mirrors the pattern in `test/lib/init/ui/wizard-store.test.ts`:
+ * exercise mutators directly, assert on snapshot changes, verify
+ * subscriber notification and idempotency invariants.
+ */
+
+import { describe, expect, test } from "bun:test";
+import type { DashboardViewData } from "../../../src/lib/formatters/dashboard.js";
+import {
+ type DashboardAction,
+ DashboardStore,
+} from "../../../src/lib/formatters/dashboard-store.js";
+
+const SAMPLE_DATA: DashboardViewData = {
+ id: "1",
+ title: "Test",
+ period: "24h",
+ fetchedAt: "2024-01-01T00:00:00Z",
+ url: "https://sentry.io/test",
+ widgets: [
+ {
+ title: "Widget A",
+ displayType: "big_number",
+ layout: { x: 0, y: 0, w: 2, h: 1 },
+ data: { type: "scalar", value: 1, unit: null },
+ },
+ {
+ title: "Widget B",
+ displayType: "line",
+ layout: { x: 2, y: 0, w: 4, h: 2 },
+ data: {
+ type: "timeseries",
+ series: [{ label: "errors", unit: null, values: [] }],
+ },
+ },
+ {
+ title: "Widget C",
+ displayType: "table",
+ layout: { x: 0, y: 2, w: 6, h: 2 },
+ data: { type: "table", columns: ["x"], rows: [] },
+ },
+ ],
+};
+
+function makeStore(): DashboardStore {
+ return new DashboardStore({ data: SAMPLE_DATA, currentPeriod: "24h" });
+}
+
+describe("DashboardStore initial state", () => {
+ test("starts with no widget focused, no overlays, no auto-refresh", () => {
+ const snap = makeStore().getSnapshot();
+ expect(snap.focusedWidgetIndex).toBe(-1);
+ expect(snap.drilldownActive).toBe(false);
+ expect(snap.helpOverlayActive).toBe(false);
+ expect(snap.autoRefreshEnabled).toBe(false);
+ expect(snap.fetching).toBe(false);
+ expect(snap.fetchError).toBeNull();
+ expect(snap.currentPeriod).toBe("24h");
+ });
+
+ test("respects auto-refresh init values", () => {
+ const store = new DashboardStore({
+ data: SAMPLE_DATA,
+ currentPeriod: "7d",
+ autoRefreshEnabled: true,
+ autoRefreshIntervalMs: 30_000,
+ });
+ const snap = store.getSnapshot();
+ expect(snap.autoRefreshEnabled).toBe(true);
+ expect(snap.autoRefreshIntervalMs).toBe(30_000);
+ });
+});
+
+describe("DashboardStore.cycleFocus", () => {
+ test("forward from -1 lands on 0", () => {
+ const store = makeStore();
+ store.cycleFocus("forward");
+ expect(store.getSnapshot().focusedWidgetIndex).toBe(0);
+ });
+
+ test("forward wraps from last to first", () => {
+ const store = makeStore();
+ store.setFocusedWidget(2);
+ store.cycleFocus("forward");
+ expect(store.getSnapshot().focusedWidgetIndex).toBe(0);
+ });
+
+ test("backward from -1 wraps to last widget", () => {
+ const store = makeStore();
+ store.cycleFocus("backward");
+ expect(store.getSnapshot().focusedWidgetIndex).toBe(2);
+ });
+
+ test("backward wraps from first to last", () => {
+ const store = makeStore();
+ store.setFocusedWidget(0);
+ store.cycleFocus("backward");
+ expect(store.getSnapshot().focusedWidgetIndex).toBe(2);
+ });
+
+ test("no-op when widget list is empty", () => {
+ const store = new DashboardStore({
+ data: { ...SAMPLE_DATA, widgets: [] },
+ currentPeriod: "24h",
+ });
+ store.cycleFocus("forward");
+ expect(store.getSnapshot().focusedWidgetIndex).toBe(-1);
+ });
+});
+
+describe("DashboardStore.setFocusedWidget", () => {
+ test("clamps to valid range", () => {
+ const store = makeStore();
+ store.setFocusedWidget(99);
+ expect(store.getSnapshot().focusedWidgetIndex).toBe(2);
+ store.setFocusedWidget(-99);
+ expect(store.getSnapshot().focusedWidgetIndex).toBe(-1);
+ });
+
+ test("idempotent for same index", () => {
+ const store = makeStore();
+ let calls = 0;
+ store.subscribe(() => {
+ calls += 1;
+ });
+ store.setFocusedWidget(1);
+ store.setFocusedWidget(1);
+ expect(calls).toBe(1);
+ });
+});
+
+describe("DashboardStore.toggleDrilldown", () => {
+ test("ignored when no widget focused", () => {
+ const store = makeStore();
+ store.toggleDrilldown();
+ expect(store.getSnapshot().drilldownActive).toBe(false);
+ });
+
+ test("toggles when a widget is focused", () => {
+ const store = makeStore();
+ store.setFocusedWidget(0);
+ store.toggleDrilldown();
+ expect(store.getSnapshot().drilldownActive).toBe(true);
+ store.toggleDrilldown();
+ expect(store.getSnapshot().drilldownActive).toBe(false);
+ });
+
+ test("exitDrilldown is idempotent", () => {
+ const store = makeStore();
+ let calls = 0;
+ store.subscribe(() => {
+ calls += 1;
+ });
+ store.exitDrilldown();
+ expect(calls).toBe(0);
+ });
+});
+
+describe("DashboardStore data mutations", () => {
+ test("setData clears fetching + fetchError", () => {
+ const store = makeStore();
+ store.setFetching(true);
+ store.setFetchError("boom");
+ store.setData(SAMPLE_DATA);
+ const snap = store.getSnapshot();
+ expect(snap.fetching).toBe(false);
+ expect(snap.fetchError).toBeNull();
+ });
+
+ test("setData updates currentPeriod when provided", () => {
+ const store = makeStore();
+ store.setData(SAMPLE_DATA, "7d");
+ expect(store.getSnapshot().currentPeriod).toBe("7d");
+ });
+
+ test("setFetching is idempotent for same value", () => {
+ const store = makeStore();
+ let calls = 0;
+ store.subscribe(() => {
+ calls += 1;
+ });
+ store.setFetching(false);
+ expect(calls).toBe(0);
+ });
+});
+
+describe("DashboardStore action dispatch", () => {
+ test("dispatch routes to registered dispatcher", () => {
+ const store = makeStore();
+ const seen: DashboardAction[] = [];
+ store.setActionDispatcher((a) => seen.push(a));
+ store.dispatch({ kind: "refresh" });
+ store.dispatch({ kind: "cycle-period" });
+ expect(seen).toEqual([{ kind: "refresh" }, { kind: "cycle-period" }]);
+ });
+
+ test("dispatch is a no-op when no dispatcher is registered", () => {
+ const store = makeStore();
+ expect(() => store.dispatch({ kind: "quit" })).not.toThrow();
+ });
+
+ test("clearing the dispatcher disables future dispatches", () => {
+ const store = makeStore();
+ let calls = 0;
+ store.setActionDispatcher(() => {
+ calls += 1;
+ });
+ store.dispatch({ kind: "refresh" });
+ store.setActionDispatcher(undefined);
+ store.dispatch({ kind: "refresh" });
+ expect(calls).toBe(1);
+ });
+});
+
+describe("DashboardStore subscribers", () => {
+ test("notifies on real changes only", () => {
+ const store = makeStore();
+ let calls = 0;
+ const unsub = store.subscribe(() => {
+ calls += 1;
+ });
+ store.setFocusedWidget(0); // change → 1 call
+ store.setFocusedWidget(0); // no-op → 0 calls
+ store.toggleHelp(); // change → 1 call
+ store.toggleHelp(); // change (toggle) → 1 call
+ unsub();
+ store.setFocusedWidget(2); // unsubscribed → 0 calls
+ expect(calls).toBe(3);
+ });
+});