Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/number-tile-background-chart-external-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hyperdx/api": patch
---

Add `backgroundChart` support to number tiles in the external dashboards API (`/api/v2/dashboards`). Builder number tiles can now carry an optional background trend sparkline (`type` line or area, with an optional palette-token `color`) over the v2 REST API, matching the dashboard editor. Raw SQL number tiles do not support it.
26 changes: 26 additions & 0 deletions packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -985,6 +985,28 @@
"description": "Palette token used to color a number tile. Tokens reflow across light and dark themes, so raw hex values are not accepted.\n",
"example": "chart-red"
},
"BackgroundChart": {
"type": "object",
"required": [
"type"
],
"description": "Optional background trend sparkline drawn behind a number tile's value, derived from a time-bucketed version of the tile's query. Builder number tiles only (raw SQL number tiles have no time dimension to bucket).\n",
"properties": {
"type": {
"type": "string",
"enum": [
"line",
"area"
],
"description": "Sparkline shape.",
"example": "line"
},
"color": {
"$ref": "#/components/schemas/ChartPaletteToken",
"description": "Optional palette-token override for the sparkline. When unset the sparkline inherits the tile's static color.\n"
}
}
},
"NumericColorCondition": {
"type": "object",
"required": [
Expand Down Expand Up @@ -1783,6 +1805,10 @@
"items": {
"$ref": "#/components/schemas/NumberTileColorCondition"
}
},
"backgroundChart": {
"$ref": "#/components/schemas/BackgroundChart",
"description": "Optional background trend sparkline drawn behind the value.\n"
}
}
},
Expand Down
171 changes: 171 additions & 0 deletions packages/api/src/routers/external-api/__tests__/dashboards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2371,6 +2371,7 @@ describe('External API v2 Dashboards - new format', () => {
label: 'Critical',
},
],
backgroundChart: { type: 'area', color: 'chart-blue' },
},
};

Expand Down Expand Up @@ -3898,6 +3899,7 @@ describe('External API v2 Dashboards - new format', () => {
label: 'Critical',
},
],
backgroundChart: { type: 'area', color: 'chart-blue' },
},
};

Expand Down Expand Up @@ -4910,6 +4912,175 @@ describe('External API v2 Dashboards - new format', () => {
});
});

describe('Number tile background chart (HDX-1360)', () => {
// Minimal builder number tile; callers supply backgroundChart. The payload
// is sent through `.send()` (untyped) so negative tests can post
// intentionally invalid values without tripping the compile-time schema.
const numberTile = (config: Record<string, unknown>) => ({
name: 'Number',
x: 0,
y: 0,
w: 3,
h: 3,
config: {
displayType: 'number',
sourceId: traceSource._id.toString(),
select: [{ aggFn: 'count', where: '' }],
...config,
},
});

const postTile = (config: Record<string, unknown>) =>
authRequest('post', BASE_URL).send({
name: 'Number background chart dashboard',
tiles: [numberTile(config)],
tags: [],
});

const rawSqlNumberTile = (config: Record<string, unknown>) => ({
name: 'Number Raw SQL',
x: 0,
y: 0,
w: 3,
h: 3,
config: {
configType: 'sql',
displayType: 'number',
connectionId: connection._id.toString(),
sqlTemplate: 'SELECT count() FROM otel_logs WHERE {timeFilter}',
sourceId: traceSource._id.toString(),
...config,
},
});

// ── Positive: one per UI input ──────────────────────────────────────

it('round-trips a builder number tile with a line background chart', async () => {
const create = await postTile({
backgroundChart: { type: 'line' },
}).expect(200);
expect(create.body.data.tiles[0].config.backgroundChart).toEqual({
type: 'line',
});

const get = await authRequest(
'get',
`${BASE_URL}/${create.body.data.id}`,
).expect(200);
expect(get.body.data.tiles[0].config.backgroundChart).toEqual({
type: 'line',
});
});

it('round-trips an area background chart with a color override', async () => {
const create = await postTile({
backgroundChart: { type: 'area', color: 'chart-green' },
}).expect(200);
expect(create.body.data.tiles[0].config.backgroundChart).toEqual({
type: 'area',
color: 'chart-green',
});

const get = await authRequest(
'get',
`${BASE_URL}/${create.body.data.id}`,
).expect(200);
expect(get.body.data.tiles[0].config.backgroundChart).toEqual({
type: 'area',
color: 'chart-green',
});
});

it('round-trips backgroundChart through an update (PUT)', async () => {
const created = await postTile({}).expect(200);
const dashboardId = created.body.data.id;
const tile = created.body.data.tiles[0];

const update = await authRequest('put', `${BASE_URL}/${dashboardId}`)
.send({
name: 'Number background chart dashboard',
tiles: [
{
...tile,
config: {
...tile.config,
backgroundChart: { type: 'line', color: 'chart-red' },
},
},
],
tags: [],
})
.expect(200);
expect(update.body.data.tiles[0].config.backgroundChart).toEqual({
type: 'line',
color: 'chart-red',
});

const get = await authRequest('get', `${BASE_URL}/${dashboardId}`).expect(
200,
);
expect(get.body.data.tiles[0].config.backgroundChart).toEqual({
type: 'line',
color: 'chart-red',
});
});

// ── Negative: one per schema rejection rule ─────────────────────────

it('rejects a background chart type outside the line/area enum', async () => {
const res = await postTile({
backgroundChart: { type: 'bar' },
}).expect(400);
expect(res.body.message).toContain('tiles.0.config.backgroundChart');
});

it('rejects a background chart with no type', async () => {
// `type` is required; a color override alone is not a valid sparkline.
await postTile({
backgroundChart: { color: 'chart-green' },
}).expect(400);
});

it('rejects a background chart color that is not a palette token', async () => {
await postTile({
backgroundChart: { type: 'line', color: '#ff0000' },
}).expect(400);
// Legacy numeric tokens are normalized on read, never accepted on write.
await postTile({
backgroundChart: { type: 'line', color: 'chart-1' },
}).expect(400);
});

// ── Builder-only: raw SQL number tiles never carry it ───────────────

it('strips backgroundChart from a raw SQL number tile, keeping color', async () => {
// The raw SQL number schema has no backgroundChart (the editor disables
// the control for SQL tiles and the save path never persists it), so a
// hand-written payload is stripped rather than stored.
const create = await authRequest('post', BASE_URL)
.send({
name: 'Raw SQL backgroundChart',
tiles: [
rawSqlNumberTile({
color: 'chart-blue',
backgroundChart: { type: 'line' },
}),
],
tags: [],
})
.expect(200);
expect(create.body.data.tiles[0].config.color).toBe('chart-blue');
expect(create.body.data.tiles[0].config.backgroundChart).toBeUndefined();
});

// ── Backward compatibility: the sparkline is opt-in ─────────────────

it('round-trips a builder number tile with no background chart', async () => {
const create = await postTile({}).expect(200);
expect(create.body.data.tiles[0].config.backgroundChart).toBeUndefined();
});
});

