Skip to content
Open
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
17 changes: 17 additions & 0 deletions .changeset/dashboard-filter-constant-render-modes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@hyperdx/common-utils': minor
'@hyperdx/api': minor
'@hyperdx/app': minor
---

feat(dashboards): support constant values and render modes for dashboard filters

Dashboard filters can now be locked to the dashboard's saved default value
(`constant: true`) so viewers cannot change the scope, and the filter chip
can be hidden from the filter bar or rendered as a disabled chip
(`renderMode: 'readonly' | 'hidden'`). One dashboard template can be cloned
and re-pointed by saving a different default per copy, instead of
hand-coding the scope into every tile's WHERE clause. The filter editor
exposes a single "Visibility" select with three presets (Editable, Read-only,
Hidden); the external API and MCP `hyperdx_save_dashboard` tool accept the
two new fields and preserve them across round-trips.
30 changes: 23 additions & 7 deletions packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -814,16 +814,17 @@
"type": {
"type": "string",
"enum": [
"sql"
"sql",
"lucene"
],
"default": "sql",
"description": "Filter type. Currently only \"sql\" is supported.",
"example": "sql"
"description": "Language of the `condition` expression. Use `lucene` for the\nLucene-style key:value syntax that round-trips through the\nUI's \"Save default\" flow (e.g. `ServiceName:\"hdx-private-api\"`)\nand pairs with a dashboard filter that has `constant: true`.\nUse `sql` for a raw SQL fragment evaluated as a WHERE clause\non the matching source.\n",
"example": "lucene"
},
"condition": {
"type": "string",
"description": "SQL filter condition. For example use expressions in the form \"column IN ('value')\".",
"example": "ServiceName IN ('hdx-oss-dev-api')"
"description": "Filter condition. For `type: sql`, a raw SQL expression in\nthe form `column IN ('value')`. For `type: lucene`, a\nLucene-style `key:value` string keyed by a dashboard\nfilter's `expression`; this is the shape the UI writes\nwhen an author clicks \"Save default\" on a chip and the\nshape constant filters consume.\n",
"example": "ServiceName:\"hdx-oss-dev-api\""
}
}
},
Expand Down Expand Up @@ -2739,6 +2740,21 @@
"example": [
"65f5e4a3b9e77c001a111111"
]
},
"constant": {
"type": "boolean",
"description": "When true, the value from the dashboard's savedFilterValues matched\nby this filter's expression is applied automatically on every tile\nthis filter scopes, and viewers cannot change it. Use this to lock\na dashboard template to a single scope (clone the dashboard, save a\ndifferent default per copy). Pairs with renderMode to control how\nthe locked filter shows in the filter bar. Omit (or send false)\nfor an ordinary editable filter (the implicit default behavior).\nTwo filters that share the same expression on the same dashboard\nmust agree on `constant`: mixing one locked sibling and one\neditable sibling on the same expression is rejected.\n",
"example": true
},
"renderMode": {
"type": "string",
"enum": [
"editable",
"readonly",
"hidden"
],
"description": "Controls how this filter renders in the dashboard filter bar.\nOmit for the implicit \"editable\" behavior (normal dropdown the\nviewer can change). \"readonly\" shows a disabled chip with a lock\nicon; the viewer sees the locked value but cannot edit it.\n\"hidden\" omits the chip entirely; the locked value still scopes\nevery matching tile. \"readonly\" and \"hidden\" require constant: true.\n",
"example": "readonly"
}
}
},
Expand Down Expand Up @@ -2888,7 +2904,7 @@
},
"savedFilterValues": {
"type": "array",
"description": "Optional default dashboard filter values to persist on the dashboard.",
"description": "Optional default dashboard filter values to persist on the dashboard.\nDrop any entries whose expression does not match a filter you are\nkeeping in the `filters` array, otherwise they remain as orphaned\nsaved values invisible to the UI editor.\n",
"items": {
"$ref": "#/components/schemas/SavedFilterValue"
}
Expand Down Expand Up @@ -2959,7 +2975,7 @@
},
"savedFilterValues": {
"type": "array",
"description": "Optional default dashboard filter values to persist on the dashboard.",
"description": "Optional default dashboard filter values to persist on the dashboard.\nOn update, this array is overwritten as a whole. Drop any entries\nwhose expression does not match a filter you kept in `filters` so\nthey do not remain as orphaned scope locks.\n",
"items": {
"$ref": "#/components/schemas/SavedFilterValue"
}
Expand Down
261 changes: 261 additions & 0 deletions packages/api/src/mcp/__tests__/dashboards/dashboardFilters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import { callTool, getFirstText } from '../mcpTestUtils';
import { setupDashboardTests } from './setup';

// Filter modes (HDX-4404) via the MCP save/get path. The MCP input schema
// delegates to the same create/update body schemas the v2 REST path uses,
// so these guard that the MCP tool lights up the filter round-trip and the
// constant/renderMode coherence rejections at the input boundary.
describe('MCP Dashboard Tools - dashboard filters (HDX-4404)', () => {
const ctx = setupDashboardTests();

const traceTile = (sourceId: string) => ({
name: 'Volume',
x: 0,
y: 0,
w: 6,
h: 3,
config: {
displayType: 'line' as const,
sourceId,
select: [{ aggFn: 'count' as const, where: '' }],
},
});

it('should round-trip filters on create, get, and update', async () => {
const sourceId = ctx.traceSource._id.toString();

const createResult = await callTool(
ctx.client!,
'clickstack_save_dashboard',
{
name: 'Service Detail (MCP filter round-trip)',
tiles: [traceTile(sourceId)],
filters: [
{
type: 'QUERY_EXPRESSION',
name: 'Service',
expression: 'ServiceName',
sourceId,
},
{
type: 'QUERY_EXPRESSION',
name: 'Environment',
expression: 'deployment.environment',
sourceId,
where: "deployment.environment = 'production'",
whereLanguage: 'sql',
},
],
},
);

expect(createResult.isError).toBeFalsy();
const created = JSON.parse(getFirstText(createResult));
expect(Array.isArray(created.filters)).toBe(true);
expect(created.filters).toHaveLength(2);
const [serviceFilter, envFilter] = created.filters;
expect(serviceFilter).toMatchObject({
type: 'QUERY_EXPRESSION',
name: 'Service',
expression: 'ServiceName',
sourceId,
});
expect(typeof serviceFilter.id).toBe('string');
expect(serviceFilter.id.length).toBeGreaterThan(0);
expect(envFilter).toMatchObject({
type: 'QUERY_EXPRESSION',
name: 'Environment',
expression: 'deployment.environment',
sourceId,
where: "deployment.environment = 'production'",
whereLanguage: 'sql',
});

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

const updateResult = await callTool(
ctx.client!,
'clickstack_save_dashboard',
{
id: created.id,
name: 'Service Detail (MCP filter round-trip)',
tiles: [traceTile(sourceId)],
filters: [
{
id: serviceFilter.id,
type: 'QUERY_EXPRESSION',
name: 'Service (renamed)',
expression: 'ServiceName',
sourceId,
},
],
},
);

expect(updateResult.isError).toBeFalsy();
const updated = JSON.parse(getFirstText(updateResult));
expect(updated.filters).toHaveLength(1);
expect(updated.filters[0]).toMatchObject({
id: serviceFilter.id,
type: 'QUERY_EXPRESSION',
name: 'Service (renamed)',
expression: 'ServiceName',
sourceId,
});
});

it('should round-trip constant and renderMode on filters (HDX-4404)', async () => {
const sourceId = ctx.traceSource._id.toString();
const savedFilterValues = [
{
type: 'sql' as const,
condition: "ServiceName IN ('hdx-private-api')",
},
];
const createResult = await callTool(
ctx.client!,
'clickstack_save_dashboard',
{
name: 'Locked dashboard template',
tiles: [traceTile(sourceId)],
filters: [
{
type: 'QUERY_EXPRESSION',
name: 'Service (locked)',
expression: 'ServiceName',
sourceId,
whereLanguage: 'sql',
constant: true,
renderMode: 'readonly',
},
],
savedFilterValues,
},
);
expect(createResult.isError).toBeFalsy();
const created = JSON.parse(getFirstText(createResult));
expect(created.savedFilterValues).toEqual(savedFilterValues);

// GET preserves savedFilterValues verbatim alongside the filter.
const getResult = await callTool(ctx.client!, 'clickstack_get_dashboard', {
id: created.id,
});
const fetched = JSON.parse(getFirstText(getResult));
expect(fetched.savedFilterValues).toEqual(savedFilterValues);
expect(fetched.filters).toEqual(created.filters);

// UPDATE with a different saved value: the new value replaces the
// old one verbatim (clone-and-flip semantics).
const nextSavedFilterValues = [
{
type: 'sql' as const,
condition: "ServiceName IN ('hdx-public-api')",
},
];
const updateResult = await callTool(
ctx.client!,
'clickstack_save_dashboard',
{
id: created.id,
name: 'Locked dashboard template',
tiles: [traceTile(sourceId)],
filters: [
{
id: created.filters[0].id,
type: 'QUERY_EXPRESSION',
name: 'Service (locked)',
expression: 'ServiceName',
sourceId,
whereLanguage: 'sql',
constant: true,
renderMode: 'readonly',
},
],
savedFilterValues: nextSavedFilterValues,
},
);
expect(updateResult.isError).toBeFalsy();
const updated = JSON.parse(getFirstText(updateResult));
expect(updated.savedFilterValues).toEqual(nextSavedFilterValues);
});

it('should reject mismatched sibling constants on the same expression (HDX-4404)', async () => {
// Two filters on the same expression where one is constant and the
// other editable: the editable side's URL value would clobber the
// constant's locked value. The sibling refinement rejects this.
const sourceId = ctx.traceSource._id.toString();
const result = await callTool(ctx.client!, 'clickstack_save_dashboard', {
name: 'Mismatched siblings',
tiles: [traceTile(sourceId)],
filters: [
{
type: 'QUERY_EXPRESSION',
name: 'Service (locked)',
expression: 'ServiceName',
sourceId,
whereLanguage: 'sql',
constant: true,
renderMode: 'readonly',
},
{
type: 'QUERY_EXPRESSION',
name: 'Service (editable)',
expression: 'ServiceName',
sourceId,
whereLanguage: 'sql',
},
],
});
expect(result.isError).toBeTruthy();
});

it('should reject renderMode without constant: true on the MCP schema (HDX-4404)', async () => {
// renderMode 'readonly' without constant: true paints a locked-looking
// chip the hook never overlays, so the WHERE clause never gains the
// value. Rejected at the input boundary.
const sourceId = ctx.traceSource._id.toString();
const result = await callTool(ctx.client!, 'clickstack_save_dashboard', {
name: 'Incoherent renderMode',
tiles: [traceTile(sourceId)],
filters: [
{
type: 'QUERY_EXPRESSION',
name: 'Service',
expression: 'ServiceName',
sourceId,
whereLanguage: 'sql',
renderMode: 'readonly',
},
],
});
expect(result.isError).toBeTruthy();
});

it('should reject constant: true with no matching savedFilterValues entry (HDX-4404)', async () => {
// A constant filter is useful only when there is a value to lock to.
// Without a matching savedFilterValues entry the chip renders locked
// but the WHERE clause never applies, so reject at the boundary.
const sourceId = ctx.traceSource._id.toString();
const result = await callTool(ctx.client!, 'clickstack_save_dashboard', {
name: 'Locked-but-no-saved-value',
tiles: [traceTile(sourceId)],
filters: [
{
type: 'QUERY_EXPRESSION',
name: 'Service (locked)',
expression: 'ServiceName',
sourceId,
whereLanguage: 'sql',
constant: true,
renderMode: 'readonly',
},
],
// No savedFilterValues to back the constant filter.
});
expect(result.isError).toBeTruthy();
});
});
Loading
Loading