diff --git a/.changeset/mcp-number-tile-color.md b/.changeset/mcp-number-tile-color.md new file mode 100644 index 0000000000..c83f70fe00 --- /dev/null +++ b/.changeset/mcp-number-tile-color.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/api": patch +--- + +Support number-tile color in the MCP dashboard tools. `save_dashboard` and `patch_dashboard` now accept a static `color` and conditional `colorRules` on builder number tiles, and a static `color` on raw SQL number tiles, matching the external REST dashboards API. diff --git a/packages/api/src/mcp/__tests__/dashboards/patchDashboard.test.ts b/packages/api/src/mcp/__tests__/dashboards/patchDashboard.test.ts index 98d4927c3c..dae74ab500 100644 --- a/packages/api/src/mcp/__tests__/dashboards/patchDashboard.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards/patchDashboard.test.ts @@ -115,7 +115,7 @@ describe('MCP Dashboard Tools - clickstack_patch_dashboard', () => { const created = JSON.parse(getFirstText(createResult)); const tileId = created.tiles[0].id; - // Patch without specifying layout — layout should be preserved + // Patch without specifying layout; layout should be preserved const patchResult = await callTool( ctx.client!, 'clickstack_patch_dashboard', @@ -464,7 +464,7 @@ describe('MCP Dashboard Tools - clickstack_patch_dashboard', () => { ); expect(patchResult.isError).toBeFalsy(); - // Read raw DB tiles again — tiles B and C should be byte-identical + // Read raw DB tiles again; tiles B and C should be byte-identical const afterPatch = await Dashboard.findById(dashboardId).lean(); const tilesAfterPatch = (afterPatch as any).tiles; @@ -498,7 +498,7 @@ describe('MCP Dashboard Tools - clickstack_patch_dashboard', () => { const created = JSON.parse(getFirstText(createResult)); const tileId = created.tiles[0].id; - // Patch config only — no name field at all + // Patch config only; no name field at all const patchResult = await callTool( ctx.client!, 'clickstack_patch_dashboard', @@ -566,7 +566,7 @@ describe('MCP Dashboard Tools - clickstack_patch_dashboard', () => { $pull: { tiles: { id: tileAId } }, }); - // Now try to patch tile A — it no longer exists in the array. + // Now try to patch tile A; it no longer exists in the array. const patchResult = await callTool( ctx.client!, 'clickstack_patch_dashboard', @@ -623,6 +623,8 @@ describe('MCP Dashboard Tools - clickstack_patch_dashboard', () => { displayType: 'number', sourceId, select: [{ aggFn: 'avg', valueExpression: 'Duration' }], + color: 'chart-blue', + colorRules: [{ operator: 'gte', value: 500, color: 'chart-error' }], }, }, }); @@ -643,5 +645,9 @@ describe('MCP Dashboard Tools - clickstack_patch_dashboard', () => { expect(tile.name).toBe('Patched'); expect(tile.config.displayType).toBe('number'); expect(tile.config.select[0].aggFn).toBe('avg'); + expect(tile.config.color).toBe('chart-blue'); + expect(tile.config.colorRules).toEqual([ + { operator: 'gte', value: 500, color: 'chart-error' }, + ]); }); }); diff --git a/packages/api/src/mcp/__tests__/dashboards/saveDashboard.test.ts b/packages/api/src/mcp/__tests__/dashboards/saveDashboard.test.ts index 36e359e0bf..b329eb6ed2 100644 --- a/packages/api/src/mcp/__tests__/dashboards/saveDashboard.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards/saveDashboard.test.ts @@ -393,6 +393,7 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => { connectionId, sqlTemplate: 'SELECT 0.99 AS value LIMIT 1', numberFormat: { output: 'percent' as const, mantissa: 2 }, + color: 'chart-success' as const, }; const saveResult = await callTool( @@ -438,7 +439,7 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => { ); expect(fetchedSqlTile.config).toMatchObject(sqlConfig); - // Update a DIFFERENT tile, passing all fetched tiles back — the + // Update a DIFFERENT tile, passing all fetched tiles back; the // formatted SQL tile must survive the round-trip untouched. const updateResult = await callTool( ctx.client!, @@ -713,6 +714,125 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => { expect(result.isError).toBe(true); }); + it('should round-trip number-tile color and colorRules through save and get', async () => { + const sourceId = ctx.traceSource._id.toString(); + // One rule per operator family the number-tile editor emits: + // gt/gte/lt/lte (number), between ([min, max]), eq/neq (number). + const numberConfig = { + displayType: 'number', + sourceId, + select: [{ aggFn: 'count' }], + color: 'chart-blue', + colorRules: [ + { operator: 'lt', value: 10, color: 'chart-gray' }, + { operator: 'lte', value: 0, color: 'chart-success' }, + { operator: 'between', value: [50, 99], color: 'chart-cyan' }, + { operator: 'eq', value: 42, color: 'chart-pink', label: 'Answer' }, + { operator: 'neq', value: 0, color: 'chart-purple' }, + { operator: 'gt', value: 100, color: 'chart-warning' }, + { operator: 'gte', value: 500, color: 'chart-error' }, + ], + }; + + const saveResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Number Color', + tiles: [{ name: 'Number', config: numberConfig }], + }, + ); + expect(saveResult.isError).toBeFalsy(); + const saved = JSON.parse(getFirstText(saveResult)); + expect(saved.tiles[0].config).toMatchObject(numberConfig); + + const getResult = await callTool( + ctx.client!, + 'clickstack_get_dashboard', + { + id: saved.id, + }, + ); + expect(getResult.isError).toBeFalsy(); + const fetched = JSON.parse(getFirstText(getResult)); + expect(fetched.tiles[0].config).toMatchObject(numberConfig); + }); + + it('should reject a number-tile color that is not a palette token', async () => { + const sourceId = ctx.traceSource._id.toString(); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Bad Color', + tiles: [ + { + name: 'Number', + config: { + displayType: 'number', + sourceId, + select: [{ aggFn: 'count' }], + color: 'not-a-token', + }, + }, + ], + }); + + expect(result.isError).toBe(true); + }); + + it('should reject a number-tile colorRule with an unsupported operator', async () => { + const sourceId = ctx.traceSource._id.toString(); + // `regex` is a ColorConditionSchema operator but not part of the + // narrower NumberTileColorConditionSchema the editor (and this tool) + // accept, so it must be rejected. + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Bad ColorRule', + tiles: [ + { + name: 'Number', + config: { + displayType: 'number', + sourceId, + select: [{ aggFn: 'count' }], + colorRules: [ + { operator: 'regex', value: '.*', color: 'chart-blue' }, + ], + }, + }, + ], + }); + + expect(result.isError).toBe(true); + }); + + it('should keep color but drop colorRules on a raw SQL number tile', async () => { + const connectionId = ctx.connection._id.toString(); + const saveResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'SQL Number Color', + tiles: [ + { + name: 'SLO', + config: { + configType: 'sql', + displayType: 'number', + connectionId, + sqlTemplate: 'SELECT 0.99 AS value LIMIT 1', + color: 'chart-success', + colorRules: [ + { operator: 'gte', value: 1, color: 'chart-error' }, + ], + }, + }, + ], + }, + ); + expect(saveResult.isError).toBeFalsy(); + const saved = JSON.parse(getFirstText(saveResult)); + expect(saved.tiles[0].config.color).toBe('chart-success'); + expect(saved.tiles[0].config.colorRules).toBeUndefined(); + }); + it('should preserve display fields on builder tiles through save, get, update, and re-get', async () => { const sourceId = ctx.traceSource._id.toString(); const numberFormat = { @@ -753,6 +873,19 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => { sourceId, select: [{ aggFn: 'count' as const, alias: 'Requests' }], numberFormat, + color: 'chart-green' as const, + colorRules: [ + { + operator: 'gte' as const, + value: 100, + color: 'chart-warning' as const, + }, + { + operator: 'gte' as const, + value: 500, + color: 'chart-error' as const, + }, + ], }; const tiles = [ { name: 'Line', config: lineConfig }, @@ -793,7 +926,7 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => { const fetched = JSON.parse(getFirstText(getResult)); assertTiles(fetched); - // Update passing all fetched tiles back verbatim — every display + // Update passing all fetched tiles back verbatim; every display // field must survive a second pass through the MCP input schema. const updateResult = await callTool( ctx.client!, diff --git a/packages/api/src/mcp/prompts/dashboards/content.ts b/packages/api/src/mcp/prompts/dashboards/content.ts index a79fde71ab..b93b9ad6fa 100644 --- a/packages/api/src/mcp/prompts/dashboards/content.ts +++ b/packages/api/src/mcp/prompts/dashboards/content.ts @@ -977,6 +977,33 @@ Common outputs: data_rate Bytes per second. throughput Count per second. +== NUMBER TILE COLOR == + +number tiles can color the value. Two fields on the tile config: + + color A palette token applied to the number (e.g. "chart-blue", "chart-green", "chart-success"). Use it for a fixed brand or status color. + + colorRules Conditional colors. An ordered array, evaluated with the LAST matching rule winning, falling back to color (then the default text color) when none match. Up to 10 rules. USE THIS for status dashboards where a value should turn yellow then red as it crosses thresholds. + +Each colorRules entry is { operator, value, color, label? }: + gt / gte / lt / lte value is a number. e.g. { operator: "gte", value: 500, color: "chart-error" } + between value is a [min, max] pair. e.g. { operator: "between", value: [100, 500], color: "chart-warning" } + eq / neq value is a number or string. + +Colors are palette tokens, not hex. Order rules from least to most severe so the most severe match wins. Example: a latency tile green by default, warning past 200ms, error past 500ms: + config: { + displayType: "number", + sourceId: "...", + select: [{ aggFn: "avg", valueExpression: "Duration", numberFormat: { output: "duration", factor: 0.000000001 } }], + color: "chart-green", + colorRules: [ + { operator: "gte", value: 200000000, color: "chart-warning", label: "Slow" }, + { operator: "gte", value: 500000000, color: "chart-error", label: "Critical" } + ] + } + +colorRules is for number builder tiles only. Raw SQL number tiles (configType: "sql") support color but not colorRules. + == asRatio == Set asRatio: true on line / stacked_bar / table tiles with exactly 2 select items diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index 5ad793f61a..c12d485737 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -4,11 +4,13 @@ // readability. Reformatting all separators is out of scope here. import { AggregateFunctionSchema, + ChartPaletteTokenSchema, DASHBOARD_CONTAINER_ID_MAX, DASHBOARD_MAX_CONTAINERS, DashboardContainerSchema, DashboardFilterType, MetricsDataType, + NumberTileColorConditionSchema, SearchConditionLanguageSchema, } from '@hyperdx/common-utils/dist/types'; import { z } from 'zod'; @@ -28,6 +30,26 @@ const tileLevelNumberFormatDescription = 'Most useful: { output: "duration", factor: 0.000000001 } to auto-format nanosecond durations, ' + 'or { output: "number", mantissa: 2, thousandSeparated: true } for clean counts.'; +const numberTileColorDescription = + 'Static color for the displayed number, as a palette token such as ' + + '"chart-blue", "chart-green", or "chart-success" (see the enum for the ' + + 'full set). Applied unless a colorRules entry matches the value.'; + +const numberTileColorRulesDescription = + 'Conditional colors for the number, evaluated in array order with the ' + + 'last matching rule winning; falls back to color (then the default text ' + + 'color) when none match. Up to 10 rules. Each rule is ' + + '{ operator, value, color, label? }: operator gt | gte | lt | lte with a ' + + 'number value, between with a [min, max] value, or eq | neq with a number ' + + 'or string value. color is a palette token. Example: ' + + '[{ operator: "gte", value: 500, color: "chart-error", label: "Critical" }].'; + +const rawSqlNumberTileColorDescription = + 'Static color for the displayed number, as a palette token such as ' + + '"chart-blue" or "chart-success". Valid only when displayType is ' + + '"number", ignored otherwise. Raw SQL number tiles do not support ' + + 'conditional colorRules.'; + const mcpNumberFormatSchema = z.object({ output: z .enum([ @@ -490,6 +512,14 @@ const mcpNumberTileSchema = mcpTileLayoutSchema.extend({ numberFormat: mcpNumberFormatSchema .optional() .describe(tileLevelNumberFormatDescription), + color: ChartPaletteTokenSchema.optional().describe( + numberTileColorDescription, + ), + colorRules: z + .array(NumberTileColorConditionSchema) + .max(10) + .optional() + .describe(numberTileColorRulesDescription), }), }); @@ -661,6 +691,9 @@ const mcpSqlTileSchema = mcpTileLayoutSchema.extend({ 'Scale the y-axis to the data range instead of starting at zero. ' + 'Valid only when displayType is "line", ignored otherwise.', ), + color: ChartPaletteTokenSchema.optional().describe( + rawSqlNumberTileColorDescription, + ), onClick: mcpOnClickSchema.optional(), }), });