diff --git a/.changeset/number-tile-color-external-api.md b/.changeset/number-tile-color-external-api.md new file mode 100644 index 0000000000..3739b66c4f --- /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. 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 9bcad76117..d043230d7e 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -779,6 +779,174 @@ } } }, + "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. Only finite numbers are accepted (Infinity and NaN are rejected).\n", + "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. Both bounds must be finite numbers.\n", + "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": "A finite number, or a string up to 200 characters, to compare for equality.\n", + "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" + } + } + }, + "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" + }, + { + "$ref": "#/components/schemas/BetweenColorCondition" + }, + { + "$ref": "#/components/schemas/EqualityColorCondition" + } + ], + "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", "required": [ @@ -1417,6 +1585,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/NumberTileColorCondition" + } } } }, @@ -1772,6 +1952,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..cdf07957bc 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,338 @@ 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: '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-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); + + 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'); + }); + + 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 () => { + const res = await postTile({ color: 'red' }).expect(400); + expect(res.body.message).toContain('tiles.0.config.color'); + await postTile({ color: 'chart-99' }).expect(400); + 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', + })); + 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 () => { + 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 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: 'gt', value: 1, color: 'chart-1' }], + }).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 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('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({ + 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..345ea97bf5 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -122,6 +122,122 @@ 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. Only + * finite numbers are accepted (Infinity and NaN are rejected). + * 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. Both bounds must be finite numbers. + * 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: > + * A finite number, or a 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" + * 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. + * oneOf: + * - $ref: '#/components/schemas/NumericColorCondition' + * - $ref: '#/components/schemas/BetweenColorCondition' + * - $ref: '#/components/schemas/EqualityColorCondition' + * 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 * required: @@ -627,6 +743,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/NumberTileColorCondition' * * PieBuilderChartConfig: * type: object @@ -902,6 +1030,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..81b0191f4e 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, @@ -23,7 +25,10 @@ import { isOnClickDashboardById, isOnClickSearchById, isTraceSource, + NumberTileColorCondition, + NumberTileColorConditionSchema, RawSqlSavedChartConfig, + resolveChartPaletteToken, SavedChartConfig, } from '@hyperdx/common-utils/dist/types'; import { SearchConditionLanguageSchema as whereLanguageSchema } from '@hyperdx/common-utils/dist/types'; @@ -148,6 +153,36 @@ 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` 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. 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, +): NumberTileColorCondition[] | undefined => { + if (!colorRules) return undefined; + const resolved = colorRules.flatMap((rule): NumberTileColorCondition[] => { + const color = resolveChartPaletteToken(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, ): ExternalDashboardTileConfig | undefined => { @@ -195,6 +230,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 +315,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 +625,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 +690,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..d271c5ef4c 100644 --- a/packages/api/src/utils/zod.ts +++ b/packages/api/src/utils/zod.ts @@ -3,11 +3,13 @@ import { AggregateFunctionSchema, alertNoteSchema, AlertThresholdType, + ChartPaletteTokenSchema, DASHBOARD_CONTAINER_ID_MAX, DASHBOARD_MAX_TILES, DashboardFilterSchema, MetricsDataType, NumberFormatSchema, + NumberTileColorConditionSchema, OnClickDashboardSchema, OnClickSearchSchema, scheduleStartAtSchema, @@ -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,19 @@ 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. `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(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 dc38e71b2f..28d38ec500 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -1029,60 +1029,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