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/large-suits-smoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
Comment thread
pulpdrew marked this conversation as resolved.
Resolved

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.

🔵 minor — Changeset omits @hyperdx/api despite API-visible changes

This PR changes packages/api/src/utils/zod.ts, the MCP tile schema and prompt, the v2 converters, and the published packages/api/openapi.json, but the changeset lists only @hyperdx/app, so the API changelog won't mention that raw SQL number tiles now accept and return colorRules. Add '@hyperdx/api': patch (see .changeset/promql-label-dashboard-filters.md for the multi-package form).

---

Preserve conditional color rules for SQL and PromQl chart configs
10 changes: 9 additions & 1 deletion packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2435,7 +2435,15 @@
},
"color": {
"$ref": "#/components/schemas/ChartPaletteToken",
"description": "Optional static color applied to the displayed number. Raw SQL number tiles do not support conditional colorRules.\n"
"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"
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,65 @@ describe('MCP Dashboard Tools - clickstack_patch_dashboard', () => {
]);
});

it('should preserve raw SQL number tile colorRules across a get/patch round trip', async () => {
const connectionId = ctx.connection._id.toString();
const colorRules = [
{ operator: 'lt', value: 0.95, color: 'chart-error', label: 'Down' },
{ operator: 'between', value: [0.95, 0.99], color: 'chart-warning' },
];
const createResult = await callTool(
ctx.client!,
'clickstack_save_dashboard',
{
name: 'SQL ColorRules Patch Test',
tiles: [
{
name: 'SLO',
config: {
configType: 'sql',
displayType: 'number',
connectionId,
sqlTemplate: 'SELECT 0.99 AS value LIMIT 1',
color: 'chart-success',
colorRules,
},
},
],
},
);
const created = JSON.parse(getFirstText(createResult));
const tileId = created.tiles[0].id;

const getDashboardResult = await callTool(
ctx.client!,
'clickstack_get_dashboard',
{ id: created.id },
);
const fetched = JSON.parse(getFirstText(getDashboardResult));

// Feed the fetched tile straight back in, the way an agent editing an
// existing tile would; the config must survive the round trip intact.
const patchResult = await callTool(
ctx.client!,
'clickstack_patch_dashboard',
{
dashboardId: created.id,
tileId,
tile: { name: 'SLO (patched)', config: fetched.tiles[0].config },
},
);
expect(patchResult.isError).toBeFalsy();

const getResult = await callTool(
ctx.client!,
'clickstack_get_dashboard_tile',
{ dashboardId: created.id, tileId },
);
const tile = JSON.parse(getFirstText(getResult));
expect(tile.config.color).toBe('chart-success');
expect(tile.config.colorRules).toEqual(colorRules);
});

it('should round-trip backgroundChart: patch then get_dashboard_tile', async () => {
const sourceId = ctx.traceSource._id.toString();
const createResult = await callTool(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -816,34 +816,42 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => {
expect(result.isError).toBe(true);
});

it('should keep color but drop colorRules on a raw SQL number tile', async () => {
it('should round-trip color and colorRules on a raw SQL number tile', async () => {
const connectionId = ctx.connection._id.toString();
const sqlConfig = {
configType: 'sql',
displayType: 'number',
connectionId,
sqlTemplate: 'SELECT 0.99 AS value LIMIT 1',
color: 'chart-success',
colorRules: [
{ operator: 'lt', value: 0.95, color: 'chart-error', label: 'Down' },
{ operator: 'between', value: [0.95, 0.99], color: 'chart-warning' },
],
};

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' },
],
},
},
],
tiles: [{ name: 'SLO', config: sqlConfig }],
},
);
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();
expect(saved.tiles[0].config).toMatchObject(sqlConfig);

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(sqlConfig);
});

it('should round-trip number-tile backgroundChart through save and get', async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/mcp/prompts/dashboards/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1060,7 +1060,7 @@ Colors are palette tokens, not hex. Order rules from least to most severe so the
]
}

colorRules is for number builder tiles only. Raw SQL number tiles (configType: "sql") support color but not colorRules.
Both color and colorRules work on builder number tiles and on raw SQL number tiles (configType: "sql").

== NUMBER TILE BACKGROUND CHART ==

Expand Down
13 changes: 11 additions & 2 deletions packages/api/src/mcp/tools/dashboards/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,12 @@ const numberTileColorRulesDescription =
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.';
'"number", ignored otherwise. Applied unless a colorRules entry matches ' +
'the value.';

const rawSqlNumberTileColorRulesDescription =
`${numberTileColorRulesDescription} Valid only when displayType is ` +
'"number", ignored otherwise.';

const numberTileBackgroundChartDescription =
'Optional background trend sparkline drawn behind the number, derived ' +
Expand Down Expand Up @@ -1052,6 +1056,11 @@ GROUP BY ServiceName, ts
color: ChartPaletteTokenSchema.optional().describe(
rawSqlNumberTileColorDescription,
),
colorRules: z
.array(NumberTileColorConditionSchema)
.max(10)
.optional()
.describe(rawSqlNumberTileColorRulesDescription),
onClick: mcpOnClickSchema.optional(),
}),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3086,7 +3086,7 @@ describe('External API v2 Dashboards - new format', () => {
sqlTemplate,
sourceId,
numberFormat: { output: 'currency', currencySymbol: '$' },
// Raw SQL number tiles carry the static tile color (no colorRules).
// This fixture exercises the static color; raw SQL number tiles also support colorRules.
color: 'chart-purple',
},
};
Expand Down Expand Up @@ -4780,7 +4780,7 @@ describe('External API v2 Dashboards - new format', () => {
sqlTemplate,
sourceId,
numberFormat: { output: 'currency', currencySymbol: '$' },
// Raw SQL number tiles carry the static tile color (no colorRules).
// This fixture exercises the static color; raw SQL number tiles also support colorRules.
color: 'chart-purple',
},
};
Expand Down Expand Up @@ -5879,6 +5879,13 @@ describe('External API v2 Dashboards - new format', () => {
},
});

