diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md index 83c2c85ec..8f57dcb17 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md @@ -40,6 +40,7 @@ View a dashboard **Flags:** - `-w, --web - Open in browser` +- `-s, --sixel - Render timeseries widgets as sixel images` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-r, --refresh - Auto-refresh interval in seconds (default: 60, min: 10)` - `-t, --period - Time range: "7d", "2026-07-01..2026-08-01", ">=2026-07-01"` diff --git a/packages/cli/src/commands/dashboard/view.ts b/packages/cli/src/commands/dashboard/view.ts index 39390699c..aa3d810ae 100644 --- a/packages/cli/src/commands/dashboard/view.ts +++ b/packages/cli/src/commands/dashboard/view.ts @@ -54,6 +54,7 @@ type ViewFlags = { readonly period?: TimeRange; readonly json: boolean; readonly fields?: string[]; + readonly sixel: boolean; }; /** @@ -188,6 +189,11 @@ export const viewCommand = buildCommand({ brief: "Open in browser", default: false, }, + sixel: { + kind: "boolean", + brief: "Render timeseries widgets as sixel images", + default: false, + }, fresh: FRESH_FLAG, refresh: { kind: "parsed", @@ -203,10 +209,19 @@ export const viewCommand = buildCommand({ optional: true, }, }, - aliases: { ...FRESH_ALIASES, w: "web", r: "refresh", t: "period" }, + aliases: { + ...FRESH_ALIASES, + w: "web", + s: "sixel", + r: "refresh", + t: "period", + }, }, async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) { applyFreshFlag(flags); + if (flags.sixel) { + process.env.SENTRY_DASHBOARD_SIXEL = "1"; + } const { cwd } = this; const { dashboardRef, targetArg } = parseDashboardPositionalArgs(args); diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index 4889e647c..c5b3311d9 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -19,11 +19,13 @@ import type { TimeseriesResult, WidgetDataResult, } from "../../types/dashboard.js"; +import { getEnv } from "../env.js"; +import { canRenderSixel, terminalPixelWidth } from "../sixel.js"; import { COLORS, muted, terminalLink } from "./colors.js"; import { renderMarkdown } from "./markdown.js"; - import type { HumanRenderer } from "./output.js"; import { isPlainOutput } from "./plain-detect.js"; +import { renderTimeseriesAsSixel } from "./sixel-timeseries.js"; import { downsample, sparkline } from "./sparkline.js"; // --------------------------------------------------------------------------- @@ -1546,7 +1548,25 @@ function renderContentLines(opts: { const { data } = widget; switch (data.type) { - case "timeseries": + case "timeseries": { + // Opt-in sixel image rendering for timeseries widgets. + const env = getEnv(); + if ( + !isPlainOutput() && + (env.SENTRY_DASHBOARD_SIXEL === "1" || + widget.displayType === "timeseries_sixel") && + canRenderSixel() + ) { + const pixelBudget = terminalPixelWidth(); + const sixel = renderTimeseriesAsSixel(data, { + maxPixelWidth: pixelBudget ?? innerWidth * 8, + maxPixelHeight: contentHeight * 12, + }); + if (sixel) { + return [sixel]; + } + } + if (widget.displayType === "categorical_bar") { return renderVerticalBarsContent(data, { innerWidth, contentHeight }); } @@ -1555,6 +1575,7 @@ function renderContentLines(opts: { return renderTimeseriesBarsContent(data, { innerWidth, contentHeight }); } return renderTimeseriesContent(data, innerWidth); + } case "table": return renderTableContent(data, innerWidth); @@ -1616,7 +1637,7 @@ function renderWidgetLines( * If longer, it is truncated (ANSI-aware via character iteration). */ /** ANSI escape sequence type for the truncation state machine. */ -type EscapeType = "none" | "start" | "csi" | "osc"; +type EscapeType = "none" | "start" | "csi" | "osc" | "dcs"; /** Check if a character is an ASCII letter (CSI sequence terminator). */ function isAsciiLetter(ch: string): boolean { @@ -1635,6 +1656,15 @@ function advanceEscape( ch: string, buffer: string ): boolean { + return advanceEscapeInner(state, ch, buffer.at(-1)); +} + +function advanceEscapeInner( + state: { type: EscapeType }, + ch: string, + prev: string | undefined +): boolean { + const stTerminator = ch === "\\" && prev === "\x1b"; switch (state.type) { case "none": if (ch === "\x1b") { @@ -1647,6 +1677,8 @@ function advanceEscape( state.type = "csi"; } else if (ch === "]") { state.type = "osc"; + } else if (ch === "P") { + state.type = "dcs"; } else { state.type = "none"; } @@ -1658,7 +1690,13 @@ function advanceEscape( return true; case "osc": // OSC ends at BEL (\x07) or ST (\x1b\\) - if (ch === "\x07" || (ch === "\\" && buffer.at(-1) === "\x1b")) { + if (ch === "\x07" || stTerminator) { + state.type = "none"; + } + return true; + case "dcs": + // DCS ends at ST (\x1b\\) + if (stTerminator) { state.type = "none"; } return true; diff --git a/packages/cli/src/lib/formatters/index.ts b/packages/cli/src/lib/formatters/index.ts index 51c2381bc..adca23422 100644 --- a/packages/cli/src/lib/formatters/index.ts +++ b/packages/cli/src/lib/formatters/index.ts @@ -14,6 +14,7 @@ export * from "./markdown.js"; export * from "./numbers.js"; export * from "./output.js"; export * from "./seer.js"; +export * from "./sixel-timeseries.js"; export * from "./sparkline.js"; export * from "./table.js"; export * from "./time-utils.js"; diff --git a/packages/cli/src/lib/formatters/sixel-timeseries.ts b/packages/cli/src/lib/formatters/sixel-timeseries.ts new file mode 100644 index 000000000..c8335692d --- /dev/null +++ b/packages/cli/src/lib/formatters/sixel-timeseries.ts @@ -0,0 +1,309 @@ +/** + * Timeseries → sixel chart renderer. + * + * Renders a Sentry timeseries result as a small inline sixel bar chart. + * Builds a tiny RGBA bitmap, draws vertical/stacked bars colored by series, + * then reuses the existing {@link encodeImageToSixel} encoder for a + * terminal-ready DCS escape sequence. + */ + +import type { TimeseriesResult } from "../../types/dashboard.js"; +import { type DecodedImage, encodeImageToSixel } from "../sixel-image.js"; + +export type RenderSixelOpts = { + /** Maximum pixel width of the rendered chart. */ + maxPixelWidth?: number; + /** Maximum pixel height of the rendered chart. */ + maxPixelHeight?: number; + /** Leave the background transparent instead of filling it. */ + backgroundTransparent?: boolean; +}; + +/** Default chart bitmap dimensions. */ +const DEFAULT_WIDTH = 320; +const DEFAULT_HEIGHT = 120; + +/** RGBA for the default background when transparency is off. */ +const BACKGROUND_RGBA: [number, number, number, number] = [30, 30, 30, 255]; + +/** + * Chart color palette matching the dashboard's categorical series colors. + * Kept local to avoid a circular import with dashboard.ts. + */ +const SERIES_PALETTE = [ + "#7553FF", + "#F0369A", + "#C06F20", + "#3D8F09", + "#8B6AC8", + "#E45560", + "#B82D90", + "#9E8B18", + "#228A83", + "#7B50D0", +] as const; + +/** Get the color for a series by index. */ +function chartColor(label: string, index: number): string { + if (label === "Other") { + return "#888888"; + } + return SERIES_PALETTE[index % SERIES_PALETTE.length] ?? "#7553FF"; +} + +/** + * Render a timeseries result as an inline sixel image. + * + * Returns a DCS sixel escape sequence, or `undefined` when the data is empty + * or the bitmap has no drawable pixels. + */ +export function renderTimeseriesAsSixel( + data: TimeseriesResult, + opts: RenderSixelOpts = {} +): string | undefined { + const { + maxPixelWidth = DEFAULT_WIDTH, + maxPixelHeight = DEFAULT_HEIGHT, + backgroundTransparent = true, + } = opts; + + if ( + data.series.length === 0 || + data.series.every((s) => s.values.length === 0) + ) { + return; + } + + const width = Math.max(16, Math.floor(maxPixelWidth)); + const height = Math.max(8, Math.floor(maxPixelHeight)); + + const img = createBitmap(width, height, backgroundTransparent); + + const isMulti = data.series.length > 1; + if (isMulti) { + drawStackedBars(img, data, width, height); + } else { + const first = data.series[0]; + if (first) { + drawSingleSeriesBars(img, first, width, height); + } + } + + return encodeImageToSixel(img, width); +} + +/** Create an RGBA bitmap, optionally transparent. */ +function createBitmap( + width: number, + height: number, + transparent: boolean +): DecodedImage { + const size = width * height * 4; + const data = new Uint8Array(size); + if (!transparent) { + const [r, g, b, a] = BACKGROUND_RGBA; + for (let i = 0; i < size; i += 4) { + data[i] = r; + data[i + 1] = g; + data[i + 2] = b; + data[i + 3] = a; + } + } + return { width, height, data }; +} + +/** Parse an RGB hex color into a 3-tuple. */ +function hexToRgb(hex: string): [number, number, number] { + const normalized = hex.replace("#", ""); + if (normalized.length === 3) { + const r0 = normalized[0]; + const g0 = normalized[1]; + const b0 = normalized[2]; + if (r0 && g0 && b0) { + return [ + Number.parseInt(r0 + r0, 16), + Number.parseInt(g0 + g0, 16), + Number.parseInt(b0 + b0, 16), + ]; + } + } + return [ + Number.parseInt(normalized.slice(0, 2), 16), + Number.parseInt(normalized.slice(2, 4), 16), + Number.parseInt(normalized.slice(4, 6), 16), + ]; +} + +/** Draw a single-series bar chart into the bitmap. */ +function drawSingleSeriesBars( + img: DecodedImage, + series: NonNullable, + width: number, + height: number +): void { + const values = series.values.map((v) => v.value); + if (values.length === 0) { + return; + } + + const maxVal = Math.max(...values, 1); + const color = hexToRgb(chartColor(series.label, 0)); + const layout = computeBarLayout(width, values.length); + + for (let i = 0; i < values.length; i++) { + const value = values[i] ?? 0; + drawValueBar({ img, value, maxVal, index: i, height, color, layout }); + } +} + +type BarLayout = { + gap: number; + barWidth: number; +}; + +/** Compute the gap and width for evenly distributed bars. */ +function computeBarLayout(width: number, count: number): BarLayout { + const gap = Math.max(1, Math.floor(width / count / 8)); + const barWidth = Math.max(1, Math.floor((width - (count - 1) * gap) / count)); + return { gap, barWidth }; +} + +/** Parameters for {@link drawValueBar}. */ +type ValueBarOpts = { + img: DecodedImage; + value: number; + maxVal: number; + index: number; + height: number; + color: [number, number, number]; + layout: BarLayout; +}; + +/** Draw one bar for a single-series chart. */ +function drawValueBar(opts: ValueBarOpts): void { + const { img, value, maxVal, index, height, color, layout } = opts; + const h = (value / maxVal) * height; + const x0 = index * (layout.barWidth + layout.gap); + drawRect(img, { + x: x0, + y: height - Math.round(h), + w: layout.barWidth, + h: Math.round(h), + color, + }); +} + +/** Draw a stacked multi-series bar chart into the bitmap. */ +function drawStackedBars( + img: DecodedImage, + data: TimeseriesResult, + width: number, + height: number +): void { + const firstSeries = data.series[0]; + if (!firstSeries) { + return; + } + + const bucketCount = firstSeries.values.length; + if (bucketCount === 0) { + return; + } + + const totals = computeBucketTotals(data, bucketCount); + const maxTotal = Math.max(...totals, 1); + const layout = computeBarLayout(width, bucketCount); + + for (let b = 0; b < bucketCount; b++) { + const total = totals[b]; + if (total === undefined) { + continue; + } + drawStackedColumn({ img, data, bucket: b, maxTotal, height, layout }); + } +} + +/** Sum each time bucket across all series. */ +function computeBucketTotals( + data: TimeseriesResult, + bucketCount: number +): number[] { + const totals = new Array(bucketCount).fill(0); + for (const s of data.series) { + for (let i = 0; i < bucketCount; i++) { + const total = totals[i]; + const point = s.values[i]; + if (total !== undefined && point) { + totals[i] = total + point.value; + } + } + } + return totals; +} + +/** Parameters for {@link drawStackedColumn}. */ +type StackedColumnOpts = { + img: DecodedImage; + data: TimeseriesResult; + bucket: number; + maxTotal: number; + height: number; + layout: BarLayout; +}; + +/** Draw one stacked column for a given time bucket. */ +function drawStackedColumn(opts: StackedColumnOpts): void { + const { img, data, bucket, maxTotal, height, layout } = opts; + const x0 = bucket * (layout.barWidth + layout.gap); + let yBottom = height; + + for (let s = 0; s < data.series.length; s++) { + const series = data.series[s]; + if (!series) { + continue; + } + const point = series.values[bucket]; + const segmentValue = point?.value ?? 0; + if (segmentValue <= 0 || yBottom <= 0) { + continue; + } + + const segmentHeight = Math.min( + yBottom, + Math.max(1, Math.round((segmentValue / maxTotal) * height)) + ); + const yTop = Math.max(0, yBottom - segmentHeight); + const color = hexToRgb(chartColor(series.label, s)); + drawRect(img, { + x: x0, + y: yTop, + w: layout.barWidth, + h: yBottom - yTop, + color, + }); + yBottom = yTop; + } +} + +/** Parameters for {@link drawRect}. */ +type RectOpts = { + x: number; + y: number; + w: number; + h: number; + color: [number, number, number]; +}; + +/** Fill a solid rectangle in the bitmap. */ +function drawRect(img: DecodedImage, opts: RectOpts): void { + const { x, y, w, h, color } = opts; + for (let py = Math.max(0, y); py < Math.min(img.height, y + h); py++) { + for (let px = Math.max(0, x); px < Math.min(img.width, x + w); px++) { + const i = (py * img.width + px) * 4; + img.data[i] = color[0]; + img.data[i + 1] = color[1]; + img.data[i + 2] = color[2]; + img.data[i + 3] = 255; + } + } +} diff --git a/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts b/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts new file mode 100644 index 000000000..db4485db8 --- /dev/null +++ b/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts @@ -0,0 +1,146 @@ +/** + * Dashboard sixel integration tests. + * + * Stubs `canRenderSixel` and `terminalPixelWidth` so the dashboard formatter + * takes the sixel rendering path deterministically, then verifies that the + * output contains a sixel DCS sequence for eligible timeseries widgets. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + type DashboardViewData, + type DashboardViewWidget, + formatDashboardWithData, +} from "../../../src/lib/formatters/dashboard.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for vi.spyOn mocking +import * as sixelModule from "../../../src/lib/sixel.js"; +import type { TimeseriesResult } from "../../../src/types/dashboard.js"; + +const ESC = "\x1b"; + +function makeTimeseries( + overrides: Partial = {} +): TimeseriesResult { + return { + type: "timeseries", + series: [ + { + label: "count()", + values: [ + { timestamp: 1_700_000_000, value: 10 }, + { timestamp: 1_700_000_060, value: 20 }, + { timestamp: 1_700_000_120, value: 15 }, + { timestamp: 1_700_000_180, value: 30 }, + ], + }, + ], + ...overrides, + }; +} + +function makeWidget( + overrides: Partial = {} +): DashboardViewWidget { + return { + title: "Test Widget", + displayType: "line", + data: makeTimeseries(), + ...overrides, + }; +} + +function makeDashboardData( + overrides: Partial = {} +): DashboardViewData { + return { + id: "12345", + title: "My Dashboard", + period: "24h", + fetchedAt: "2024-01-15T10:30:00Z", + url: "https://sentry.io/organizations/test-org/dashboard/12345/", + environment: ["production"], + widgets: [makeWidget()], + ...overrides, + }; +} + +describe("dashboard sixel integration", () => { + let savedSixelEnv: string | undefined; + + beforeEach(() => { + savedSixelEnv = process.env.SENTRY_DASHBOARD_SIXEL; + process.env.SENTRY_DASHBOARD_SIXEL = "1"; + process.env.SENTRY_PLAIN_OUTPUT = "0"; + vi.spyOn(sixelModule, "canRenderSixel").mockReturnValue(true); + vi.spyOn(sixelModule, "terminalPixelWidth").mockReturnValue(320); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (savedSixelEnv === undefined) { + delete process.env.SENTRY_DASHBOARD_SIXEL; + } else { + process.env.SENTRY_DASHBOARD_SIXEL = savedSixelEnv; + } + }); + + test("renders a sixel DCS sequence for timeseries widgets when enabled", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Sixel Chart", + displayType: "line", + layout: { x: 0, y: 0, w: 6, h: 2 }, + }), + ], + }); + + const output = formatDashboardWithData(data); + expect(output).toContain(`${ESC}P`); + expect(output).toContain(`${ESC}\\`); + expect(output).toContain("Sixel Chart"); + }); + + test("uses displayType=timeseries_sixel as an opt-in signal", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Explicit Sixel", + displayType: "timeseries_sixel", + layout: { x: 0, y: 0, w: 6, h: 2 }, + }), + ], + }); + // Disable the env flag so only the displayType triggers sixel rendering. + delete process.env.SENTRY_DASHBOARD_SIXEL; + + const output = formatDashboardWithData(data); + expect(output).toContain(`${ESC}P`); + expect(output).toContain(`${ESC}\\`); + expect(output).toContain("Explicit Sixel"); + }); + + test("does not emit sixel for non-timeseries widget types", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Big Number", + displayType: "big_number", + data: { type: "scalar", value: 42 }, + layout: { x: 0, y: 0, w: 3, h: 1 }, + }), + makeWidget({ + title: "Sixel Chart", + displayType: "line", + layout: { x: 3, y: 0, w: 3, h: 2 }, + }), + ], + }); + + const output = formatDashboardWithData(data); + expect(output).toContain("Big Number"); + expect(output).toContain("Sixel Chart"); + expect(output).toContain(`${ESC}P`); + expect(output).toContain(`${ESC}\\`); + }); +}); diff --git a/packages/cli/test/lib/formatters/sixel-timeseries.test.ts b/packages/cli/test/lib/formatters/sixel-timeseries.test.ts new file mode 100644 index 000000000..a68fec308 --- /dev/null +++ b/packages/cli/test/lib/formatters/sixel-timeseries.test.ts @@ -0,0 +1,109 @@ +/** + * Timeseries → sixel renderer tests. + */ + +import { describe, expect, test } from "vitest"; +import { renderTimeseriesAsSixel } from "../../../src/lib/formatters/sixel-timeseries.js"; +import type { TimeseriesResult } from "../../../src/types/dashboard.js"; + +const ESC = "\x1b"; + +function makeTimeseries( + overrides: Partial = {} +): TimeseriesResult { + return { + type: "timeseries", + series: [ + { + label: "count()", + values: [ + { timestamp: 1_700_000_000, value: 10 }, + { timestamp: 1_700_000_060, value: 20 }, + { timestamp: 1_700_000_120, value: 15 }, + { timestamp: 1_700_000_180, value: 30 }, + ], + }, + ], + ...overrides, + }; +} + +describe("renderTimeseriesAsSixel", () => { + test("returns undefined when there are no series", () => { + const data = makeTimeseries({ series: [] }); + expect(renderTimeseriesAsSixel(data)).toBeUndefined(); + }); + + test("returns undefined when all series are empty", () => { + const data = makeTimeseries({ + series: [ + { label: "a", values: [] }, + { label: "b", values: [] }, + ], + }); + expect(renderTimeseriesAsSixel(data)).toBeUndefined(); + }); + + test("emits a DCS sixel sequence for a single series", () => { + const data = makeTimeseries(); + const sixel = renderTimeseriesAsSixel(data, { + maxPixelWidth: 64, + maxPixelHeight: 32, + }); + expect(sixel).toBeDefined(); + expect(sixel).toContain(`${ESC}P`); + expect(sixel).toContain(`${ESC}\\`); + }); + + test("emits a DCS sixel sequence for stacked multi-series", () => { + const data = makeTimeseries({ + series: [ + { + label: "alpha", + values: [ + { timestamp: 1_700_000_000, value: 10 }, + { timestamp: 1_700_000_060, value: 20 }, + ], + }, + { + label: "beta", + values: [ + { timestamp: 1_700_000_000, value: 5 }, + { timestamp: 1_700_000_060, value: 10 }, + ], + }, + ], + }); + const sixel = renderTimeseriesAsSixel(data, { + maxPixelWidth: 64, + maxPixelHeight: 32, + }); + expect(sixel).toBeDefined(); + expect(sixel).toContain(`${ESC}P`); + expect(sixel).toContain(`${ESC}\\`); + }); + + test("applies background fill when requested", () => { + const data = makeTimeseries(); + const transparent = renderTimeseriesAsSixel(data, { + maxPixelWidth: 32, + maxPixelHeight: 16, + backgroundTransparent: true, + }); + const opaque = renderTimeseriesAsSixel(data, { + maxPixelWidth: 32, + maxPixelHeight: 16, + backgroundTransparent: false, + }); + expect(transparent).toBeDefined(); + expect(opaque).toBeDefined(); + }); + + test("uses sensible defaults for missing options", () => { + const data = makeTimeseries(); + const sixel = renderTimeseriesAsSixel(data); + expect(sixel).toBeDefined(); + expect(sixel).toContain(`${ESC}P`); + expect(sixel).toContain(`${ESC}\\`); + }); +});