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 a655422aca..21287a2a42 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.test.ts @@ -2371,6 +2371,7 @@ describe('External API v2 Dashboards - new format', () => { label: 'Critical', }, ], + backgroundChart: { type: 'area', color: 'chart-blue' }, }, }; @@ -3898,6 +3899,7 @@ describe('External API v2 Dashboards - new format', () => { label: 'Critical', }, ], + backgroundChart: { type: 'area', color: 'chart-blue' }, }, }; @@ -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) => ({ + 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({