Skip to content

Commit 76d8152

Browse files
committed
feat(dashboard): rich terminal chart rendering for dashboard view
Add a full chart rendering engine for `sentry dashboard view` that transforms widget data into rich terminal visualizations. - **Big numbers**: 4-tier auto-scaling ASCII art fonts (7×7 → 5×5 → 3×3 → single-line) with vertical centering - **Time-series bars**: Fractional block characters (▁▂▃▄▅▆▇█) for smooth bar tops with Y-axis ticks and time-axis labels - **Stacked multi-series bars**: Distinct colors per series with a color-keyed legend (■ markers) - **Categorical bars**: Direct labels for short names, letter-keyed legend (A:label B:label…) for long ones - **Horizontal bars**: Filled + background bar with value labels - **Sparklines**: Compact inline graphs for small widgets - **Tables**: Markdown table rendering - Framebuffer grid compositing matching Sentry's widget layout - Dynamic terminal width (re-reads on each render cycle) - Y-axis with compact tick labels (formatTickLabel: 321K, 1.5M) - Time-axis with ┬ tick marks and auto-formatted date labels - Rounded Unicode borders with embedded widget titles - Multi-series aggregation for grouped time-series data - Widget data querying with environment/project filters - Zod schemas for dashboard API response validation - Downsample values and timestamps for terminal-width fitting
1 parent e4e7fd1 commit 76d8152

File tree

10 files changed

+3795
-127
lines changed

10 files changed

+3795
-127
lines changed

AGENTS.md

Lines changed: 35 additions & 109 deletions
Large diffs are not rendered by default.