const postRawSqlTile = (config: Record<string, unknown>) =>
authRequest('post', BASE_URL).send({
name: 'Raw SQL number color dashboard',
tiles: [rawSqlNumberTile(config)],
tags: [],
});

// ── Positive: one per UI input ──────────────────────────────────────

it('round-trips a builder number tile with a static color', async () => {
Expand Down Expand Up @@ -5974,21 +5981,52 @@ describe('External API v2 Dashboards - new format', () => {
});
});

it('strips colorRules from a raw SQL number tile, keeping color', async () => {
it('round-trips colorRules for a raw SQL number tile', async () => {
const colorRules = [
{ operator: 'gt', value: 1, color: 'chart-red' },
{ operator: 'lte', value: 1, color: 'chart-green' },
];

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' }],
colorRules,
}),
],
tags: [],
})
.expect(200);
expect(create.body.data.tiles[0].config.color).toBe('chart-blue');
expect(create.body.data.tiles[0].config.colorRules).toBeUndefined();

expect(create.body.data.tiles[0].config).toMatchObject({
color: 'chart-blue',
colorRules,
});

const dashboardId = create.body.data.id;
const get = await authRequest('get', `${BASE_URL}/${dashboardId}`).expect(
200,
);

expect(get.body.data.tiles[0].config).toMatchObject({
color: 'chart-blue',
colorRules,
});

const update = await authRequest('put', `${BASE_URL}/${dashboardId}`)
.send({
name: get.body.data.name,
tiles: get.body.data.tiles,
tags: get.body.data.tags,
})
.expect(200);

expect(update.body.data.tiles[0].config).toMatchObject({
color: 'chart-blue',
colorRules,
});
});

// ── Negative: one per schema rejection rule ─────────────────────────
Expand Down Expand Up @@ -6018,6 +6056,16 @@ describe('External API v2 Dashboards - new format', () => {
expect(res.body.message).toContain('tiles.0.config.colorRules');
});

it('rejects more than 10 colorRules for a raw SQL number tile', async () => {
const colorRules = Array.from({ length: 11 }, (_, i) => ({
operator: 'gt',
value: i,
color: 'chart-blue',
}));
const res = await postRawSqlTile({ 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' }],
Expand All @@ -6039,6 +6087,15 @@ describe('External API v2 Dashboards - new format', () => {
}
});

it('rejects unsupported colorRule operators for a raw SQL number tile', async () => {
for (const operator of ['contains', 'startsWith', 'endsWith', 'regex']) {
const res = await postRawSqlTile({
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' }],
Expand All @@ -6050,6 +6107,17 @@ describe('External API v2 Dashboards - new format', () => {
}).expect(400);
});

it('rejects a per-rule color that is not a palette token for a raw SQL number tile', async () => {
const res = await postRawSqlTile({
colorRules: [{ operator: 'gt', value: 1, color: 'red' }],
}).expect(400);
expect(res.body.message).toContain('tiles.0.config.colorRules');

await postRawSqlTile({
colorRules: [{ operator: 'gt', value: 1, color: 'chart-1' }],
}).expect(400);
});

it('rejects a rule label longer than 40 characters', async () => {
await postTile({
colorRules: [
Expand Down Expand Up @@ -6148,13 +6216,7 @@ describe('External API v2 Dashboards - new format', () => {
});

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 create = await postRawSqlTile({ color: 'chart-blue' }).expect(200);
const dashboardId = create.body.data.id;

await Dashboard.updateOne(
Expand All @@ -6168,6 +6230,32 @@ describe('External API v2 Dashboards - new format', () => {
// chart-4 maps to chart-red.
expect(get.body.data.tiles[0].config.color).toBe('chart-red');
});

it('normalizes legacy raw SQL colorRule colors and drops unresolvable ones on read', async () => {
const create = await postRawSqlTile({
colorRules: [{ operator: 'gt', value: 1, color: 'chart-green' }],
}).expect(200);
const dashboardId = create.body.data.id;

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,
);
expect(get.body.data.tiles[0].config.colorRules).toEqual([
{ operator: 'gt', value: 1, color: 'chart-green' },
]);
});
});

describe('Number tile background chart (HDX-1360)', () => {
Expand Down
11 changes: 9 additions & 2 deletions packages/api/src/routers/external-api/v2/dashboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1299,9 +1299,16 @@ const EXTERNAL_DASHBOARD_PROJECTION = {
* example: "number"
* color:
* $ref: '#/components/schemas/ChartPaletteToken'
* description: Optional static color applied to the displayed number.
* colorRules:
* type: array
* maxItems: 10
* description: >
* Optional static color applied to the displayed number. Raw
* SQL number tiles do not support conditional colorRules.
* 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'
*
* PieRawSqlChartConfig:
* description: Raw SQL configuration for a pie chart.
Expand Down
Loading
Loading