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/chart-core.ts b/packages/cli/src/lib/formatters/chart-core.ts new file mode 100644 index 000000000..d7e18da42 --- /dev/null +++ b/packages/cli/src/lib/formatters/chart-core.ts @@ -0,0 +1,291 @@ +/** + * Shared timeseries chart core. + * + * Turns a {@link TimeseriesResult} into a resolution-independent + * {@link ChartModel}, then rasterizes that model into an RGBA pixel canvas. + * Both the sixel renderer (pixel resolution) and the ASCII renderer + * (character-cell resolution) consume this single core so the two paths + * agree on layout, palette, and stacking. The output resolution is chosen by + * the target and fed in upfront via {@link rasterizeChart}. + */ + +import type { TimeseriesResult } from "../../types/dashboard.js"; +import type { DecodedImage } from "../sixel-image.js"; + +/** + * Chart color palette based on Sentry's categorical chart hues. + * + * Derived from sentry/static/app/utils/theme/scraps/tokens/color.tsx + * (categorical.dark / categorical.light), adjusted to a mid-luminance range + * so every color achieves ≥3:1 contrast on both dark (#1e1e1e) and light + * (#f0f0f0) terminal backgrounds. "Other" always gets muted gray. + */ +export const SERIES_PALETTE = [ + "#7553FF", // blurple (Sentry primary) + "#F0369A", // pink + "#C06F20", // orange (darkened from #FF9838) + "#3D8F09", // green (darkened from #67C800) + "#8B6AC8", // purple (lightened from #5D3EB2) + "#E45560", // salmon (darkened from #FA6769) + "#B82D90", // magenta + "#9E8B18", // yellow (darkened from #FFD00E) + "#228A83", // teal (fills hue gap) + "#7B50D0", // indigo (lightened from #50219C) +] as const; + +/** Muted gray for the "Other" bucket. */ +export const OTHER_COLOR = "#888888"; + +/** Get the hex color for a series by index. "Other" gets muted gray. */ +export function seriesColor(label: string, index: number): string { + if (label === "Other") { + return OTHER_COLOR; + } + return SERIES_PALETTE[index % SERIES_PALETTE.length] ?? SERIES_PALETTE[0]; +} + +/** Parse an RGB hex color into a 3-tuple. */ +export 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), + ]; +} + +/** One series in a chart model: a label plus its per-bucket values. */ +export type ChartSeries = { + label: string; + values: number[]; +}; + +/** + * Resolution-independent chart description. + * + * `buckets` is the number of time buckets (columns). `maxVal` is the + * axis maximum: the largest single value for a single series, or the largest + * per-bucket total for a stacked chart. `stacked` records whether the columns + * are drawn as stacked segments (multi-series) or as plain bars (single). + */ +export type ChartModel = { + series: ChartSeries[]; + buckets: number; + maxVal: number; + stacked: boolean; +}; + +/** Build a resolution-independent chart model from a timeseries result. */ +export function buildChartModel( + data: TimeseriesResult +): ChartModel | undefined { + if ( + data.series.length === 0 || + data.series.every((s) => s.values.length === 0) + ) { + return; + } + + const series: ChartSeries[] = data.series.map((s) => ({ + label: s.label, + values: s.values.map((v) => v.value), + })); + const buckets = Math.max(...series.map((s) => s.values.length)); + const stacked = series.length > 1; + + const maxVal = stacked + ? Math.max(...bucketTotals(series, buckets), 1) + : Math.max(...(series[0]?.values ?? []), 1); + + return { series, buckets, maxVal, stacked }; +} + +/** Sum each bucket across every series. */ +function bucketTotals(series: ChartSeries[], buckets: number): number[] { + const totals = new Array(buckets).fill(0); + for (const s of series) { + for (let i = 0; i < buckets; i++) { + const total = totals[i]; + const value = s.values[i]; + if (total !== undefined && value !== undefined) { + totals[i] = total + value; + } + } + } + return totals; +} + +/** RGBA for the default background when transparency is off. */ +const BACKGROUND_RGBA: [number, number, number, number] = [30, 30, 30, 255]; + +/** Options for {@link rasterizeChart}. */ +export type RasterizeOpts = { + /** Target canvas width in pixels. */ + width: number; + /** Target canvas height in pixels. */ + height: number; + /** Leave the background transparent instead of filling it. */ + backgroundTransparent?: boolean; +}; + +/** + * Rasterize a chart model into an RGBA pixel canvas at the given resolution. + * + * This is the pixel core: the resolution is chosen by the caller for its + * output target (sixel cell pixels, or an ASCII cell-grid multiple). Returns + * `undefined` when the model has no buckets to draw. + */ +export function rasterizeChart( + model: ChartModel, + opts: RasterizeOpts +): DecodedImage | undefined { + const width = Math.max(16, Math.floor(opts.width)); + const height = Math.max(8, Math.floor(opts.height)); + if (model.buckets === 0) { + return; + } + + const img = createCanvas(width, height, opts.backgroundTransparent ?? true); + const layout = computeBarLayout(width, model.buckets); + + if (model.stacked) { + drawStackedColumns(img, model, height, layout); + } else { + drawBars(img, model, height, layout); + } + + return img; +} + +/** Create an RGBA canvas, optionally transparent. */ +function createCanvas( + 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 }; +} + +/** Gap and bar width for evenly distributed columns. */ +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 }; +} + +/** Draw single-series bars into the canvas. */ +function drawBars( + img: DecodedImage, + model: ChartModel, + height: number, + layout: BarLayout +): void { + const series = model.series[0]; + if (!series) { + return; + } + const color = hexToRgb(seriesColor(series.label, 0)); + + for (let i = 0; i < series.values.length; i++) { + const value = series.values[i] ?? 0; + const h = Math.round((value / model.maxVal) * height); + const x0 = i * (layout.barWidth + layout.gap); + drawRect(img, { + x: x0, + y: height - h, + w: layout.barWidth, + h, + color, + }); + } +} + +/** Draw stacked multi-series columns into the canvas. */ +function drawStackedColumns( + img: DecodedImage, + model: ChartModel, + height: number, + layout: BarLayout +): void { + for (let b = 0; b < model.buckets; b++) { + const x0 = b * (layout.barWidth + layout.gap); + let yBottom = height; + + for (let s = 0; s < model.series.length; s++) { + const series = model.series[s]; + if (!series) { + continue; + } + const value = series.values[b] ?? 0; + if (value <= 0 || yBottom <= 0) { + continue; + } + + const segmentHeight = Math.min( + yBottom, + Math.max(1, Math.round((value / model.maxVal) * height)) + ); + const yTop = Math.max(0, yBottom - segmentHeight); + drawRect(img, { + x: x0, + y: yTop, + w: layout.barWidth, + h: yBottom - yTop, + color: hexToRgb(seriesColor(series.label, s)), + }); + 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 canvas. */ +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/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index 4889e647c..7e4311ac6 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -19,11 +19,14 @@ import type { TimeseriesResult, WidgetDataResult, } from "../../types/dashboard.js"; +import { getEnv } from "../env.js"; +import { canRenderSixel, terminalPixelWidth } from "../sixel.js"; +import { SERIES_PALETTE } from "./chart-core.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"; // --------------------------------------------------------------------------- @@ -1210,29 +1213,6 @@ function renderTimeBarRows( return rows; } -/** - * Chart color palette based on Sentry's categorical chart hues. - * - * Derived from sentry/static/app/utils/theme/scraps/tokens/color.tsx - * (categorical.dark / categorical.light), adjusted to a mid-luminance - * range so every color achieves ≥3:1 contrast on **both** dark (#1e1e1e) - * and light (#f0f0f0) terminal backgrounds. - * - * "Other" always gets muted gray (handled by seriesColor). - */ -const SERIES_PALETTE = [ - "#7553FF", // blurple (Sentry primary) - "#F0369A", // pink - "#C06F20", // orange (darkened from #FF9838) - "#3D8F09", // green (darkened from #67C800) - "#8B6AC8", // purple (lightened from #5D3EB2) - "#E45560", // salmon (darkened from #FA6769) - "#B82D90", // magenta - "#9E8B18", // yellow (darkened from #FFD00E) - "#228A83", // teal (fills hue gap) - "#7B50D0", // indigo (lightened from #50219C) -] as const; - /** * Fill characters for plain/no-color mode. * @@ -1241,7 +1221,13 @@ const SERIES_PALETTE = [ */ const PLAIN_FILLS = ["█", "▓", "▒", "#", "=", "*", "+", "~", ":", "."] as const; -/** Get the color for a series by index. "Other" gets muted gray. */ +/** + * Get the color for a series by index. "Other" gets muted gray. + * + * Shares {@link SERIES_PALETTE} with the pixel chart core so the ASCII and + * sixel renderers use identical hues; only the "Other" bucket differs (ANSI + * muted vs the core's hex gray). + */ function seriesColor(label: string, index: number): string { if (label === "Other") { return COLORS.muted; @@ -1546,7 +1532,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 +1559,7 @@ function renderContentLines(opts: { return renderTimeseriesBarsContent(data, { innerWidth, contentHeight }); } return renderTimeseriesContent(data, innerWidth); + } case "table": return renderTableContent(data, innerWidth); @@ -1616,7 +1621,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 +1640,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 +1661,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 +1674,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..6df80358a --- /dev/null +++ b/packages/cli/src/lib/formatters/sixel-timeseries.ts @@ -0,0 +1,58 @@ +/** + * Timeseries → sixel chart renderer. + * + * Thin wrapper over the shared chart core (see {@link buildChartModel} and + * {@link rasterizeChart}): builds the resolution-independent model, rasterizes + * it at the caller's pixel resolution, then reuses the existing + * {@link encodeImageToSixel} encoder for a terminal-ready DCS escape sequence. + */ + +import type { TimeseriesResult } from "../../types/dashboard.js"; +import { encodeImageToSixel } from "../sixel-image.js"; +import { buildChartModel, rasterizeChart } from "./chart-core.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; + +/** + * 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; + + const model = buildChartModel(data); + if (!model) { + return; + } + + const img = rasterizeChart(model, { + width: maxPixelWidth, + height: maxPixelHeight, + backgroundTransparent, + }); + if (!img) { + return; + } + + return encodeImageToSixel(img, img.width); +} diff --git a/packages/cli/test/lib/formatters/chart-core.test.ts b/packages/cli/test/lib/formatters/chart-core.test.ts new file mode 100644 index 000000000..c245d5ad8 --- /dev/null +++ b/packages/cli/test/lib/formatters/chart-core.test.ts @@ -0,0 +1,149 @@ +/** + * Shared chart core tests. + */ + +import { describe, expect, test } from "vitest"; +import { + buildChartModel, + hexToRgb, + rasterizeChart, + SERIES_PALETTE, + seriesColor, +} from "../../../src/lib/formatters/chart-core.js"; +import type { TimeseriesResult } from "../../../src/types/dashboard.js"; + +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("seriesColor", () => { + test("returns muted gray for the Other bucket", () => { + expect(seriesColor("Other", 3)).toBe("#888888"); + }); + + test("cycles through the palette by index", () => { + expect(seriesColor("a", 0)).toBe(SERIES_PALETTE[0]); + expect(seriesColor("a", SERIES_PALETTE.length)).toBe(SERIES_PALETTE[0]); + expect(seriesColor("a", 1)).toBe(SERIES_PALETTE[1]); + }); +}); + +describe("hexToRgb", () => { + test("parses six-digit hex", () => { + expect(hexToRgb("#7553FF")).toEqual([0x75, 0x53, 0xff]); + }); + + test("parses shorthand three-digit hex", () => { + expect(hexToRgb("#0f8")).toEqual([0x00, 0xff, 0x88]); + }); +}); + +describe("buildChartModel", () => { + test("returns undefined for empty series", () => { + expect(buildChartModel(makeTimeseries({ series: [] }))).toBeUndefined(); + }); + + test("returns undefined when every series is empty", () => { + const model = buildChartModel( + makeTimeseries({ + series: [ + { label: "a", values: [] }, + { label: "b", values: [] }, + ], + }) + ); + expect(model).toBeUndefined(); + }); + + test("builds a single-series, non-stacked model with peak maxVal", () => { + const model = buildChartModel(makeTimeseries()); + expect(model).toBeDefined(); + expect(model?.stacked).toBe(false); + expect(model?.buckets).toBe(4); + expect(model?.maxVal).toBe(30); + }); + + test("builds a stacked model with per-bucket totals as maxVal", () => { + const model = buildChartModel( + makeTimeseries({ + series: [ + { + label: "alpha", + values: [ + { timestamp: 1, value: 10 }, + { timestamp: 2, value: 20 }, + ], + }, + { + label: "beta", + values: [ + { timestamp: 1, value: 5 }, + { timestamp: 2, value: 10 }, + ], + }, + ], + }) + ); + expect(model?.stacked).toBe(true); + expect(model?.buckets).toBe(2); + // Largest per-bucket total is 20 + 10 = 30. + expect(model?.maxVal).toBe(30); + }); +}); + +describe("rasterizeChart", () => { + test("returns a canvas at the requested resolution", () => { + const model = buildChartModel(makeTimeseries()); + const img = rasterizeChart(model!, { width: 64, height: 32 }); + expect(img).toBeDefined(); + expect(img?.width).toBe(64); + expect(img?.height).toBe(32); + expect(img?.data.length).toBe(64 * 32 * 4); + }); + + test("clamps resolution to a minimum size", () => { + const model = buildChartModel(makeTimeseries()); + const img = rasterizeChart(model!, { width: 1, height: 1 }); + expect(img?.width).toBe(16); + expect(img?.height).toBe(8); + }); + + test("draws opaque pixels for bars", () => { + const model = buildChartModel(makeTimeseries()); + const img = rasterizeChart(model!, { width: 64, height: 32 }); + let opaque = 0; + for (let i = 3; i < (img?.data.length ?? 0); i += 4) { + if ((img?.data[i] ?? 0) > 0) { + opaque += 1; + } + } + expect(opaque).toBeGreaterThan(0); + }); + + test("fills the background when transparency is off", () => { + const model = buildChartModel(makeTimeseries()); + const img = rasterizeChart(model!, { + width: 32, + height: 16, + backgroundTransparent: false, + }); + // Top-left pixel is above the bars, so it shows the background fill. + expect(img?.data[3]).toBe(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}\\`); + }); +});