From c651b36d9ad8b758267915fd29a1d7a17214b598 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 24 Jun 2026 02:20:33 +0000 Subject: [PATCH] feat(external-api): support number-tile backgroundChart in the v2 dashboards API #2489 added the background trend sparkline to the dashboard editor but left the v2 REST surface unaware of it. Carry backgroundChart ({ type: line | area, color? }) through the builder number-tile schema, both conversion directions, and the OpenAPI spec, reusing BackgroundChartSchema from common-utils so the surfaces cannot drift. Mirrors how number-tile color shipped in #2428. Builder number tiles only: the editor disables the control for raw SQL number tiles and convertFormStateToSavedChartConfig never persists it there, so the raw SQL schema does not expose it (a sent value is stripped, the same as colorRules). Co-Authored-By: Claude Opus 4.8 --- ...mber-tile-background-chart-external-api.md | 5 + packages/api/openapi.json | 26 +++ .../external-api/__tests__/dashboards.test.ts | 171 ++++++++++++++++++ .../src/routers/external-api/v2/dashboards.ts | 24 +++ .../external-api/v2/utils/dashboards.ts | 10 + packages/api/src/utils/zod.ts | 12 ++ 6 files changed, 248 insertions(+) create mode 100644 .changeset/number-tile-background-chart-external-api.md diff --git a/.changeset/number-tile-background-chart-external-api.md b/.changeset/number-tile-background-chart-external-api.md new file mode 100644 index 0000000000..653ec8f4f8 --- /dev/null +++ b/.changeset/number-tile-background-chart-external-api.md @@ -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. diff --git a/packages/api/openapi.json b/packages/api/openapi.json index 0e28c1aa1b..8f15f81117 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -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": [ @@ -1783,6 +1805,10 @@ "items": { "$ref": "#/components/schemas/NumberTileColorCondition" } + }, + "backgroundChart": { + "$ref": "#/components/schemas/BackgroundChart", + "description": "Optional background trend sparkline drawn behind the value.\n" } } }, diff --git a/packages/api/src/routers/external-api/__tests__/dashboards.test.ts b/packages/api/src/routers/external-api/__tests__/dashboards.test.ts index cdf07957bc..29000f8958 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.test.ts @@ -2372,6 +2372,7 @@ describe('External API v2 Dashboards - new format', () => { label: 'Critical', }, ], + backgroundChart: { type: 'area', color: 'chart-blue' }, }, }; @@ -3899,6 +3900,7 @@ describe('External API v2 Dashboards - new format', () => { label: 'Critical', }, ], + backgroundChart: { type: 'area', color: 'chart-blue' }, }, }; @@ -4911,6 +4913,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) => ({ + 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) => + authRequest('post', BASE_URL).send({ + name: 'Number background chart dashboard', + tiles: [numberTile(config)], + tags: [], + }); + + const rawSqlNumberTile = (config: Record) => ({ + 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, diff --git a/packages/api/src/routers/external-api/v2/dashboards.ts b/packages/api/src/routers/external-api/v2/dashboards.ts index 345ea97bf5..8a27f843cd 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -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: @@ -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 diff --git a/packages/api/src/routers/external-api/v2/utils/dashboards.ts b/packages/api/src/routers/external-api/v2/utils/dashboards.ts index 81b0191f4e..08b952ed2d 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -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 { @@ -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; diff --git a/packages/api/src/utils/zod.ts b/packages/api/src/utils/zod.ts index d271c5ef4c..6e934724bd 100644 --- a/packages/api/src/utils/zod.ts +++ b/packages/api/src/utils/zod.ts @@ -3,6 +3,7 @@ import { AggregateFunctionSchema, alertNoteSchema, AlertThresholdType, + BackgroundChartSchema, ChartPaletteTokenSchema, DASHBOARD_CONTAINER_ID_MAX, DASHBOARD_MAX_TILES, @@ -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({