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..63bc6630b --- /dev/null +++ b/packages/cli/src/lib/formatters/chart-core.ts @@ -0,0 +1,315 @@ +/** + * 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"; +import { downsample } from "./sparkline.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; + } + + // Each column needs at least a 1px bar plus a 1px gap, so more buckets than + // ~half the canvas width would push later columns off-canvas and clip them. + // Downsample to fit, mirroring what the ASCII sparkline path already does. + const fitted = fitModelToWidth(model, width); + + const img = createCanvas(width, height, opts.backgroundTransparent ?? true); + const layout = computeBarLayout(width, fitted.buckets); + + if (fitted.stacked) { + drawStackedColumns(img, fitted, height, layout); + } else { + drawBars(img, fitted, height, layout); + } + + return img; +} + +/** + * Downsample a model's series so the bucket count fits the canvas: with a 1px + * bar and 1px gap each column needs ~2px, so cap buckets at `width / 2`. + * Returns the model unchanged when it already fits. + */ +function fitModelToWidth(model: ChartModel, width: number): ChartModel { + const maxBuckets = Math.max(1, Math.floor(width / 2)); + if (model.buckets <= maxBuckets) { + return model; + } + const series = model.series.map((s) => ({ + label: s.label, + values: downsample(s.values, maxBuckets), + })); + const buckets = Math.max(...series.map((s) => s.values.length)); + return { ...model, series, buckets }; +} + +/** 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..b51d562a4 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,7 @@ function renderContentLines(opts: { const { data } = widget; switch (data.type) { - case "timeseries": + case "timeseries": { if (widget.displayType === "categorical_bar") { return renderVerticalBarsContent(data, { innerWidth, contentHeight }); } @@ -1555,6 +1541,7 @@ function renderContentLines(opts: { return renderTimeseriesBarsContent(data, { innerWidth, contentHeight }); } return renderTimeseriesContent(data, innerWidth); + } case "table": return renderTableContent(data, innerWidth); @@ -1616,7 +1603,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 +1622,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 +1643,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 +1656,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; @@ -1675,7 +1679,7 @@ function fitToWidth(line: string, targetWidth: number): string { // Truncate: walk characters, tracking visible width let result = ""; let width = 0; - const esc = { type: "none" as "none" | "start" | "csi" | "osc" }; + const esc: { type: EscapeType } = { type: "none" }; for (const ch of line) { if (advanceEscape(esc, ch, result)) { result += ch; @@ -1840,11 +1844,146 @@ export function formatDashboardWithData(data: DashboardViewData): string { const termWidth = getTermWidth(); const lines: string[] = []; lines.push(...renderHeader(data, termWidth)); - lines.push(...renderGrid(data.widgets, termWidth)); + + // Sixel widgets can't be composed into the side-by-side framebuffer: a DCS + // image advances the cursor by many rows, which would trample the widget's + // own borders and any neighbor sharing its grid rows. Render them full-width + // and stacked, after the character grid, and keep the grid for the rest. + const sixelWidgets = data.widgets.filter(isSixelEligible); + const gridWidgets = sixelWidgets.length + ? data.widgets.filter((w) => !isSixelEligible(w)) + : data.widgets; + + if (gridWidgets.length > 0) { + const packed = packGridY(gridWidgets); + lines.push(...renderGrid(packed, termWidth)); + } + for (const w of sixelWidgets) { + lines.push(...renderSixelWidget(w, termWidth)); + } + lines.push(""); return lines.join("\n"); } +/** + * Return a shallow copy of the widgets with their `layout.y` renumbered so + * the remaining widgets pack contiguously from y=0 without blank bands. + * Widgets without a layout are left untouched. Relative order within the + * same original y-band is preserved (important for left/right widgets on + * the same row). + * + * Packing is skipped for staggered layouts (widgets whose start y lies + * inside another widget's span) because correctly re-computing y for + * overlapping columns is out of scope for this opt-in experimental path. + */ +function packGridY( + widgets: DashboardViewWidget[] +): DashboardViewWidget[] { + const withLayout = widgets.filter((w) => w.layout); + if (withLayout.length === 0) { + return widgets; + } + + // Detect staggered starts: a widget whose y is not equal to any other + // widget's y + h (for the tallest h at that y). + const bands = new Map(); // y -> max h at that y + for (const w of withLayout) { + const y = w.layout!.y; + const h = w.layout!.h ?? 1; + bands.set(y, Math.max(bands.get(y) ?? 0, h)); + } + let maxEnd = 0; + let staggered = false; + for (const [y, h] of Array.from(bands.entries()).sort( + (a, b) => a[0] - b[0] + )) { + if (y < maxEnd) { + staggered = true; + break; + } + maxEnd = y + h; + } + if (staggered) { + return widgets; // leave original y untouched + } + + // Stable sort by original y so relative vertical order is preserved. + const sorted = [...withLayout].sort( + (a, b) => (a.layout?.y ?? 0) - (b.layout?.y ?? 0) + ); + + let nextY = 0; + const newY = new Map(); + for (const w of sorted) { + if (newY.has(w)) { + continue; + } + const bandY = w.layout!.y; + const band = sorted.filter((ww) => ww.layout!.y === bandY); + for (const b of band) { + newY.set(b, nextY); + } + const maxH = Math.max(...band.map((b) => b.layout!.h ?? 1)); + nextY += maxH; + } + + return widgets.map((w) => { + if (!w.layout) { + return w; + } + const ny = newY.get(w); + if (ny === undefined) { + return w; + } + return { ...w, layout: { ...w.layout, y: ny } }; + }); +} + +/** + * Whether a widget should render as an inline sixel image: opt-in is on, the + * data is a plain (non-categorical) timeseries, and the terminal supports + * sixel. Categorical bars are excluded — the chart core only models + * time-bucket columns, so they keep the ASCII per-category renderer. + */ +function isSixelEligible(widget: DashboardViewWidget): boolean { + if ( + widget.data.type !== "timeseries" || + widget.displayType === "categorical_bar" + ) { + return false; + } + const env = getEnv(); + const optedIn = + env.SENTRY_DASHBOARD_SIXEL === "1" || + widget.displayType === "timeseries_sixel"; + return optedIn && !isPlainOutput() && canRenderSixel(); +} + +/** + * Render a single sixel widget as a full-width block: a title line followed by + * the inline image. Falls back to the normal bordered character rendering when + * the image can't be produced (empty data, no drawable pixels). + */ +function renderSixelWidget( + widget: DashboardViewWidget, + termWidth: number +): string[] { + if (widget.data.type !== "timeseries") { + return renderWidgetLines(widget, termWidth); + } + const pixelBudget = terminalPixelWidth(); + const sixel = renderTimeseriesAsSixel(widget.data, { + maxPixelWidth: pixelBudget ?? termWidth * 8, + maxPixelHeight: 2 * LINES_PER_UNIT * 12, + }); + if (!sixel) { + return renderWidgetLines(widget, termWidth); + } + const title = isPlainOutput() ? widget.title : chalk.bold(widget.title); + return ["", title, sixel]; +} + // --------------------------------------------------------------------------- // HumanRenderer factory (supports --refresh mode) // --------------------------------------------------------------------------- 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..8f4cef672 --- /dev/null +++ b/packages/cli/test/lib/formatters/chart-core.test.ts @@ -0,0 +1,176 @@ +/** + * 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); + }); + + test("downsamples dense series so late buckets stay on canvas", () => { + // Far more buckets than half the canvas width: without downsampling the + // rising tail would be clipped off the right edge. Put all the signal in + // the last quarter of the range so a clipped render would be near-empty. + const values = Array.from({ length: 400 }, (_, i) => ({ + timestamp: 1_700_000_000 + i * 60, + value: i < 300 ? 0 : i, + })); + const model = buildChartModel( + makeTimeseries({ series: [{ label: "c", values }] }) + ); + const width = 64; + const img = rasterizeChart(model!, { width, height: 32 }); + + // Count opaque pixels in the right quarter — the tail must survive. + let rightOpaque = 0; + const data = img?.data ?? new Uint8Array(); + for (let y = 0; y < 32; y++) { + for (let x = Math.floor(width * 0.75); x < width; x++) { + if ((data[(y * width + x) * 4 + 3] ?? 0) > 0) { + rightOpaque += 1; + } + } + } + expect(rightOpaque).toBeGreaterThan(0); + }); +}); 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..93a779a58 --- /dev/null +++ b/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts @@ -0,0 +1,193 @@ +/** + * 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}\\`); + }); + + test("categorical_bar widgets keep the ASCII renderer, not sixel", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Categorical", + displayType: "categorical_bar", + layout: { x: 0, y: 0, w: 6, h: 2 }, + }), + ], + }); + + const output = formatDashboardWithData(data); + expect(output).toContain("Categorical"); + // The chart core has no categorical mode, so these must not be rasterized. + expect(output).not.toContain(`${ESC}P`); + }); + + test("sixel widgets render as a standalone block, keeping the grid intact", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Big Number", + displayType: "big_number", + data: { type: "scalar", value: 42 }, + layout: { x: 0, y: 0, w: 3, h: 2 }, + }), + makeWidget({ + title: "Sixel Chart", + displayType: "line", + layout: { x: 3, y: 0, w: 3, h: 2 }, + }), + ], + }); + + const output = formatDashboardWithData(data); + const lines = output.split("\n"); + // The DCS image must not share a terminal row with any bordered widget. + const sixelLine = lines.find((l) => l.includes(`${ESC}P`)); + expect(sixelLine).toBeDefined(); + expect(sixelLine).not.toContain("│"); + expect(sixelLine).not.toContain("─"); + // The character grid (big-number widget) still renders; the sixel image + // appears after the grid (full-width, no hole in the packed layout). + expect(output).toContain("Big Number"); + expect(output).toContain("Sixel Chart"); + }); +}); 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}\\`); + }); +});