describe('Containers and tabs', () => {
const buildTile = (
sourceId: string,
Expand Down
24 changes: 24 additions & 0 deletions packages/api/src/routers/external-api/v2/dashboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,26 @@ const EXTERNAL_DASHBOARD_PROJECTION = {
* Palette token used to color a number tile. Tokens reflow across
* light and dark themes, so raw hex values are not accepted.
* example: "chart-red"
* BackgroundChart:
* type: object
* required:
* - type
* description: >
* Optional background trend sparkline drawn behind a number tile's
* value, derived from a time-bucketed version of the tile's query.
* Builder number tiles only (raw SQL number tiles have no time
* dimension to bucket).
* properties:
* type:
* type: string
* enum: [line, area]
* description: Sparkline shape.
* example: "line"
* color:
* $ref: '#/components/schemas/ChartPaletteToken'
* description: >
* Optional palette-token override for the sparkline. When unset
* the sparkline inherits the tile's static color.
* NumericColorCondition:
* type: object
* required:
Expand Down Expand Up @@ -755,6 +775,10 @@ const EXTERNAL_DASHBOARD_PROJECTION = {
* text color when no rule matches.
* items:
* $ref: '#/components/schemas/NumberTileColorCondition'
* backgroundChart:
* $ref: '#/components/schemas/BackgroundChart'
* description: >
* Optional background trend sparkline drawn behind the value.
*
* PieBuilderChartConfig:
* type: object
Expand Down
10 changes: 10 additions & 0 deletions packages/api/src/routers/external-api/v2/utils/dashboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,12 @@ const convertToExternalTileChartConfig = (
// response, keeping it within the palette-token enum.
color: resolveChartPaletteToken(config.color),
colorRules: toExternalColorRules(config.colorRules),
// Pass `backgroundChart` through as-is: it shipped after the hue
// rename, so its optional `color` can only hold a hue-named token (no
// legacy normalization needed). Builder number tiles only; raw SQL
// number tiles never persist it (see the schema). Absent resolves to
// undefined and is omitted from the response.
backgroundChart: config.backgroundChart,
};
case DisplayType.Pie:
return {
Expand Down Expand Up @@ -695,6 +701,10 @@ export function convertToInternalTileConfig(
// them when absent.
color: externalConfig.color,
colorRules: externalConfig.colorRules,
// Builder number tiles only; `_.omitBy(_.isNil)` below drops it when
// absent. The input schema validates the nested color as a hue-only
// palette token, so it passes through directly.
backgroundChart: externalConfig.backgroundChart,
name,
} satisfies BuilderSavedChartConfig;
break;
Expand Down
12 changes: 12 additions & 0 deletions packages/api/src/utils/zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
AggregateFunctionSchema,
alertNoteSchema,
AlertThresholdType,
BackgroundChartSchema,
ChartPaletteTokenSchema,
DASHBOARD_CONTAINER_ID_MAX,
DASHBOARD_MAX_TILES,
Expand Down Expand Up @@ -336,6 +337,17 @@ const externalDashboardNumberChartConfigSchema = z.object({
// drift from what the UI persists.
color: ChartPaletteTokenSchema.optional(),
colorRules: z.array(NumberTileColorConditionSchema).max(10).optional(),
// Optional background trend sparkline. Mirrors the internal
// `SharedChartSettingsSchema.backgroundChart` (common-utils types.ts),
// gated by the editor to builder number tiles
// (`ChartDisplaySettingsDrawer`: shown for number tiles but disabled when
// `configType === 'sql'`). The save path
// (`convertFormStateToSavedChartConfig`) persists `backgroundChart` only on
// the builder branch (the raw SQL / promql picks omit it), so it lives on
// the builder number schema only, like `colorRules`. `BackgroundChartSchema`
// is imported from common-utils so the external surface cannot drift from
// what the UI persists.
backgroundChart: BackgroundChartSchema.optional(),
});

const externalDashboardPieChartConfigSchema = z.object({
Expand Down
Loading