Skip to content

feat(dashboard): render dashboard view with OpenTUI on the Bun binary#887

Closed
MathurAditya724 wants to merge 4 commits intofeat/init-wizard-ui-abstractionfrom
feat/dashboard-view-tui
Closed

feat(dashboard): render dashboard view with OpenTUI on the Bun binary#887
MathurAditya724 wants to merge 4 commits intofeat/init-wizard-ui-abstractionfrom
feat/dashboard-view-tui

Conversation

@MathurAditya724
Copy link
Copy Markdown
Member

Stacked on top of #862 (OpenTUI / WizardUI scaffold). Once #862 merges, GitHub auto-retargets this PR to main.

Summary

Replaces the framebuffer + grid composition logic in dashboard view with an OpenTUI React app for the Bun-compiled binary. The plain-text framebuffer (1868 lines in formatters/dashboard.ts) is preserved intact for the npm/Node distribution where OpenTUI's Zig bindings can't load — Node users see no behavior change. Bun-binary users get a sexier rendered dashboard with proper flex layout, bordered widgets, and consistent styling.

What it looks like

Production Dashboard  [24h]  env: production
────────────────────────────────────────────────────────────────────
╭─ Total Errors ────────────────╮╭─ Errors over time ──────────────╮
│                               ││ errors  115                     │
│                               ││ 147 ┤                  ▃▃██     │
│                               ││     │      ▁▁▁    ▆▆▆        ▁▁│
│                               ││     │▃▃▃   ███   ▂▂▂███      ▃▃│
│            42,678             ││  74 ┤███   ████████████      ██│
│                               ││     │█████████████████████████ │
│                               ││     │████████████████████████ │
│                               ││   0 ┤████████████████████████ │
╰───────────────────────────────╯╰─────────────────────────────────╯
╭─ Top issues ──────────────────────────────────────────────────────╮
│ TITLE                             EVENTS                    USERS │
│ ────────────────────────────────  ──────  ──────────────────────  │
│ TypeError: cannot read property    1,234                       89 │
│ ReferenceError: x is not defined     567                       42 │
│ Network timeout                      234                       15 │
╰───────────────────────────────────────────────────────────────────╯

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.
  • src/lib/formatters/dashboard-tui.ts (new) — Bridge that mounts the React tree off-screen via createTestRenderer() (from @opentui/core/testing), awaits a microtask for React's async commit, calls renderOnce(), and captures the rendered character grid via captureCharFrame(). Trailing blank lines are stripped.
  • src/lib/formatters/dashboard.tsDashboardViewData gains optional rendered?: string (excluded from JSON via jsonExclude). renderContentLines() is exported so the OpenTUI app reuses the existing per-widget content helpers without duplication. createDashboardViewRenderer uses data.rendered if present, else falls through to the legacy framebuffer.
  • src/commands/dashboard/view.ts — pre-renders with OpenTUI inside the async func() before yielding CommandOutput so the synchronous HumanRenderer.render() returns the pre-baked string. Lazy-imports dashboard-tui.js via dynamic import() to keep its with { type: "file" } resolution + heavy OpenTUI deps out of test paths that walk the Stricli route map.
  • script/build.ts + script/bundle.ts — extended sidecar cleanup to include dashboard-app.tsx alongside the existing opentui-app.tsx.

Trade-offs

  • Flat per-row colors. OpenTUI's <text> strips ANSI from its content. The plain-text renderer's per-segment row coloring (cyan label + magenta sparkline + bold value on the same line) collapses to one dominant color per row. Tables: bold headers + muted separators. Sparklines: accent purple. Big numbers: green. Loses some granularity but layout (the win) is solid.
  • Approximate 2D grid. Plain-text does true 2D framebuffer composition where a tall widget can span multiple row groups. OpenTUI groups widgets by starting y and renders each row group flush — widgets with non-uniform h within a row don't overlap the next row. Most dashboards are uniform-height per row, so it lands clean.

Bun-binary only (same gating as the wizard)

@opentui/core ships native Zig bindings that don't load on Node. The npm distribution externalizes OpenTUI from its bundle entirely, and the lazy import("./dashboard-tui.js") will throw "Cannot find module" at runtime there. The view command catches that and falls back to formatDashboardWithData (the original framebuffer renderer). Node users get exactly the output they get today.

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/dashboard458 pass (includes 10 new tests for the OpenTUI bridge)
  • SENTRY_CLIENT_ID=test bun run build (binary 118.29 MB, unchanged)
  • 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 of a 3-widget dashboard (big-number, vertical bars, table) confirms OpenTUI's flex engine lays everything out correctly.

Tests added

test/lib/formatters/dashboard-tui.test.ts — 10 coarse end-to-end tests asserting the renderer produces a non-empty string containing dashboard title, period badge, environment badge, widget titles, box-drawing characters; verifies trailing-blank-line trimming and graceful handling of empty widget lists, orphan widgets (no layout), and error widgets.

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 (`<box>` / `<text>`). 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 `<text>` 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.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 29, 2026

PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://cli.sentry.dev/_preview/pr-887/

Built to branch gh-pages at 2026-04-30 19:03 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

… runners

Two CI-only failures from PR #887:

1. Biome formatter wanted the sidecar-cleanup for-loop on a single line.
   Auto-fixed via `bun x biome format --write script/bundle.ts`.

2. Tests fail in CI with an empty captured frame because React's
   reconciler commit + OpenTUI's layout pass don't fully complete after
   one `setTimeout(0)` + `renderOnce()` on slower CI runners. Render
   twice with a microtask wait between — the first pass flushes pending
   layout effects, the second captures the laid-out frame. Locally a
   single pass works because event-loop turns are faster.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 29, 2026

Codecov Results 📊

