Skip to content

Commit 48c41b9

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. ## Chart types - **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 ## Layout and formatting - 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 ## Data pipeline - Widget data querying with environment/project filters - Zod schemas for dashboard API response validation - Downsample values and timestamps for terminal-width fitting
1 parent 5f41707 commit 48c41b9

File tree

9 files changed

+3906
-117
lines changed

9 files changed

+3906
-117
lines changed

AGENTS.md

Lines changed: 36 additions & 100 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: 166 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,151 @@
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 {
15+
createDashboardViewRenderer,
16+
transformDashboardViewJson,
17+
} from "../../lib/formatters/dashboard.js";
1318
import { CommandOutput } from "../../lib/formatters/output.js";
1419
import {
1520
applyFreshFlag,
1621
FRESH_ALIASES,
1722
FRESH_FLAG,
1823
} from "../../lib/list-command.js";
24+
import { logger } from "../../lib/logger.js";
1925
import { withProgress } from "../../lib/polling.js";
26+
import { resolveOrgRegion } from "../../lib/region.js";
2027
import { buildDashboardUrl } from "../../lib/sentry-urls.js";
21-
import type { DashboardDetail } from "../../types/dashboard.js";
28+
import type {
29+
DashboardWidget,
30+
WidgetDataResult,
31+
} from "../../types/dashboard.js";
2232
import {
2333
parseDashboardPositionalArgs,
2434
resolveDashboardId,
2535
resolveOrgFromTarget,
2636
} from "./resolve.js";
2737

38+
/** Default auto-refresh interval in seconds */
39+
const DEFAULT_REFRESH_INTERVAL = 60;
40+
41+
/** Minimum auto-refresh interval in seconds (avoid rate limiting) */
42+
const MIN_REFRESH_INTERVAL = 10;
43+
2844
type ViewFlags = {
2945
readonly web: boolean;
3046
readonly fresh: boolean;
47+
readonly refresh?: number;
48+
readonly period?: string;
3149
readonly json: boolean;
3250
readonly fields?: string[];
3351
};
3452

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

37128
export const viewCommand = buildCommand({
38129
docs: {
39130
brief: "View a dashboard",
40131
fullDescription:
41-
"View details of a specific Sentry dashboard.\n\n" +
132+
"View a Sentry dashboard with rendered widget data.\n\n" +
133+
"Fetches actual data for each widget and displays sparkline charts,\n" +
134+
"tables, and big numbers in the terminal.\n\n" +
42135
"The dashboard can be specified by numeric ID or title.\n\n" +
43136
"Examples:\n" +
44137
" sentry dashboard view 12345\n" +
45138
" sentry dashboard view 'My Dashboard'\n" +
46139
" sentry dashboard view my-org/ 12345\n" +
47140
" sentry dashboard view 12345 --json\n" +
141+
" sentry dashboard view 12345 --period 7d\n" +
142+
" sentry dashboard view 12345 -r\n" +
143+
" sentry dashboard view 12345 -r 30\n" +
48144
" sentry dashboard view 12345 --web",
49145
},
50146
output: {
51-
human: formatDashboardView,
147+
human: createDashboardViewRenderer,
148+
jsonTransform: transformDashboardViewJson,
52149
},
53150
parameters: {
54151
positional: {
@@ -65,8 +162,21 @@ export const viewCommand = buildCommand({
65162
default: false,
66163
},
67164
fresh: FRESH_FLAG,
165+
refresh: {
166+
kind: "parsed",
167+
parse: parseRefresh,
168+
brief: "Auto-refresh interval in seconds (default: 60, min: 10)",
169+
optional: true,
170+
inferEmpty: true,
171+
},
172+
period: {
173+
kind: "parsed",
174+
parse: String,
175+
brief: 'Time period override (e.g., "24h", "7d", "14d")',
176+
optional: true,
177+
},
68178
},
69-
aliases: { ...FRESH_ALIASES, w: "web" },
179+
aliases: { ...FRESH_ALIASES, w: "web", r: "refresh", t: "period" },
70180
},
71181
async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) {
72182
applyFreshFlag(flags);
@@ -80,20 +190,66 @@ export const viewCommand = buildCommand({
80190
"sentry dashboard view <org>/ <id>"
81191
);
82192
const dashboardId = await resolveDashboardId(orgSlug, dashboardRef);
83-
84193
const url = buildDashboardUrl(orgSlug, dashboardId);
85194

86195
if (flags.web) {
87196
await openInBrowser(url, "dashboard");
88197
return;
89198
}
90199

200+
// Fetch the dashboard definition (widget structure)
91201
const dashboard = await withProgress(
92202
{ message: "Fetching dashboard...", json: flags.json },
93203
() => getDashboard(orgSlug, dashboardId)
94204
);
95205

96-
yield new CommandOutput({ ...dashboard, url } as ViewResult);
206+
const regionUrl = await resolveOrgRegion(orgSlug);
207+
const period = flags.period ?? dashboard.period ?? "24h";
208+
const widgets = dashboard.widgets ?? [];
209+
210+
if (flags.refresh !== undefined) {
211+
// ── Refresh mode: poll and re-render ──
212+
const interval = flags.refresh;
213+
if (!flags.json) {
214+
logger.info(
215+
`Auto-refreshing dashboard every ${interval}s. Press Ctrl+C to stop.`
216+
);
217+
}
218+
219+
const controller = new AbortController();
220+
const stop = () => controller.abort();
221+
process.once("SIGINT", stop);
222+
223+
try {
224+
while (!controller.signal.aborted) {
225+
const widgetData = await queryAllWidgets(
226+
regionUrl,
227+
orgSlug,
228+
dashboard,
229+
{ period }
230+
);
231+
232+
yield new CommandOutput(
233+
buildViewData(dashboard, widgetData, widgets, { period, url })
234+
);
235+
236+
await abortableSleep(interval * 1000, controller.signal);
237+
}
238+
} finally {
239+
process.removeListener("SIGINT", stop);
240+
}
241+
return;
242+
}
243+
244+
// ── Single fetch mode ──
245+
const widgetData = await withProgress(
246+
{ message: "Querying widget data...", json: flags.json },
247+
() => queryAllWidgets(regionUrl, orgSlug, dashboard, { period })
248+
);
249+
250+
yield new CommandOutput(
251+
buildViewData(dashboard, widgetData, widgets, { period, url })
252+
);
97253
return { hint: `Dashboard: ${url}` };
98254
},
99255
});

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)