Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions script/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,11 +508,11 @@ async function build(): Promise<void> {
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)}`);
Expand Down
26 changes: 15 additions & 11 deletions script/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,17 +293,21 @@ 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)
Expand Down
250 changes: 241 additions & 9 deletions src/commands/dashboard/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,39 @@ import {
resolveOrgFromTarget,
} from "./resolve.js";

/**
* True when the dashboard should mount the interactive Bun-binary
* TUI rather than the static one-shot renderer. Requires a
* real TTY on both stdin (for keystrokes) and stdout (for the
* alternate-screen takeover) AND non-JSON output mode AND the
* Bun runtime (where the OpenTUI bindings can load).
*
* Stays a function rather than a boolean so the test suite can
* mock TTY-ness per-test without import-order timing issues.
*
* The `stdout` argument is the Writer from `SentryContext`. We
* read `.isTTY` via a structural type because the `Writer` shape
* deliberately omits TTY metadata to keep library-mode consumers
* pluggable — but in practice the production stdout is
* `process.stdout` and exposes the flag.
*/
function isInteractiveContext(
flags: ViewFlags,
stdin: NodeJS.ReadStream,
stdout: { isTTY?: boolean }
): boolean {
if (flags.json) {
return false;
}
if (!(stdin.isTTY && stdout.isTTY)) {
return false;
}
// The Bun-compiled binary exposes `process.versions.bun`. The
// npm/Node distribution doesn't. The interactive runtime
// imports OpenTUI which only loads under Bun.
return typeof process.versions.bun === "string";
}

/** Default auto-refresh interval in seconds */
const DEFAULT_REFRESH_INTERVAL = 60;

Expand Down Expand Up @@ -97,6 +130,11 @@ function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {

/**
* 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: {
Expand Down Expand Up @@ -134,6 +172,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<DashboardViewData> {
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.
*
Expand All @@ -150,6 +235,103 @@ function resolveViewTimeRange(
return dashboardPeriod ? parsePeriod(dashboardPeriod) : TIME_RANGE_24H;
}

/**
* Inputs for the interactive dashboard runtime. Bundled into a
* single object so the helper can stay readable instead of
* threading 9+ positional args.
*/
type InteractiveContext = {
regionUrl: string;
orgSlug: string;
url: string;
dashboard: Awaited<ReturnType<typeof getDashboard>>;
widgets: DashboardWidget[];
widgetTimeOpts:
| { period: string }
| { start: string | undefined; end: string | undefined };
/**
* Seconds covered by the current period. `undefined` is the
* legitimate "couldn't compute" return from `timeRangeToSeconds`
* for malformed absolute ranges; downstream API code accepts
* undefined and falls back to its own period parsing.
*/
periodSeconds: number | undefined;
timeRange: TimeRange;
/** From `flags.refresh` — undefined when auto-refresh is off. */
refreshSeconds: number | undefined;
};

/**
* Fetch initial widget data and hand off to the OpenTUI runtime.
*
* Returns `true` when the runtime took over and ran to user-quit;
* the caller should `return` from `func()` immediately. Returns
* `false` when the runtime is unavailable (npm/Node distribution,
* unusual environment) so the caller can fall through to the
* non-interactive path.
*
* Lazy-imports `dashboard-runtime.js` for the same reason
* `tryPreRenderTui` lazy-imports `dashboard-tui.js`: keep
* OpenTUI references out of the npm bundle's static module
* graph.
*/
async function tryRunInteractive(ctx: InteractiveContext): Promise<boolean> {
// Initial fetch happens before mounting the renderer so any
// error (auth, 404, network) surfaces in the normal stderr
// stream rather than getting wiped by the alternate-screen
// takeover.
const initialWidgetData = await withProgress(
{ message: "Querying widget data...", json: false },
() =>
queryAllWidgets(ctx.regionUrl, ctx.orgSlug, ctx.dashboard, {
...ctx.widgetTimeOpts,
periodSeconds: ctx.periodSeconds,
})
);
const initialData = buildViewData(
ctx.dashboard,
initialWidgetData,
ctx.widgets,
{ period: formatTimeRangeFlag(ctx.timeRange), url: ctx.url }
);

try {
const { runInteractiveDashboard } = await import(
"../../lib/formatters/dashboard-runtime.js"
);
await runInteractiveDashboard({
initialData,
initialPeriod: formatTimeRangeFlag(ctx.timeRange),
orgSlug: ctx.orgSlug,
fetch: async ({ period }) => {
const fresh = await queryAllWidgets(
ctx.regionUrl,
ctx.orgSlug,
ctx.dashboard,
{ period, periodSeconds: timeRangeToSeconds(parsePeriod(period)) }
);
return buildViewData(ctx.dashboard, fresh, ctx.widgets, {
period,
url: ctx.url,
});
},
autoRefreshIntervalMs:
ctx.refreshSeconds !== undefined
? ctx.refreshSeconds * 1000
: undefined,
initialAutoRefresh: ctx.refreshSeconds !== undefined,
});
return true;
} catch (err) {
logger.debug(
`Interactive dashboard unavailable, falling back to static render: ${
err instanceof Error ? err.message : String(err)
}`
);
return false;
}
}

export const viewCommand = buildCommand({
docs: {
brief: "View a dashboard",
Expand All @@ -172,6 +354,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: {
Expand Down Expand Up @@ -205,6 +392,7 @@ export const viewCommand = buildCommand({
},
aliases: { ...FRESH_ALIASES, w: "web", r: "refresh", t: "period" },
},
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: sequential dispatch across web/interactive/refresh-poll/single-fetch modes is inherently flat; further splitting would spread one logical flow across multiple helpers without simplifying the branching
async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) {
applyFreshFlag(flags);
const { cwd } = this;
Expand Down Expand Up @@ -246,6 +434,40 @@ export const viewCommand = buildCommand({
: { start: timeRange.start, end: timeRange.end };
const widgets = dashboard.widgets ?? [];

// Interactive path — Bun binary, real TTY, non-JSON. Mounts a
// long-lived OpenTUI app that owns the alternate screen until
// the user quits. The `--refresh N` flag becomes "start with
// auto-refresh enabled at N-second interval"; without it,
// auto-refresh starts off and the user can toggle with `R`.
if (
isInteractiveContext(
flags,
this.stdin,
// The Writer type doesn't expose `isTTY` (kept abstract
// for library-mode consumers), but the production stdout
// is `process.stdout` and does. Cast to read the flag
// without coupling Writer to Node's stream shape.
this.stdout as unknown as { isTTY?: boolean }
)
) {
const handled = await tryRunInteractive({
regionUrl,
orgSlug,
url,
dashboard,
widgets,
widgetTimeOpts,
periodSeconds,
timeRange,
refreshSeconds: flags.refresh,
});
if (handled) {
return;
}
// tryRunInteractive returned false → fall through to the
// non-interactive paths below.
}

if (flags.refresh !== undefined) {
// ── Refresh mode: poll and re-render ──
const interval = flags.refresh;
Expand Down Expand Up @@ -277,11 +499,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();
Expand Down Expand Up @@ -309,10 +538,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}` };
},
Expand Down
Loading
Loading