6382 passed | Total: 6382 | Pass Rate: 100% | Execution Time: 0ms

📊 Comparison with Base Branch

Metric Change
Total Tests 📈 +5
Passed Tests 📈 +5
Failed Tests
Skipped Tests

All tests are passing successfully.

❌ Patch coverage is 49.57%. Project has 13591 uncovered lines.
❌ Project coverage is 75.54%. Comparing base (base) to head (head).

Files with missing lines (5)
File Patch % Lines
src/lib/formatters/dashboard-app.tsx 46.72% ⚠️ 244 Missing
src/commands/dashboard/view.ts 2.01% ⚠️ 146 Missing
src/lib/formatters/dashboard-store.ts 92.31% ⚠️ 8 Missing
src/lib/formatters/dashboard.ts 33.33% ⚠️ 6 Missing
src/lib/formatters/dashboard-tui.ts 96.55% ⚠️ 3 Missing
Coverage diff
@@            Coverage Diff             @@
##          main       #PR       +/-##
==========================================
- Coverage    75.89%    75.54%    -0.35%
==========================================
  Files          294       303        +9
  Lines        54604     55569      +965
  Branches         0         0         —
==========================================
+ Hits         41440     41978      +538
- Misses       13164     13591      +427
- Partials         0         0         —

Generated by Codecov Action

Converts the static OpenTUI dashboard renderer into a live
interactive TUI on the Bun binary. The npm/Node distribution
falls back to the existing plain-text framebuffer, unchanged.

Keybindings:

- Tab / Shift+Tab / arrows  Cycle widget focus
- Enter                     Drill into focused widget
- Esc                       Back out one layer (drilldown → help → quit)
- t                         Cycle time period (1h → 24h → 7d → 30d → 90d)
- r                         Refresh now
- R                         Toggle auto-refresh
- o                         Open dashboard in browser
- ?                         Toggle keybindings overlay
- q / Ctrl+C                Quit

Implementation:

- `dashboard-store.ts` — external state store with action
  dispatcher slot. Owns focus index, drilldown / help overlay
  flags, current period, auto-refresh enabled, fetching state,
  fetch error. Pure mutators with idempotency invariants.

- `dashboard-runtime.ts` — mounts a long-lived
  `createCliRenderer({ exitOnCtrlC: false, screenMode:
  "alternate-screen" })`. Services dispatched actions:
  `refresh` and `cycle-period` re-fetch via the caller-supplied
  `fetch` callback; `toggle-auto-refresh` flips the timer;
  `open-in-browser` calls `openBrowser` directly (NOT
  `openInBrowser`, which would write "Opening in browser…" to
  stdout and corrupt the alternate-screen TUI); `quit` resolves
  the runtime promise so the caller returns to the shell.
  Cleanup order: detach dispatcher → stop timer → unmount React
  tree → destroy renderer (releases alt-screen + raw mode last).

- `dashboard-app.tsx` — App now subscribes via
  `useSyncExternalStore`, has a `useKeyboard` hook that
  delegates to extracted top-level handler functions
  (`handleKey`, `handleEscape`, `handleHelpKey`,
  `handleDrilldownKey`, `handleGridKey`). Renders a Drilldown
  view that takes over the screen for the focused widget, a
  HelpOverlay that lists keybindings, and a StatusBar pinned
  to the bottom showing the current shortcut hints.

- `dashboard-tui.ts` (static path) — unchanged behavior;
  constructs a default-state store and passes it to the same
  App component the interactive runtime uses. Existing 10
  static-render tests still pass.

- `view.ts` — adds `isInteractiveContext()` (TTY both ends +
  non-JSON + Bun runtime) and `tryRunInteractive()` which
  lazy-imports `dashboard-runtime.js` (matches the existing
  `tryPreRenderTui` lazy-import pattern). Falls through to the
  existing static path on import failure or non-interactive
  context. The `--refresh N` flag now means "start interactive
  with auto-refresh ON at N seconds" rather than a static
  polling loop; legacy non-interactive consumers (CI / piped /
  JSON) still get the polling loop.

Bun-binary only — same gating as the static path. Non-Bun
runtimes externalize OpenTUI and the lazy import resolves to
"Cannot find module", caught and falling through to plain
text.
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.
Comment on lines +216 to +218
const idx = PERIOD_CYCLE.indexOf(current);
const nextIdx = (idx + 1) % PERIOD_CYCLE.length;
const next = PERIOD_CYCLE[nextIdx] ?? PERIOD_CYCLE[0] ?? "24h";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Period cycling logic incorrectly handles periods not in the PERIOD_CYCLE list, causing it to reset to "1h" on the first cycle attempt.
Severity: MEDIUM

Suggested Fix

To fix this, the period cycling logic should handle cases where the current period is not found in the PERIOD_CYCLE array. One approach is to find the closest period in the array and start cycling from there, or to default to a sensible starting point like "24h" instead of always jumping to "1h".

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/lib/formatters/dashboard-runtime.ts#L216-L218

Potential issue: The `cycle-period` action in `dashboard-runtime.ts` uses
`PERIOD_CYCLE.indexOf(currentPeriod)` to determine the next period when a user cycles
through time ranges. If the `currentPeriod` is a valid but non-standard value (e.g.,
`"2w"` or `"14d"`, which can be set via the CLI or loaded from a dashboard's saved
state), `indexOf` returns -1. The subsequent calculation `(-1 + 1) % length` always
results in 0, causing the period to unexpectedly jump to the first element of
`PERIOD_CYCLE` (`"1h"`) instead of cycling logically.

Did we get this right? 👍 / 👎 to inform future reviews.

@MathurAditya724
Copy link
Copy Markdown
Member Author

Created this for fun, closing this now cause it doesn't add value

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant