Skip to content

Commit a4b0e70

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 a4b0e70

File tree

12 files changed

+3860
-125
lines changed

12 files changed

+3860
-125
lines changed

AGENTS.md

Lines changed: 43 additions & 105 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: 174 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,147 @@
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 { CommandOutput } from "../../lib/formatters/output.js";
13+
import type { DashboardViewData } from "../../lib/formatters/dashboard.js";
14+
import { createDashboardViewRenderer } from "../../lib/formatters/dashboard.js";
15+
import { ClearScreen, CommandOutput } from "../../lib/formatters/output.js";
1416
import {
1517
applyFreshFlag,
1618
FRESH_ALIASES,
1719
FRESH_FLAG,
1820
} from "../../lib/list-command.js";
21+
import { logger } from "../../lib/logger.js";
1922
import { withProgress } from "../../lib/polling.js";
23+
import { resolveOrgRegion } from "../../lib/region.js";
2024
import { buildDashboardUrl } from "../../lib/sentry-urls.js";
21-
import type { DashboardDetail } from "../../types/dashboard.js";
25+
import type {
26+
DashboardWidget,
27+
WidgetDataResult,
28+
} from "../../types/dashboard.js";
2229
import {
2330
parseDashboardPositionalArgs,
2431
resolveDashboardId,
2532
resolveOrgFromTarget,
2633
} from "./resolve.js";
2734

35+
/** Default auto-refresh interval in seconds */
36+
const DEFAULT_REFRESH_INTERVAL = 60;
37+
38+
/** Minimum auto-refresh interval in seconds (avoid rate limiting) */
39+
const MIN_REFRESH_INTERVAL = 10;
40+
2841
type ViewFlags = {
2942
readonly web: boolean;
3043
readonly fresh: boolean;
44+
readonly refresh?: number;
45+
readonly period?: string;
3146
readonly json: boolean;
3247
readonly fields?: string[];
3348
};
3449

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

37125
export const viewCommand = buildCommand({
38126
docs: {
39127
brief: "View a dashboard",
40128
fullDescription:
41-
"View details of a specific Sentry dashboard.\n\n" +
129+
"View a Sentry dashboard with rendered widget data.\n\n" +
130+
"Fetches actual data for each widget and displays sparkline charts,\n" +
131+
"tables, and big numbers in the terminal.\n\n" +
42132
"The dashboard can be specified by numeric ID or title.\n\n" +
43133
"Examples:\n" +
44134
" sentry dashboard view 12345\n" +
45135
" sentry dashboard view 'My Dashboard'\n" +
46136
" sentry dashboard view my-org/ 12345\n" +
47137
" sentry dashboard view 12345 --json\n" +
138+
" sentry dashboard view 12345 --period 7d\n" +
139+
" sentry dashboard view 12345 -r\n" +
140+
" sentry dashboard view 12345 -r 30\n" +
48141
" sentry dashboard view 12345 --web",
49142
},
50143
output: {
51-
human: formatDashboardView,
144+
human: createDashboardViewRenderer,
52145
},
53146
parameters: {
54147
positional: {
@@ -65,8 +158,21 @@ export const viewCommand = buildCommand({
65158
default: false,
66159
},
67160
fresh: FRESH_FLAG,
161+
refresh: {
162+
kind: "parsed",
163+
parse: parseRefresh,
164+
brief: "Auto-refresh interval in seconds (default: 60, min: 10)",
165+
optional: true,
166+
inferEmpty: true,
167+
},
168+
period: {
169+
kind: "parsed",
170+
parse: String,
171+
brief: 'Time period override (e.g., "24h", "7d", "14d")',
172+
optional: true,
173+
},
68174
},
69-
aliases: { ...FRESH_ALIASES, w: "web" },
175+
aliases: { ...FRESH_ALIASES, w: "web", r: "refresh", t: "period" },
70176
},
71177
async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) {
72178
applyFreshFlag(flags);
@@ -80,20 +186,77 @@ export const viewCommand = buildCommand({
80186
"sentry dashboard view <org>/ <id>"
81187
);
82188
const dashboardId = await resolveDashboardId(orgSlug, dashboardRef);
83-
84189
const url = buildDashboardUrl(orgSlug, dashboardId);
85190

86191
if (flags.web) {
87192
await openInBrowser(url, "dashboard");
88193
return;
89194
}
90195

196+
// Fetch the dashboard definition (widget structure)
91197
const dashboard = await withProgress(
92198
{ message: "Fetching dashboard...", json: flags.json },
93199
() => getDashboard(orgSlug, dashboardId)
94200
);
95201

96-
yield new CommandOutput({ ...dashboard, url } as ViewResult);
202+
const regionUrl = await resolveOrgRegion(orgSlug);
203+
const period = flags.period ?? dashboard.period ?? "24h";
204+
const widgets = dashboard.widgets ?? [];
205+
206+
if (flags.refresh !== undefined) {
207+
// ── Refresh mode: poll and re-render ──
208+
const interval = flags.refresh;
209+
if (!flags.json) {
210+
logger.info(
211+
`Auto-refreshing dashboard every ${interval}s. Press Ctrl+C to stop.`
212+
);
213+
}
214+
215+
const controller = new AbortController();
216+
const stop = () => controller.abort();
217+
process.once("SIGINT", stop);
218+
219+
let isFirstRender = true;
220+
221+
try {
222+
while (!controller.signal.aborted) {
223+
const widgetData = await queryAllWidgets(
224+
regionUrl,
225+
orgSlug,
226+
dashboard,
227+
{ period }
228+
);
229+
230+
// Build output data before clearing so clear→render is instantaneous
231+
const viewData = buildViewData(dashboard, widgetData, widgets, {
232+
period,
233+
url,
234+
});
235+
236+
if (!isFirstRender) {
237+
yield new ClearScreen();
238+
}
239+
isFirstRender = false;
240+
241+
yield new CommandOutput(viewData);
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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export {
2323
createDashboard,
2424
getDashboard,
2525
listDashboards,
26+
queryAllWidgets,
2627
updateDashboard,
2728
} from "./api/dashboards.js";
2829
export {

0 commit comments

Comments
 (0)