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 + // (`