Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 <value> - Auto-refresh interval in seconds (default: 60, min: 10)`
- `-t, --period <value> - Time range: "7d", "2026-07-01..2026-08-01", ">=2026-07-01"`
Expand Down
17 changes: 16 additions & 1 deletion packages/cli/src/commands/dashboard/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type ViewFlags = {
readonly period?: TimeRange;
readonly json: boolean;
readonly fields?: string[];
readonly sixel: boolean;
};

/**
Expand Down Expand Up @@ -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",
Expand All @@ -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);
Expand Down
291 changes: 291 additions & 0 deletions packages/cli/src/lib/formatters/chart-core.ts
Original file line number Diff line number Diff line change
@@ -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<number>(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;
}
}
}
Loading
Loading