Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-number-tile-color.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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' }],
},
},
});
Expand All @@ -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' },
]);
});
});
137 changes: 135 additions & 2 deletions packages/api/src/mcp/__tests__/dashboards/saveDashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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!,
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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!,
Expand Down
27 changes: 27 additions & 0 deletions packages/api/src/mcp/prompts/dashboards/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity, why is this?


== asRatio ==

Set asRatio: true on line / stacked_bar / table tiles with exactly 2 select items
Expand Down
33 changes: 33 additions & 0 deletions packages/api/src/mcp/tools/dashboards/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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([
Expand Down Expand Up @@ -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),
}),
});

Expand Down Expand Up @@ -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(),
}),
});
Expand Down
Loading