plugins/sentry-cli/skills/sentry-cli/references/dashboards.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ View a dashboard
4444
**Flags:**
4545
- `-w, --web - Open in browser`
4646
- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data`
47+
- `-r, --refresh <value> - Auto-refresh interval in seconds (default: 60, min: 10)`
48+
- `-t, --period <value> - Time period override (e.g., "24h", "7d", "14d")`
4749

4850
**Examples:**
4951

src/commands/dashboard/view.ts

Lines changed: 173 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,148 @@
11
/**
22
* sentry dashboard view
33
*
4-
* View details of a specific dashboard.
4+
* View a dashboard with rendered widget data (sparklines, tables, big numbers).
5+
* Supports --refresh for auto-refreshing live display.
56
*/
67

78
import type { SentryContext } from "../../context.js";
8-
import { getDashboard } from "../../lib/api-client.js";
9+
import { getDashboard, queryAllWidgets } from "../../lib/api-client.js";
910
import { parseOrgProjectArg } from "../../lib/arg-parsing.js";
1011
import { openInBrowser } from "../../lib/browser.js";
1112
import { buildCommand } from "../../lib/command.js";
12-
import { formatDashboardView } from "../../lib/formatters/human.js";
13+
import type { DashboardViewData } from "../../lib/formatters/dashboard.js";
14+
import { createDashboardViewRenderer } from "../../lib/formatters/dashboard.js";
1315
import { CommandOutput } from "../../lib/formatters/output.js";
16+
import { isPlainOutput } from "../../lib/formatters/plain-detect.js";
1417
import {
1518
applyFreshFlag,
1619
FRESH_ALIASES,
1720
FRESH_FLAG,
1821
} from "../../lib/list-command.js";
22+
import { logger } from "../../lib/logger.js";
1923
import { withProgress } from "../../lib/polling.js";
24+
import { resolveOrgRegion } from "../../lib/region.js";
2025
import { buildDashboardUrl } from "../../lib/sentry-urls.js";
21-
import type { DashboardDetail } from "../../types/dashboard.js";
26+
import type {
27+
DashboardWidget,
28+
WidgetDataResult,
29+
} from "../../types/dashboard.js";
2230
import {
2331
parseDashboardPositionalArgs,
2432
resolveDashboardId,
2533
resolveOrgFromTarget,
2634
} from "./resolve.js";
2735

36+
/** Default auto-refresh interval in seconds */
37+
const DEFAULT_REFRESH_INTERVAL = 60;
38+
39+
/** Minimum auto-refresh interval in seconds (avoid rate limiting) */
40+
const MIN_REFRESH_INTERVAL = 10;
41+
2842
type ViewFlags = {
2943
readonly web: boolean;
3044
readonly fresh: boolean;
45+
readonly refresh?: number;
46+
readonly period?: string;
3147
readonly json: boolean;
3248
readonly fields?: string[];
3349
};
3450

35-
type ViewResult = DashboardDetail & { url: string };
51+
/**
52+
* Parse --refresh flag value.
53+
* Supports: -r (empty string → 60s default), -r 30 (explicit interval in seconds)
54+
*/
55+
function parseRefresh(value: string): number {
56+
if (value === "") {
57+
return DEFAULT_REFRESH_INTERVAL;
58+
}
59+
const num = Number.parseInt(value, 10);
60+
if (Number.isNaN(num) || num < MIN_REFRESH_INTERVAL) {
61+
throw new Error(
62+
`--refresh interval must be at least ${MIN_REFRESH_INTERVAL} seconds`
63+
);
64+
}
65+
return num;
66+
}
67+
68+
/**
69+
* Sleep that resolves early when an AbortSignal fires.
70+
* Resolves (not rejects) on abort for clean generator shutdown.
71+
*/
72+
function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
73+
return new Promise<void>((resolve) => {
74+
if (signal.aborted) {
75+
resolve();
76+
return;
77+
}
78+
const onAbort = () => {
79+
clearTimeout(timer);
80+
resolve();
81+
};
82+
const timer = setTimeout(() => {
83+
signal.removeEventListener("abort", onAbort);
84+
resolve();
85+
}, ms);
86+
signal.addEventListener("abort", onAbort, { once: true });
87+
});
88+
}
89+
90+
/**
91+
* Build the DashboardViewData from a dashboard and its widget query results.
92+
*/
93+
function buildViewData(
94+
dashboard: {
95+
id: string;
96+
title: string;
97+
dateCreated?: string;
98+
environment?: string[];
99+
},
100+
widgetResults: Map<number, WidgetDataResult>,
101+
widgets: DashboardWidget[],
102+
opts: { period: string; url: string }
103+
): DashboardViewData {
104+
return {
105+
id: dashboard.id,
106+
title: dashboard.title,
107+
period: opts.period,
108+
fetchedAt: new Date().toISOString(),
109+
url: opts.url,
110+
dateCreated: dashboard.dateCreated,
111+
environment: dashboard.environment,
112+
widgets: widgets.map((w, i) => ({
113+
title: w.title,
114+
displayType: w.displayType,
115+
widgetType: w.widgetType,
116+
layout: w.layout,
117+
queries: w.queries,
118+
data: widgetResults.get(i) ?? {
119+
type: "error" as const,
120+
message: "No data returned",
121+
},
122+
})),
123+
};
124+
}
36125

37126
export const viewCommand = buildCommand({
38127
docs: {
39128
brief: "View a dashboard",
40129
fullDescription:
41-
"View details of a specific Sentry dashboard.\n\n" +
130+
"View a Sentry dashboard with rendered widget data.\n\n" +
131+
"Fetches actual data for each widget and displays sparkline charts,\n" +
132+
"tables, and big numbers in the terminal.\n\n" +
42133
"The dashboard can be specified by numeric ID or title.\n\n" +
43134
"Examples:\n" +
44135
" sentry dashboard view 12345\n" +
45136
" sentry dashboard view 'My Dashboard'\n" +
46137
" sentry dashboard view my-org/ 12345\n" +
47138
" sentry dashboard view 12345 --json\n" +
139+
" sentry dashboard view 12345 --period 7d\n" +
140+
" sentry dashboard view 12345 -r\n" +
141+
" sentry dashboard view 12345 -r 30\n" +
48142
" sentry dashboard view 12345 --web",
49143
},
50144
output: {
51-
human: formatDashboardView,
145+
human: createDashboardViewRenderer,
52146
},
53147
parameters: {
54148
positional: {
@@ -65,8 +159,21 @@ export const viewCommand = buildCommand({
65159
default: false,
66160
},
67161
fresh: FRESH_FLAG,
162+
refresh: {
163+
kind: "parsed",
164+
parse: parseRefresh,
165+
brief: "Auto-refresh interval in seconds (default: 60, min: 10)",
166+
optional: true,
167+
inferEmpty: true,
168+
},
169+
period: {
170+
kind: "parsed",
171+
parse: String,
172+
brief: 'Time period override (e.g., "24h", "7d", "14d")',
173+
optional: true,
174+
},
68175
},
69-
aliases: { ...FRESH_ALIASES, w: "web" },
176+
aliases: { ...FRESH_ALIASES, w: "web", r: "refresh", t: "period" },
70177
},
71178
async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) {
72179
applyFreshFlag(flags);
@@ -80,20 +187,76 @@ export const viewCommand = buildCommand({
80187
"sentry dashboard view <org>/ <id>"
81188
);
82189
const dashboardId = await resolveDashboardId(orgSlug, dashboardRef);
83-
84190
const url = buildDashboardUrl(orgSlug, dashboardId);
85191

86192
if (flags.web) {
87193
await openInBrowser(url, "dashboard");
88194
return;
89195
}
90196

197+
// Fetch the dashboard definition (widget structure)
91198
const dashboard = await withProgress(
92199
{ message: "Fetching dashboard...", json: flags.json },
93200
() => getDashboard(orgSlug, dashboardId)
94201
);
95202

96-
yield new CommandOutput({ ...dashboard, url } as ViewResult);
203+
const regionUrl = await resolveOrgRegion(orgSlug);
204+
const period = flags.period ?? dashboard.period ?? "24h";
205+
const widgets = dashboard.widgets ?? [];
206+
207+
if (flags.refresh !== undefined) {
208+
// ── Refresh mode: poll and re-render ──
209+
const interval = flags.refresh;
210+
if (!flags.json) {
211+
logger.info(
212+
`Auto-refreshing dashboard every ${interval}s. Press Ctrl+C to stop.`
213+
);
214+
}
215+
216+
const controller = new AbortController();
217+
const stop = () => controller.abort();
218+
process.once("SIGINT", stop);
219+
220+
// Use in-place refresh on interactive terminals (ANSI clear)
221+
const canClear = !(isPlainOutput() || flags.json);
222+
let isFirstRender = true;
223+
224+
try {
225+
while (!controller.signal.aborted) {
226+
const widgetData = await queryAllWidgets(
227+
regionUrl,
228+
orgSlug,
229+
dashboard,
230+
{ period }
231+
);
232+
233+
// Clear screen for in-place refresh (skip for first render and piped output)
234+
if (!isFirstRender && canClear) {
235+
this.stdout.write("\x1b[H\x1b[J");
236+
}
237+
isFirstRender = false;
238+
239+
yield new CommandOutput(
240+
buildViewData(dashboard, widgetData, widgets, { period, url })
241+
);
242+
243+
await abortableSleep(interval * 1000, controller.signal);
244+
}
245+
} finally {
246+
process.removeListener("SIGINT", stop);
247+
}
248+
return;
249+
}
250+
251+
// ── Single fetch mode ──
252+
const widgetData = await withProgress(
253+
{ message: "Querying widget data...", json: flags.json },
254+
() => queryAllWidgets(regionUrl, orgSlug, dashboard, { period })
255+
);
256+
257+
yield new CommandOutput(
258+
buildViewData(dashboard, widgetData, widgets, { period, url })
259+
);
97260
return { hint: `Dashboard: ${url}` };
98261
},
99262
});

src/lib/api-client.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ export {
2323
createDashboard,
2424
getDashboard,
2525
listDashboards,
26+
queryAllWidgets,
27+
queryWidgetData,
2628
updateDashboard,
29+
type WidgetQueryOptions,
2730
} from "./api/dashboards.js";
2831
export {
2932
findEventAcrossOrgs,

0 commit comments

Comments
 (0)