From f684b821aa4dfbd204bc06cea742d0f79a6841f7 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:08:37 +0000 Subject: [PATCH 1/3] feat(api): support number-tile color in the external dashboards API Number tiles can already set a static color and ordered conditional color rules in the editor, but the v2 external API carried neither, so dashboards-as-code could not author them. A raw SQL number tile that set a color in the UI also lost it on a GET then PUT round-trip because the external conversion never emitted it. Add `color` and `colorRules` to the builder number chart config and `color` to the raw SQL number chart config (the editor shows the rules section for raw SQL number tiles but its save path drops them, so rules stay builder-only). Round-trip both through the internal/external conversion and regenerate the OpenAPI spec. Colors are validated as hue-named palette tokens on input; tiles saved before the palette rename are normalized to hue names on read so older dashboards keep working. Co-Authored-By: Claude Opus 4.7 --- .changeset/number-tile-color-external-api.md | 5 + packages/api/openapi.json | 252 ++++++++++++++++++ .../external-api/__tests__/dashboards.test.ts | 243 +++++++++++++++++ .../src/routers/external-api/v2/dashboards.ts | 182 +++++++++++++ .../external-api/v2/utils/dashboards.ts | 46 ++++ packages/api/src/utils/zod.ts | 20 ++ 6 files changed, 748 insertions(+) create mode 100644 .changeset/number-tile-color-external-api.md diff --git a/.changeset/number-tile-color-external-api.md b/.changeset/number-tile-color-external-api.md new file mode 100644 index 0000000000..a748ce2556 --- /dev/null +++ b/.changeset/number-tile-color-external-api.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/api": minor +--- + +Support number-tile color authoring through the external dashboards API. The v2 REST API and OpenAPI spec now accept `color` (a palette token) and `colorRules` (ordered conditional color rules, last match wins) on builder number tiles, and `color` on raw SQL number tiles, matching what the in-product number-tile editor persists. Existing dashboards keep working: tiles saved before the palette was renamed to hue names are normalized to the current token names on read. diff --git a/packages/api/openapi.json b/packages/api/openapi.json index 9bcad76117..5ad2b422d0 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -779,6 +779,242 @@ } } }, + "ChartPaletteToken": { + "type": "string", + "enum": [ + "chart-blue", + "chart-orange", + "chart-red", + "chart-cyan", + "chart-green", + "chart-pink", + "chart-purple", + "chart-light-blue", + "chart-brown", + "chart-gray", + "chart-success", + "chart-warning", + "chart-error" + ], + "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" + }, + "NumericColorCondition": { + "type": "object", + "required": [ + "operator", + "value", + "color" + ], + "description": "Color rule comparing the displayed value against a single numeric bound.", + "properties": { + "operator": { + "type": "string", + "enum": [ + "gt", + "gte", + "lt", + "lte" + ], + "description": "Numeric comparison operator.", + "example": "gt" + }, + "value": { + "type": "number", + "description": "Numeric bound the displayed value is compared against.", + "example": 100 + }, + "color": { + "$ref": "#/components/schemas/ChartPaletteToken", + "description": "Color applied when the rule matches." + }, + "label": { + "type": "string", + "maxLength": 40, + "description": "Optional label describing the rule.", + "example": "High" + } + } + }, + "BetweenColorCondition": { + "type": "object", + "required": [ + "operator", + "value", + "color" + ], + "description": "Color rule matching when the displayed value falls within an inclusive range.", + "properties": { + "operator": { + "type": "string", + "enum": [ + "between" + ], + "description": "Range comparison operator.", + "example": "between" + }, + "value": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "number" + }, + "description": "Inclusive [min, max] range.", + "example": [ + 100, + 500 + ] + }, + "color": { + "$ref": "#/components/schemas/ChartPaletteToken", + "description": "Color applied when the rule matches." + }, + "label": { + "type": "string", + "maxLength": 40, + "description": "Optional label describing the rule.", + "example": "Warning" + } + } + }, + "EqualityColorCondition": { + "type": "object", + "required": [ + "operator", + "value", + "color" + ], + "description": "Color rule matching when the displayed value equals (eq) or does not equal (neq) a number or string.", + "properties": { + "operator": { + "type": "string", + "enum": [ + "eq", + "neq" + ], + "description": "Equality comparison operator.", + "example": "eq" + }, + "value": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "maxLength": 200 + } + ], + "description": "Number, or string up to 200 characters, to compare for equality.", + "example": "OK" + }, + "color": { + "$ref": "#/components/schemas/ChartPaletteToken", + "description": "Color applied when the rule matches." + }, + "label": { + "type": "string", + "maxLength": 40, + "description": "Optional label describing the rule.", + "example": "Healthy" + } + } + }, + "StringMatchColorCondition": { + "type": "object", + "required": [ + "operator", + "value", + "color" + ], + "description": "Color rule matching when the displayed value contains, starts with, or ends with a substring. Valid in the schema for a future table-tile editor; the number-tile editor does not surface these operators today.\n", + "properties": { + "operator": { + "type": "string", + "enum": [ + "contains", + "startsWith", + "endsWith" + ], + "description": "Substring comparison operator.", + "example": "contains" + }, + "value": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Substring to match against the displayed value.", + "example": "error" + }, + "color": { + "$ref": "#/components/schemas/ChartPaletteToken", + "description": "Color applied when the rule matches." + }, + "label": { + "type": "string", + "maxLength": 40, + "description": "Optional label describing the rule.", + "example": "Error" + } + } + }, + "RegexColorCondition": { + "type": "object", + "required": [ + "operator", + "value", + "color" + ], + "description": "Color rule matching when the displayed value matches a regular expression. Valid in the schema for a future table-tile editor; the number-tile editor does not surface this operator today.\n", + "properties": { + "operator": { + "type": "string", + "enum": [ + "regex" + ], + "description": "Regular expression operator.", + "example": "regex" + }, + "value": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "A valid regular expression pattern.", + "example": "^5[0-9][0-9]$" + }, + "color": { + "$ref": "#/components/schemas/ChartPaletteToken", + "description": "Color applied when the rule matches." + }, + "label": { + "type": "string", + "maxLength": 40, + "description": "Optional label describing the rule.", + "example": "5xx" + } + } + }, + "ColorCondition": { + "description": "A single conditional color rule for a number tile. Rules are evaluated in order and the last matching rule wins. When no rule matches, the static color applies, then the default text color. The number-tile editor surfaces the numeric and equality operators; the string operators are reserved for a future table-tile editor.\n", + "oneOf": [ + { + "$ref": "#/components/schemas/NumericColorCondition" + }, + { + "$ref": "#/components/schemas/BetweenColorCondition" + }, + { + "$ref": "#/components/schemas/EqualityColorCondition" + }, + { + "$ref": "#/components/schemas/StringMatchColorCondition" + }, + { + "$ref": "#/components/schemas/RegexColorCondition" + } + ] + }, "TimeChartSeries": { "type": "object", "required": [ @@ -1417,6 +1653,18 @@ "numberFormat": { "$ref": "#/components/schemas/NumberFormat", "description": "Number formatting options for displayed values." + }, + "color": { + "$ref": "#/components/schemas/ChartPaletteToken", + "description": "Optional static color applied to the displayed number." + }, + "colorRules": { + "type": "array", + "maxItems": 10, + "description": "Ordered conditional color rules evaluated against the displayed value (last match wins). Falls back to color, then the default text color when no rule matches.\n", + "items": { + "$ref": "#/components/schemas/ColorCondition" + } } } }, @@ -1772,6 +2020,10 @@ ], "description": "Display as a single big-number chart.", "example": "number" + }, + "color": { + "$ref": "#/components/schemas/ChartPaletteToken", + "description": "Optional static color applied to the displayed number. Raw SQL number tiles do not support conditional colorRules.\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 7aa2799d6a..797a43fe1b 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.test.ts @@ -2352,6 +2352,26 @@ describe('External API v2 Dashboards - new format', () => { thousandSeparated: true, average: true, }, + color: 'chart-green', + colorRules: [ + { + operator: 'gt', + value: 1000, + color: 'chart-warning', + label: 'Slow', + }, + { + operator: 'between', + value: [200, 1000], + color: 'chart-blue', + }, + { + operator: 'gte', + value: 5000, + color: 'chart-error', + label: 'Critical', + }, + ], }, }; @@ -2765,6 +2785,8 @@ describe('External API v2 Dashboards - new format', () => { sqlTemplate, sourceId, numberFormat: { output: 'currency', currencySymbol: '$' }, + // Raw SQL number tiles carry the static tile color (no colorRules). + color: 'chart-purple', }, }; @@ -3857,6 +3879,26 @@ describe('External API v2 Dashboards - new format', () => { thousandSeparated: true, average: true, }, + color: 'chart-green', + colorRules: [ + { + operator: 'gt', + value: 1000, + color: 'chart-warning', + label: 'Slow', + }, + { + operator: 'between', + value: [200, 1000], + color: 'chart-blue', + }, + { + operator: 'gte', + value: 5000, + color: 'chart-error', + label: 'Critical', + }, + ], }, }; @@ -4027,6 +4069,8 @@ describe('External API v2 Dashboards - new format', () => { sqlTemplate, sourceId, numberFormat: { output: 'currency', currencySymbol: '$' }, + // Raw SQL number tiles carry the static tile color (no colorRules). + color: 'chart-purple', }, }; @@ -4535,6 +4579,205 @@ describe('External API v2 Dashboards - new format', () => { }); }); + describe('Number tile color (HDX-1360)', () => { + // Minimal builder number tile; callers supply color / colorRules. 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 color 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 static color', async () => { + const create = await postTile({ color: 'chart-red' }).expect(200); + expect(create.body.data.tiles[0].config.color).toBe('chart-red'); + + const get = await authRequest( + 'get', + `${BASE_URL}/${create.body.data.id}`, + ).expect(200); + expect(get.body.data.tiles[0].config.color).toBe('chart-red'); + }); + + it('round-trips colorRules covering each operator family', async () => { + const colorRules = [ + { operator: 'gt', value: 1000, color: 'chart-warning', label: 'Slow' }, + { operator: 'between', value: [200, 1000], color: 'chart-blue' }, + { operator: 'eq', value: 0, color: 'chart-gray' }, + { operator: 'eq', value: 'OK', color: 'chart-success' }, + { operator: 'contains', value: 'error', color: 'chart-error' }, + { operator: 'regex', value: '^5[0-9][0-9]$', color: 'chart-pink' }, + ]; + const create = await postTile({ colorRules }).expect(200); + expect(create.body.data.tiles[0].config.colorRules).toEqual(colorRules); + + const get = await authRequest( + 'get', + `${BASE_URL}/${create.body.data.id}`, + ).expect(200); + expect(get.body.data.tiles[0].config.colorRules).toEqual(colorRules); + }); + + it('round-trips a raw SQL number tile with a static color', async () => { + const create = await authRequest('post', BASE_URL) + .send({ + name: 'Raw SQL number color', + tiles: [rawSqlNumberTile({ color: 'chart-blue' })], + tags: [], + }) + .expect(200); + expect(create.body.data.tiles[0].config.color).toBe('chart-blue'); + + const get = await authRequest( + 'get', + `${BASE_URL}/${create.body.data.id}`, + ).expect(200); + expect(get.body.data.tiles[0].config.color).toBe('chart-blue'); + }); + + // ── Negative: one per schema rejection rule ───────────────────────── + + it('rejects a static color that is not a palette token', async () => { + // Bare hue name without the chart- prefix. + await postTile({ color: 'red' }).expect(400); + // Numeric slot outside the palette. + await postTile({ color: 'chart-99' }).expect(400); + // Raw hex value. + await postTile({ color: '#ff0000' }).expect(400); + }); + + it('rejects a legacy numeric palette token on input', async () => { + // chart-1..chart-10 were renamed to hue names; the input enum is + // strict hue-only, so a legacy token in a hand-written payload is + // rejected. Legacy tokens are normalized on read, never accepted on + // write. + await postTile({ color: 'chart-1' }).expect(400); + }); + + it('rejects more than 10 colorRules', async () => { + const colorRules = Array.from({ length: 11 }, (_, i) => ({ + operator: 'gt', + value: i, + color: 'chart-blue', + })); + await postTile({ colorRules }).expect(400); + }); + + it('rejects a between rule whose value is not a two-number tuple', async () => { + await postTile({ + colorRules: [{ operator: 'between', value: 100, color: 'chart-blue' }], + }).expect(400); + }); + + it('rejects a numeric operator rule with a string value', async () => { + await postTile({ + colorRules: [{ operator: 'gt', value: 'high', color: 'chart-blue' }], + }).expect(400); + }); + + it('rejects a regex rule with an invalid pattern', async () => { + await postTile({ + colorRules: [{ operator: 'regex', value: '[', color: 'chart-blue' }], + }).expect(400); + }); + + it('rejects a rule label longer than 40 characters', async () => { + await postTile({ + colorRules: [ + { + operator: 'gt', + value: 1, + color: 'chart-blue', + label: 'x'.repeat(41), + }, + ], + }).expect(400); + }); + + // ── Backward compatibility: existing dashboards keep working ──────── + + it('round-trips a number tile with neither color nor colorRules', async () => { + const create = await postTile({}).expect(200); + expect(create.body.data.tiles[0].config.color).toBeUndefined(); + expect(create.body.data.tiles[0].config.colorRules).toBeUndefined(); + }); + + it('normalizes a legacy numeric token on a builder number tile to its hue name on read', async () => { + const create = await postTile({ color: 'chart-green' }).expect(200); + const dashboardId = create.body.data.id; + + // Simulate a tile saved during the #2265 window by writing a legacy + // numeric token directly to Mongo (the `tiles` field is `Mixed`, so + // this bypasses the create-path enum). + await Dashboard.updateOne( + { _id: dashboardId }, + { $set: { 'tiles.0.config.color': 'chart-1' } }, + ); + + const get = await authRequest('get', `${BASE_URL}/${dashboardId}`).expect( + 200, + ); + // chart-1 maps to chart-green (LEGACY_CHART_PALETTE_TOKEN_MAP). + expect(get.body.data.tiles[0].config.color).toBe('chart-green'); + }); + + it('normalizes a legacy numeric token on a raw SQL number tile to its hue name on read', async () => { + const create = await authRequest('post', BASE_URL) + .send({ + name: 'Raw SQL legacy color', + tiles: [rawSqlNumberTile({ color: 'chart-blue' })], + tags: [], + }) + .expect(200); + const dashboardId = create.body.data.id; + + await Dashboard.updateOne( + { _id: dashboardId }, + { $set: { 'tiles.0.config.color': 'chart-4' } }, + ); + + const get = await authRequest('get', `${BASE_URL}/${dashboardId}`).expect( + 200, + ); + // chart-4 maps to chart-red. + expect(get.body.data.tiles[0].config.color).toBe('chart-red'); + }); + }); + 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 9075e15388..9ccf8a6583 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -122,6 +122,171 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * description: Custom unit label. * example: "ms" * + * ChartPaletteToken: + * type: string + * enum: [chart-blue, chart-orange, chart-red, chart-cyan, chart-green, chart-pink, chart-purple, chart-light-blue, chart-brown, chart-gray, chart-success, chart-warning, chart-error] + * description: > + * 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" + * NumericColorCondition: + * type: object + * required: + * - operator + * - value + * - color + * description: Color rule comparing the displayed value against a single numeric bound. + * properties: + * operator: + * type: string + * enum: [gt, gte, lt, lte] + * description: Numeric comparison operator. + * example: "gt" + * value: + * type: number + * description: Numeric bound the displayed value is compared against. + * example: 100 + * color: + * $ref: '#/components/schemas/ChartPaletteToken' + * description: Color applied when the rule matches. + * label: + * type: string + * maxLength: 40 + * description: Optional label describing the rule. + * example: "High" + * BetweenColorCondition: + * type: object + * required: + * - operator + * - value + * - color + * description: Color rule matching when the displayed value falls within an inclusive range. + * properties: + * operator: + * type: string + * enum: [between] + * description: Range comparison operator. + * example: "between" + * value: + * type: array + * minItems: 2 + * maxItems: 2 + * items: + * type: number + * description: Inclusive [min, max] range. + * example: [100, 500] + * color: + * $ref: '#/components/schemas/ChartPaletteToken' + * description: Color applied when the rule matches. + * label: + * type: string + * maxLength: 40 + * description: Optional label describing the rule. + * example: "Warning" + * EqualityColorCondition: + * type: object + * required: + * - operator + * - value + * - color + * description: Color rule matching when the displayed value equals (eq) or does not equal (neq) a number or string. + * properties: + * operator: + * type: string + * enum: [eq, neq] + * description: Equality comparison operator. + * example: "eq" + * value: + * oneOf: + * - type: number + * - type: string + * maxLength: 200 + * description: Number, or string up to 200 characters, to compare for equality. + * example: "OK" + * color: + * $ref: '#/components/schemas/ChartPaletteToken' + * description: Color applied when the rule matches. + * label: + * type: string + * maxLength: 40 + * description: Optional label describing the rule. + * example: "Healthy" + * StringMatchColorCondition: + * type: object + * required: + * - operator + * - value + * - color + * description: > + * Color rule matching when the displayed value contains, starts with, + * or ends with a substring. Valid in the schema for a future + * table-tile editor; the number-tile editor does not surface these + * operators today. + * properties: + * operator: + * type: string + * enum: [contains, startsWith, endsWith] + * description: Substring comparison operator. + * example: "contains" + * value: + * type: string + * minLength: 1 + * maxLength: 200 + * description: Substring to match against the displayed value. + * example: "error" + * color: + * $ref: '#/components/schemas/ChartPaletteToken' + * description: Color applied when the rule matches. + * label: + * type: string + * maxLength: 40 + * description: Optional label describing the rule. + * example: "Error" + * RegexColorCondition: + * type: object + * required: + * - operator + * - value + * - color + * description: > + * Color rule matching when the displayed value matches a regular + * expression. Valid in the schema for a future table-tile editor; + * the number-tile editor does not surface this operator today. + * properties: + * operator: + * type: string + * enum: [regex] + * description: Regular expression operator. + * example: "regex" + * value: + * type: string + * minLength: 1 + * maxLength: 500 + * description: A valid regular expression pattern. + * example: "^5[0-9][0-9]$" + * color: + * $ref: '#/components/schemas/ChartPaletteToken' + * description: Color applied when the rule matches. + * label: + * type: string + * maxLength: 40 + * description: Optional label describing the rule. + * example: "5xx" + * ColorCondition: + * description: > + * A single conditional color rule for a number tile. Rules are + * evaluated in order and the last matching rule wins. When no rule + * matches, the static color applies, then the default text color. + * The number-tile editor surfaces the numeric and equality + * operators; the string operators are reserved for a future + * table-tile editor. + * oneOf: + * - $ref: '#/components/schemas/NumericColorCondition' + * - $ref: '#/components/schemas/BetweenColorCondition' + * - $ref: '#/components/schemas/EqualityColorCondition' + * - $ref: '#/components/schemas/StringMatchColorCondition' + * - $ref: '#/components/schemas/RegexColorCondition' + * * TimeChartSeries: * type: object * required: @@ -627,6 +792,18 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * numberFormat: * $ref: '#/components/schemas/NumberFormat' * description: Number formatting options for displayed values. + * color: + * $ref: '#/components/schemas/ChartPaletteToken' + * description: Optional static color applied to the displayed number. + * colorRules: + * type: array + * maxItems: 10 + * description: > + * Ordered conditional color rules evaluated against the displayed + * value (last match wins). Falls back to color, then the default + * text color when no rule matches. + * items: + * $ref: '#/components/schemas/ColorCondition' * * PieBuilderChartConfig: * type: object @@ -902,6 +1079,11 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * enum: [number] * description: Display as a single big-number chart. * example: "number" + * color: + * $ref: '#/components/schemas/ChartPaletteToken' + * description: > + * Optional static color applied to the displayed number. Raw + * SQL number tiles do not support conditional colorRules. * * PieRawSqlChartConfig: * description: Raw SQL configuration for a pie chart. 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 a8506a3e6b..8891f3b587 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -15,6 +15,8 @@ import { import { AggregateFunctionSchema, BuilderSavedChartConfig, + ChartPaletteToken, + ColorCondition, DASHBOARD_MAX_CONTAINERS, DashboardContainer, DashboardContainerSchema, @@ -24,6 +26,7 @@ import { isOnClickSearchById, isTraceSource, RawSqlSavedChartConfig, + resolveChartPaletteToken, SavedChartConfig, } from '@hyperdx/common-utils/dist/types'; import { SearchConditionLanguageSchema as whereLanguageSchema } from '@hyperdx/common-utils/dist/types'; @@ -148,6 +151,24 @@ const convertToExternalSelectItem = ( }; }; +// Normalize the per-rule palette colors of a number tile's `colorRules` +// for the API response. `colorRules` shipped after the hue rename so +// stored rules already hold hue-named tokens; running each through +// `resolveChartPaletteToken` is defense-in-depth that keeps the static +// `color` (which can hold a legacy `chart-1`..`chart-10` token from before +// the rename) and the rule colors on a single normalization path. A rule +// whose color cannot be resolved keeps its stored value so a rule is never +// dropped or left without a color. +const toExternalColorRules = ( + colorRules: ColorCondition[] | undefined, +): ColorCondition[] | undefined => + colorRules?.map( + (rule): ColorCondition => ({ + ...rule, + color: resolveChartPaletteToken(rule.color) ?? rule.color, + }), + ); + const convertToExternalTileChartConfig = ( config: SavedChartConfig, ): ExternalDashboardTileConfig | undefined => { @@ -195,6 +216,10 @@ const convertToExternalTileChartConfig = ( sqlTemplate: config.sqlTemplate, sourceId: config.source, numberFormat: config.numberFormat, + // Raw SQL number tiles carry the static tile color too (no + // colorRules; see the schema). Normalize a legacy token saved + // before the hue rename to its hue name on output. + color: resolveChartPaletteToken(config.color), }; case DisplayType.Pie: return { @@ -276,6 +301,16 @@ const convertToExternalTileChartConfig = ( ? [convertToExternalSelectItem(config.select[0])] : [DEFAULT_SELECT_ITEM], numberFormat: config.numberFormat, + // Normalize stored palette tokens on the way out. A static `color` + // saved before the hue rename holds a legacy `chart-1`..`chart-10` + // token in Mongo (the `tiles` field is `Mixed`), so map it to the + // hue name via `resolveChartPaletteToken`, matching the app's + // fetch-time `normalizeDashboardTileColors`. `colorRules` colors go + // through the same path (see `toExternalColorRules`). An absent or + // unrecognized token resolves to undefined and is omitted from the + // response, keeping it within the palette-token enum. + color: resolveChartPaletteToken(config.color), + colorRules: toExternalColorRules(config.colorRules), }; case DisplayType.Pie: return { @@ -576,6 +611,12 @@ export function convertToInternalTileConfig( externalConfig.displayType === 'table' ? externalConfig.onClick : undefined, + // Only the raw SQL number variant carries `color`; table and pie + // do not expose it. `_.omitBy(_.isNil)` below drops it when absent. + color: + externalConfig.displayType === 'number' + ? externalConfig.color + : undefined, } satisfies RawSqlSavedChartConfig; break; default: @@ -635,6 +676,11 @@ export function convertToInternalTileConfig( source: externalConfig.sourceId, where: '', numberFormat: externalConfig.numberFormat, + // The input schema validates these as hue-only palette tokens, + // so pass them through directly; `_.omitBy(_.isNil)` below drops + // them when absent. + color: externalConfig.color, + colorRules: externalConfig.colorRules, name, } satisfies BuilderSavedChartConfig; break; diff --git a/packages/api/src/utils/zod.ts b/packages/api/src/utils/zod.ts index d098e4d44e..7c5cf728d3 100644 --- a/packages/api/src/utils/zod.ts +++ b/packages/api/src/utils/zod.ts @@ -3,6 +3,8 @@ import { AggregateFunctionSchema, alertNoteSchema, AlertThresholdType, + ChartPaletteTokenSchema, + ColorConditionSchema, DASHBOARD_CONTAINER_ID_MAX, DASHBOARD_MAX_TILES, DashboardFilterSchema, @@ -301,6 +303,14 @@ const externalDashboardTableRawSqlChartConfigSchema = const externalDashboardNumberRawSqlChartConfigSchema = externalDashboardRawSqlChartConfigBaseSchema.extend({ displayType: z.literal('number'), + // Raw SQL number tiles expose the same static tile color as builder + // number tiles: the editor gates the picker on displayType, not + // configType (`ChartDisplaySettingsDrawer`). `colorRules` is + // intentionally omitted here because the editor's save path + // (`convertFormStateToSavedChartConfig`) picks `color` but not + // `colorRules` for raw SQL configs, so persisted raw SQL number tiles + // never carry rules. + color: ChartPaletteTokenSchema.optional(), }); const externalDashboardPieRawSqlChartConfigSchema = @@ -313,6 +323,16 @@ const externalDashboardNumberChartConfigSchema = z.object({ sourceId: objectIdSchema, select: z.array(externalDashboardSelectItemSchema).length(1), numberFormat: NumberFormatSchema.optional(), + // Number-tile color authoring. Mirrors the internal + // `SharedChartSettingsSchema` fields (common-utils types.ts), which the + // editor gates to number tiles (`ChartDisplaySettingsDrawer`: + // `showTileColor = displayType === DisplayType.Number`). `color` is a + // hue-named palette token; `colorRules` are ordered conditional rules + // (last match wins), capped at 10 to match the editor. Both schemas are + // imported from common-utils so the external surface cannot drift from + // what the UI persists. + color: ChartPaletteTokenSchema.optional(), + colorRules: z.array(ColorConditionSchema).max(10).optional(), }); const externalDashboardPieChartConfigSchema = z.object({ From c7af7b4c7fdebd0e8d2052a7c14f766f789d2028 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:24:06 +0000 Subject: [PATCH 2/3] fix(api): keep number-tile colorRules within the palette-token enum on read The colorRules normalizer fell back to the raw stored color when a rule's token could not be resolved, so a color written directly to Mongo that is neither a hue token nor a legacy numeric token would pass through verbatim in the GET response, outside the documented ChartPaletteToken enum. The static color field already omits an unresolvable token. Drop a rule whose color cannot be resolved instead, so the response stays within the enum. Only reachable via a direct DB write, since the input schema validates rule colors. Co-Authored-By: Claude Opus 4.7 --- .../external-api/__tests__/dashboards.test.ts | 31 +++++++++++++++++++ .../external-api/v2/utils/dashboards.ts | 22 ++++++------- 2 files changed, 42 insertions(+), 11 deletions(-) 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 797a43fe1b..0d75d7ce9a 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.test.ts @@ -4755,6 +4755,37 @@ describe('External API v2 Dashboards - new format', () => { expect(get.body.data.tiles[0].config.color).toBe('chart-green'); }); + it('normalizes legacy colorRule colors and drops unresolvable ones on read', async () => { + const create = await postTile({ + colorRules: [{ operator: 'gt', value: 1, color: 'chart-green' }], + }).expect(200); + const dashboardId = create.body.data.id; + + // Direct Mongo write: a legacy numeric token (normalized to its hue + // name on read) and an unrecognized token (dropped on read so the + // response stays within the palette-token enum). Neither is reachable + // through the validated create path. + await Dashboard.updateOne( + { _id: dashboardId }, + { + $set: { + 'tiles.0.config.colorRules': [ + { operator: 'gt', value: 1, color: 'chart-1' }, + { operator: 'gt', value: 2, color: 'not-a-token' }, + ], + }, + }, + ); + + const get = await authRequest('get', `${BASE_URL}/${dashboardId}`).expect( + 200, + ); + // chart-1 maps to chart-green; the unresolvable rule is dropped. + expect(get.body.data.tiles[0].config.colorRules).toEqual([ + { operator: 'gt', value: 1, color: 'chart-green' }, + ]); + }); + it('normalizes a legacy numeric token on a raw SQL number tile to its hue name on read', async () => { const create = await authRequest('post', BASE_URL) .send({ 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 8891f3b587..a53f6e62a6 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -154,20 +154,20 @@ const convertToExternalSelectItem = ( // Normalize the per-rule palette colors of a number tile's `colorRules` // for the API response. `colorRules` shipped after the hue rename so // stored rules already hold hue-named tokens; running each through -// `resolveChartPaletteToken` is defense-in-depth that keeps the static -// `color` (which can hold a legacy `chart-1`..`chart-10` token from before -// the rename) and the rule colors on a single normalization path. A rule -// whose color cannot be resolved keeps its stored value so a rule is never -// dropped or left without a color. +// `resolveChartPaletteToken` keeps the static `color` (which can hold a +// legacy `chart-1`..`chart-10` token from before the rename) and the rule +// colors on a single normalization path. A rule whose color cannot be +// resolved (reachable only via a direct DB write, since the input schema +// validates rule colors) is dropped, mirroring how the static `color` +// field omits an unresolvable token, so the response always stays within +// the palette-token enum instead of leaking an unknown string. const toExternalColorRules = ( colorRules: ColorCondition[] | undefined, ): ColorCondition[] | undefined => - colorRules?.map( - (rule): ColorCondition => ({ - ...rule, - color: resolveChartPaletteToken(rule.color) ?? rule.color, - }), - ); + colorRules?.flatMap((rule): ColorCondition[] => { + const color = resolveChartPaletteToken(rule.color); + return color ? [{ ...rule, color }] : []; + }); const convertToExternalTileChartConfig = ( config: SavedChartConfig, From 9e37785301e1926232b0978ca9188e0e298691f3 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:25:28 +0000 Subject: [PATCH 3/3] fix(api): restrict number-tile colorRules to the editor's operator set The external number-tile colorRules schema reused the full ColorConditionSchema, which accepts the contains, startsWith, endsWith, and regex operators that the number-tile editor never emits. A stored regex pattern is compiled and evaluated at render time, so accepting one over the API let an authenticated payload run an arbitrary pattern in a same-team viewer's browser. Add NumberTileColorConditionSchema (numeric and equality operators only) in common-utils and validate the external schema against it, so the API surface matches what the editor can author. The MCP dashboard tool can reuse the same schema. Normalize an empty colorRules result to undefined on read so the field is omitted rather than returned as an empty array, matching the static color field. Regenerate the OpenAPI spec: NumberTileColorCondition gains an operator discriminator and the unused string-match and regex schemas are dropped. Extend the integration coverage: all seven allowed operators round-trip, unsupported operators and invalid per-rule tokens are rejected, colorRules on a raw SQL number tile is stripped, and color plus colorRules round-trip through PUT. Co-Authored-By: Claude Opus 4.7 --- .changeset/number-tile-color-external-api.md | 2 +- packages/api/openapi.json | 106 +++----------- .../external-api/__tests__/dashboards.test.ts | 124 +++++++++++++++-- .../src/routers/external-api/v2/dashboards.ts | 93 +++---------- .../external-api/v2/utils/dashboards.ts | 22 ++- packages/api/src/utils/zod.ts | 13 +- packages/common-utils/src/types.ts | 130 +++++++++++------- 7 files changed, 263 insertions(+), 227 deletions(-) diff --git a/.changeset/number-tile-color-external-api.md b/.changeset/number-tile-color-external-api.md index a748ce2556..3739b66c4f 100644 --- a/.changeset/number-tile-color-external-api.md +++ b/.changeset/number-tile-color-external-api.md @@ -2,4 +2,4 @@ "@hyperdx/api": minor --- -Support number-tile color authoring through the external dashboards API. The v2 REST API and OpenAPI spec now accept `color` (a palette token) and `colorRules` (ordered conditional color rules, last match wins) on builder number tiles, and `color` on raw SQL number tiles, matching what the in-product number-tile editor persists. Existing dashboards keep working: tiles saved before the palette was renamed to hue names are normalized to the current token names on read. +Support number-tile color authoring through the external dashboards API. The v2 REST API and OpenAPI spec now accept `color` (a palette token) and `colorRules` (ordered conditional color rules, last match wins) on builder number tiles, and `color` on raw SQL number tiles, matching what the in-product number-tile editor persists. Color rules accept the numeric and equality operators the editor offers (`gt`, `gte`, `lt`, `lte`, `between`, `eq`, `neq`). Existing dashboards keep working: tiles saved before the palette was renamed to hue names are normalized to the current token names on read. diff --git a/packages/api/openapi.json b/packages/api/openapi.json index 5ad2b422d0..d043230d7e 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -821,7 +821,7 @@ }, "value": { "type": "number", - "description": "Numeric bound the displayed value is compared against.", + "description": "Numeric bound the displayed value is compared against. Only finite numbers are accepted (Infinity and NaN are rejected).\n", "example": 100 }, "color": { @@ -860,7 +860,7 @@ "items": { "type": "number" }, - "description": "Inclusive [min, max] range.", + "description": "Inclusive [min, max] range. Both bounds must be finite numbers.\n", "example": [ 100, 500 @@ -906,7 +906,7 @@ "maxLength": 200 } ], - "description": "Number, or string up to 200 characters, to compare for equality.", + "description": "A finite number, or a string up to 200 characters, to compare for equality.\n", "example": "OK" }, "color": { @@ -921,82 +921,8 @@ } } }, - "StringMatchColorCondition": { - "type": "object", - "required": [ - "operator", - "value", - "color" - ], - "description": "Color rule matching when the displayed value contains, starts with, or ends with a substring. Valid in the schema for a future table-tile editor; the number-tile editor does not surface these operators today.\n", - "properties": { - "operator": { - "type": "string", - "enum": [ - "contains", - "startsWith", - "endsWith" - ], - "description": "Substring comparison operator.", - "example": "contains" - }, - "value": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Substring to match against the displayed value.", - "example": "error" - }, - "color": { - "$ref": "#/components/schemas/ChartPaletteToken", - "description": "Color applied when the rule matches." - }, - "label": { - "type": "string", - "maxLength": 40, - "description": "Optional label describing the rule.", - "example": "Error" - } - } - }, - "RegexColorCondition": { - "type": "object", - "required": [ - "operator", - "value", - "color" - ], - "description": "Color rule matching when the displayed value matches a regular expression. Valid in the schema for a future table-tile editor; the number-tile editor does not surface this operator today.\n", - "properties": { - "operator": { - "type": "string", - "enum": [ - "regex" - ], - "description": "Regular expression operator.", - "example": "regex" - }, - "value": { - "type": "string", - "minLength": 1, - "maxLength": 500, - "description": "A valid regular expression pattern.", - "example": "^5[0-9][0-9]$" - }, - "color": { - "$ref": "#/components/schemas/ChartPaletteToken", - "description": "Color applied when the rule matches." - }, - "label": { - "type": "string", - "maxLength": 40, - "description": "Optional label describing the rule.", - "example": "5xx" - } - } - }, - "ColorCondition": { - "description": "A single conditional color rule for a number tile. Rules are evaluated in order and the last matching rule wins. When no rule matches, the static color applies, then the default text color. The number-tile editor surfaces the numeric and equality operators; the string operators are reserved for a future table-tile editor.\n", + "NumberTileColorCondition": { + "description": "A single conditional color rule for a number tile. Rules are evaluated in order and the last matching rule wins. When no rule matches, the static color applies, then the default text color. The number-tile editor surfaces numeric and equality operators only.\n", "oneOf": [ { "$ref": "#/components/schemas/NumericColorCondition" @@ -1006,14 +932,20 @@ }, { "$ref": "#/components/schemas/EqualityColorCondition" - }, - { - "$ref": "#/components/schemas/StringMatchColorCondition" - }, - { - "$ref": "#/components/schemas/RegexColorCondition" } - ] + ], + "discriminator": { + "propertyName": "operator", + "mapping": { + "gt": "#/components/schemas/NumericColorCondition", + "gte": "#/components/schemas/NumericColorCondition", + "lt": "#/components/schemas/NumericColorCondition", + "lte": "#/components/schemas/NumericColorCondition", + "between": "#/components/schemas/BetweenColorCondition", + "eq": "#/components/schemas/EqualityColorCondition", + "neq": "#/components/schemas/EqualityColorCondition" + } + } }, "TimeChartSeries": { "type": "object", @@ -1663,7 +1595,7 @@ "maxItems": 10, "description": "Ordered conditional color rules evaluated against the displayed value (last match wins). Falls back to color, then the default text color when no rule matches.\n", "items": { - "$ref": "#/components/schemas/ColorCondition" + "$ref": "#/components/schemas/NumberTileColorCondition" } } } 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 0d75d7ce9a..cdf07957bc 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.test.ts @@ -4636,11 +4636,17 @@ describe('External API v2 Dashboards - new format', () => { it('round-trips colorRules covering each operator family', async () => { const colorRules = [ { operator: 'gt', value: 1000, color: 'chart-warning', label: 'Slow' }, + { + operator: 'gte', + value: 5000, + color: 'chart-error', + label: 'Critical', + }, + { operator: 'lt', value: 0, color: 'chart-gray' }, + { operator: 'lte', value: 10, color: 'chart-purple' }, { operator: 'between', value: [200, 1000], color: 'chart-blue' }, - { operator: 'eq', value: 0, color: 'chart-gray' }, - { operator: 'eq', value: 'OK', color: 'chart-success' }, - { operator: 'contains', value: 'error', color: 'chart-error' }, - { operator: 'regex', value: '^5[0-9][0-9]$', color: 'chart-pink' }, + { operator: 'eq', value: 0, color: 'chart-cyan' }, + { operator: 'neq', value: 'OK', color: 'chart-success' }, ]; const create = await postTile({ colorRules }).expect(200); expect(create.body.data.tiles[0].config.colorRules).toEqual(colorRules); @@ -4669,14 +4675,69 @@ describe('External API v2 Dashboards - new format', () => { expect(get.body.data.tiles[0].config.color).toBe('chart-blue'); }); + it('round-trips color and colorRules 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 colorRules = [ + { + operator: 'gte', + value: 5000, + color: 'chart-error', + label: 'Critical', + }, + { operator: 'between', value: [200, 1000], color: 'chart-blue' }, + ]; + const update = await authRequest('put', `${BASE_URL}/${dashboardId}`) + .send({ + name: 'Number color dashboard', + tiles: [ + { + ...tile, + config: { ...tile.config, color: 'chart-red', colorRules }, + }, + ], + tags: [], + }) + .expect(200); + expect(update.body.data.tiles[0].config).toMatchObject({ + color: 'chart-red', + colorRules, + }); + + const get = await authRequest('get', `${BASE_URL}/${dashboardId}`).expect( + 200, + ); + expect(get.body.data.tiles[0].config).toMatchObject({ + color: 'chart-red', + colorRules, + }); + }); + + it('strips colorRules from a raw SQL number tile, keeping color', async () => { + const create = await authRequest('post', BASE_URL) + .send({ + name: 'Raw SQL colorRules', + tiles: [ + rawSqlNumberTile({ + color: 'chart-blue', + colorRules: [{ operator: 'gt', value: 1, color: 'chart-red' }], + }), + ], + tags: [], + }) + .expect(200); + expect(create.body.data.tiles[0].config.color).toBe('chart-blue'); + expect(create.body.data.tiles[0].config.colorRules).toBeUndefined(); + }); + // ── Negative: one per schema rejection rule ───────────────────────── it('rejects a static color that is not a palette token', async () => { - // Bare hue name without the chart- prefix. - await postTile({ color: 'red' }).expect(400); - // Numeric slot outside the palette. + const res = await postTile({ color: 'red' }).expect(400); + expect(res.body.message).toContain('tiles.0.config.color'); await postTile({ color: 'chart-99' }).expect(400); - // Raw hex value. await postTile({ color: '#ff0000' }).expect(400); }); @@ -4694,7 +4755,8 @@ describe('External API v2 Dashboards - new format', () => { value: i, color: 'chart-blue', })); - await postTile({ colorRules }).expect(400); + const res = await postTile({ colorRules }).expect(400); + expect(res.body.message).toContain('tiles.0.config.colorRules'); }); it('rejects a between rule whose value is not a two-number tuple', async () => { @@ -4709,9 +4771,23 @@ describe('External API v2 Dashboards - new format', () => { }).expect(400); }); - it('rejects a regex rule with an invalid pattern', async () => { + it('rejects operators the number-tile editor never emits', async () => { + for (const operator of ['contains', 'startsWith', 'endsWith', 'regex']) { + const res = await postTile({ + colorRules: [{ operator, value: 'error', color: 'chart-blue' }], + }).expect(400); + expect(res.body.message).toContain('tiles.0.config.colorRules'); + } + }); + + it('rejects a per-rule color that is not a palette token', async () => { + const res = await postTile({ + colorRules: [{ operator: 'gt', value: 1, color: 'red' }], + }).expect(400); + expect(res.body.message).toContain('tiles.0.config.colorRules'); + // Legacy numeric tokens are normalized on read, never accepted on write. await postTile({ - colorRules: [{ operator: 'regex', value: '[', color: 'chart-blue' }], + colorRules: [{ operator: 'gt', value: 1, color: 'chart-1' }], }).expect(400); }); @@ -4786,6 +4862,32 @@ describe('External API v2 Dashboards - new format', () => { ]); }); + it('omits colorRules when every stored rule color is unresolvable on read', async () => { + const create = await postTile({ + colorRules: [{ operator: 'gt', value: 1, color: 'chart-green' }], + }).expect(200); + const dashboardId = create.body.data.id; + + // Direct Mongo write of an unresolvable token (not reachable via the + // validated create path); the only rule drops, so the field is omitted + // rather than returned as an empty array. + await Dashboard.updateOne( + { _id: dashboardId }, + { + $set: { + 'tiles.0.config.colorRules': [ + { operator: 'gt', value: 1, color: 'not-a-token' }, + ], + }, + }, + ); + + const get = await authRequest('get', `${BASE_URL}/${dashboardId}`).expect( + 200, + ); + expect(get.body.data.tiles[0].config.colorRules).toBeUndefined(); + }); + it('normalizes a legacy numeric token on a raw SQL number tile to its hue name on read', async () => { const create = await authRequest('post', BASE_URL) .send({ diff --git a/packages/api/src/routers/external-api/v2/dashboards.ts b/packages/api/src/routers/external-api/v2/dashboards.ts index 9ccf8a6583..345ea97bf5 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -144,7 +144,9 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * example: "gt" * value: * type: number - * description: Numeric bound the displayed value is compared against. + * description: > + * Numeric bound the displayed value is compared against. Only + * finite numbers are accepted (Infinity and NaN are rejected). * example: 100 * color: * $ref: '#/components/schemas/ChartPaletteToken' @@ -173,7 +175,8 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * maxItems: 2 * items: * type: number - * description: Inclusive [min, max] range. + * description: > + * Inclusive [min, max] range. Both bounds must be finite numbers. * example: [100, 500] * color: * $ref: '#/components/schemas/ChartPaletteToken' @@ -201,7 +204,9 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * - type: number * - type: string * maxLength: 200 - * description: Number, or string up to 200 characters, to compare for equality. + * description: > + * A finite number, or a string up to 200 characters, to compare + * for equality. * example: "OK" * color: * $ref: '#/components/schemas/ChartPaletteToken' @@ -211,81 +216,27 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * maxLength: 40 * description: Optional label describing the rule. * example: "Healthy" - * StringMatchColorCondition: - * type: object - * required: - * - operator - * - value - * - color - * description: > - * Color rule matching when the displayed value contains, starts with, - * or ends with a substring. Valid in the schema for a future - * table-tile editor; the number-tile editor does not surface these - * operators today. - * properties: - * operator: - * type: string - * enum: [contains, startsWith, endsWith] - * description: Substring comparison operator. - * example: "contains" - * value: - * type: string - * minLength: 1 - * maxLength: 200 - * description: Substring to match against the displayed value. - * example: "error" - * color: - * $ref: '#/components/schemas/ChartPaletteToken' - * description: Color applied when the rule matches. - * label: - * type: string - * maxLength: 40 - * description: Optional label describing the rule. - * example: "Error" - * RegexColorCondition: - * type: object - * required: - * - operator - * - value - * - color - * description: > - * Color rule matching when the displayed value matches a regular - * expression. Valid in the schema for a future table-tile editor; - * the number-tile editor does not surface this operator today. - * properties: - * operator: - * type: string - * enum: [regex] - * description: Regular expression operator. - * example: "regex" - * value: - * type: string - * minLength: 1 - * maxLength: 500 - * description: A valid regular expression pattern. - * example: "^5[0-9][0-9]$" - * color: - * $ref: '#/components/schemas/ChartPaletteToken' - * description: Color applied when the rule matches. - * label: - * type: string - * maxLength: 40 - * description: Optional label describing the rule. - * example: "5xx" - * ColorCondition: + * NumberTileColorCondition: * description: > * A single conditional color rule for a number tile. Rules are * evaluated in order and the last matching rule wins. When no rule * matches, the static color applies, then the default text color. - * The number-tile editor surfaces the numeric and equality - * operators; the string operators are reserved for a future - * table-tile editor. + * The number-tile editor surfaces numeric and equality operators + * only. * oneOf: * - $ref: '#/components/schemas/NumericColorCondition' * - $ref: '#/components/schemas/BetweenColorCondition' * - $ref: '#/components/schemas/EqualityColorCondition' - * - $ref: '#/components/schemas/StringMatchColorCondition' - * - $ref: '#/components/schemas/RegexColorCondition' + * discriminator: + * propertyName: operator + * mapping: + * gt: '#/components/schemas/NumericColorCondition' + * gte: '#/components/schemas/NumericColorCondition' + * lt: '#/components/schemas/NumericColorCondition' + * lte: '#/components/schemas/NumericColorCondition' + * between: '#/components/schemas/BetweenColorCondition' + * eq: '#/components/schemas/EqualityColorCondition' + * neq: '#/components/schemas/EqualityColorCondition' * * TimeChartSeries: * type: object @@ -803,7 +754,7 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * value (last match wins). Falls back to color, then the default * text color when no rule matches. * items: - * $ref: '#/components/schemas/ColorCondition' + * $ref: '#/components/schemas/NumberTileColorCondition' * * 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 a53f6e62a6..81b0191f4e 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -25,6 +25,8 @@ import { isOnClickDashboardById, isOnClickSearchById, isTraceSource, + NumberTileColorCondition, + NumberTileColorConditionSchema, RawSqlSavedChartConfig, resolveChartPaletteToken, SavedChartConfig, @@ -160,14 +162,26 @@ const convertToExternalSelectItem = ( // resolved (reachable only via a direct DB write, since the input schema // validates rule colors) is dropped, mirroring how the static `color` // field omits an unresolvable token, so the response always stays within -// the palette-token enum instead of leaking an unknown string. +// the palette-token enum instead of leaking an unknown string. When no +// rule survives (or the stored array is empty) the field is omitted +// entirely rather than emitted as `[]`, matching the static `color` omit. const toExternalColorRules = ( colorRules: ColorCondition[] | undefined, -): ColorCondition[] | undefined => - colorRules?.flatMap((rule): ColorCondition[] => { +): NumberTileColorCondition[] | undefined => { + if (!colorRules) return undefined; + const resolved = colorRules.flatMap((rule): NumberTileColorCondition[] => { const color = resolveChartPaletteToken(rule.color); - return color ? [{ ...rule, color }] : []; + if (!color) return []; + // Re-validate against the number-tile subset so the response stays + // within the documented operator set: a string-match or regex rule + // (reachable only via a direct DB write, since neither the editor nor + // the input schema produces one on a number tile) is dropped, just + // like an unresolvable color token. + const parsed = NumberTileColorConditionSchema.safeParse({ ...rule, color }); + return parsed.success ? [parsed.data] : []; }); + return resolved.length > 0 ? resolved : undefined; +}; const convertToExternalTileChartConfig = ( config: SavedChartConfig, diff --git a/packages/api/src/utils/zod.ts b/packages/api/src/utils/zod.ts index 7c5cf728d3..d271c5ef4c 100644 --- a/packages/api/src/utils/zod.ts +++ b/packages/api/src/utils/zod.ts @@ -4,12 +4,12 @@ import { alertNoteSchema, AlertThresholdType, ChartPaletteTokenSchema, - ColorConditionSchema, DASHBOARD_CONTAINER_ID_MAX, DASHBOARD_MAX_TILES, DashboardFilterSchema, MetricsDataType, NumberFormatSchema, + NumberTileColorConditionSchema, OnClickDashboardSchema, OnClickSearchSchema, scheduleStartAtSchema, @@ -328,11 +328,14 @@ const externalDashboardNumberChartConfigSchema = z.object({ // editor gates to number tiles (`ChartDisplaySettingsDrawer`: // `showTileColor = displayType === DisplayType.Number`). `color` is a // hue-named palette token; `colorRules` are ordered conditional rules - // (last match wins), capped at 10 to match the editor. Both schemas are - // imported from common-utils so the external surface cannot drift from - // what the UI persists. + // (last match wins), capped at 10 to match the editor. `colorRules` uses + // `NumberTileColorConditionSchema` (numeric and equality operators only), + // not the full `ColorConditionSchema`, so the API cannot accept the + // string-match or regex rules the number-tile editor never emits. Both + // schemas are imported from common-utils so the external surface cannot + // drift from what the UI persists. color: ChartPaletteTokenSchema.optional(), - colorRules: z.array(ColorConditionSchema).max(10).optional(), + colorRules: z.array(NumberTileColorConditionSchema).max(10).optional(), }); const externalDashboardPieChartConfigSchema = z.object({ diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 95c0f157df..cc5c1a9283 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -1028,60 +1028,94 @@ export const ChartPaletteTokenSchema = z.enum(CHART_PALETTE_TOKENS); * Lives in common-utils so both the app and a future external-API parity * PR can import it. */ +// Numeric ordered operators (gt | gte | lt | lte). +const numericOrderedColorCondition = z.object({ + operator: z.enum(['gt', 'gte', 'lt', 'lte']), + value: z.number().finite(), + color: ChartPaletteTokenSchema, + label: z.string().max(40).optional(), +}); + +const betweenColorCondition = z.object({ + operator: z.literal('between'), + value: z.tuple([z.number().finite(), z.number().finite()]), + color: ChartPaletteTokenSchema, + label: z.string().max(40).optional(), +}); + +// Equality against a number or a string value. +const equalityColorCondition = z.object({ + operator: z.enum(['eq', 'neq']), + value: z.union([z.number().finite(), z.string().max(200)]), + color: ChartPaletteTokenSchema, + label: z.string().max(40).optional(), +}); + +// String-match operators, kept at the schema level only for a future +// table-tile slice (see the doc comment above). The number-tile editor +// never emits these. +const stringMatchColorCondition = z.object({ + operator: z.enum(['contains', 'startsWith', 'endsWith']), + value: z.string().min(1).max(200), + color: ChartPaletteTokenSchema, + label: z.string().max(40).optional(), +}); + +const regexColorCondition = z.object({ + operator: z.literal('regex'), + value: z + .string() + .min(1) + .max(500) + .refine( + v => { + try { + new RegExp(v); + return true; + } catch { + return false; + } + }, + { message: 'Invalid regex pattern' }, + ), + color: ChartPaletteTokenSchema, + label: z.string().max(40).optional(), +}); + export const ColorConditionSchema = z.discriminatedUnion('operator', [ - // Numeric ordered operators - z.object({ - operator: z.enum(['gt', 'gte', 'lt', 'lte']), - value: z.number().finite(), - color: ChartPaletteTokenSchema, - label: z.string().max(40).optional(), - }), - z.object({ - operator: z.literal('between'), - value: z.tuple([z.number().finite(), z.number().finite()]), - color: ChartPaletteTokenSchema, - label: z.string().max(40).optional(), - }), - // Equality (number OR string) - z.object({ - operator: z.enum(['eq', 'neq']), - value: z.union([z.number().finite(), z.string().max(200)]), - color: ChartPaletteTokenSchema, - label: z.string().max(40).optional(), - }), - // String operators (allowed at schema level for future table-tile reuse) - z.object({ - operator: z.enum(['contains', 'startsWith', 'endsWith']), - value: z.string().min(1).max(200), - color: ChartPaletteTokenSchema, - label: z.string().max(40).optional(), - }), - z.object({ - operator: z.literal('regex'), - value: z - .string() - .min(1) - .max(500) - .refine( - v => { - try { - new RegExp(v); - return true; - } catch { - return false; - } - }, - { message: 'Invalid regex pattern' }, - ), - color: ChartPaletteTokenSchema, - label: z.string().max(40).optional(), - }), + numericOrderedColorCondition, + betweenColorCondition, + equalityColorCondition, + stringMatchColorCondition, + regexColorCondition, ]); export type ColorCondition = z.infer; +/** + * The subset of color-rule operators the number-tile editor actually + * emits (`ColorRulesEditor.tsx` OPERATOR_OPTIONS: gt, gte, lt, lte, + * between, eq, neq). The external dashboards API and the MCP dashboard + * tool validate number-tile `colorRules` against this schema rather than + * the full `ColorConditionSchema`, so the authoring surface cannot accept + * the string-match or regex rules the UI can never produce (a stored + * regex would be compiled and evaluated at render time). Keep the operator + * set in sync with the editor's options. + */ +export const NumberTileColorConditionSchema = z.discriminatedUnion('operator', [ + numericOrderedColorCondition, + betweenColorCondition, + equalityColorCondition, +]); + +export type NumberTileColorCondition = z.infer< + typeof NumberTileColorConditionSchema +>; + // When making changes here, consider if they need to be made to the external API -// schema as well (packages/api/src/utils/zod.ts). +// as well: the Zod schema (packages/api/src/utils/zod.ts) and the hand-written +// OpenAPI JSDoc (packages/api/src/routers/external-api/v2/dashboards.ts), which +// duplicates this shape for the generated spec. /** * Schema describing settings which are shared between Raw SQL * chart configs and Structured ChartBuilder chart configs