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 @@ -628,16 +628,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 @@ -2359,6 +2360,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 @@ -2508,7 +2524,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 @@ -2579,7 +2595,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
309 changes: 309 additions & 0 deletions packages/api/src/mcp/__tests__/dashboards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1352,6 +1352,315 @@ describe('MCP Dashboard Tools', () => {
expect(envFilter.id).not.toBe(existingFilterId);
});

it('should round-trip constant and renderMode on filters (HDX-4404)', async () => {
const sourceId = traceSource._id.toString();

// CREATE: a dashboard with one locked-readonly filter, one hidden
// filter, and one default editable filter.
const createResult = await callTool(client, 'hyperdx_save_dashboard', {
name: 'Cloneable dashboard template',
tiles: [traceTile(sourceId)],
filters: [
{
type: 'QUERY_EXPRESSION',
name: 'Service (locked, read-only)',
expression: 'ServiceName',
sourceId,
constant: true,
renderMode: 'readonly',
},
{
type: 'QUERY_EXPRESSION',
name: 'Environment (hidden)',
expression: 'environment',
sourceId,
constant: true,
renderMode: 'hidden',
},
{
type: 'QUERY_EXPRESSION',
name: 'Region',
expression: 'region',
sourceId,
},
],
// Each constant filter needs a matching savedFilterValues
// entry on the same Lucene-keyed expression; the schema-level
// coherence rule rejects a locked filter without a value.
savedFilterValues: [
{
type: 'lucene' as const,
condition: 'ServiceName:"hdx-private-api"',
},
{
type: 'lucene' as const,
condition: 'environment:"production"',
},
],
});
expect(createResult.isError).toBeFalsy();
const created = JSON.parse(getFirstText(createResult));
expect(created.filters).toHaveLength(3);
expect(created.filters[0]).toMatchObject({
name: 'Service (locked, read-only)',
constant: true,
renderMode: 'readonly',
});
expect(created.filters[1]).toMatchObject({
name: 'Environment (hidden)',
constant: true,
renderMode: 'hidden',
});
expect(created.filters[2].constant).toBeUndefined();
expect(created.filters[2].renderMode).toBeUndefined();

// GET: same dashboard via hyperdx_get_dashboard preserves the new
// fields verbatim.
const getResult = await callTool(client, 'hyperdx_get_dashboard', {
id: created.id,
});
const fetched = JSON.parse(getFirstText(getResult));
expect(fetched.filters).toEqual(created.filters);

// UPDATE: flip the editable filter to read-only and keep the others.
// All three filters are now constant: true, so savedFilterValues
// must cover all three expressions.
const updateResult = await callTool(client, 'hyperdx_save_dashboard', {
id: created.id,
name: 'Cloneable dashboard template',
tiles: [traceTile(sourceId)],
filters: [
{
id: created.filters[0].id,
type: 'QUERY_EXPRESSION',
name: 'Service (locked, read-only)',
expression: 'ServiceName',
sourceId,
constant: true,
renderMode: 'readonly',
},
{
id: created.filters[1].id,
type: 'QUERY_EXPRESSION',
name: 'Environment (hidden)',
expression: 'environment',
sourceId,
constant: true,
renderMode: 'hidden',
},
{
id: created.filters[2].id,
type: 'QUERY_EXPRESSION',
name: 'Region (now read-only)',
expression: 'region',
sourceId,
constant: true,
renderMode: 'readonly',
},
],
savedFilterValues: [
{
type: 'lucene' as const,
condition: 'ServiceName:"hdx-private-api"',
},
{
type: 'lucene' as const,
condition: 'environment:"production"',
},
{
type: 'lucene' as const,
condition: 'region:"us-east-2"',
},
],
});
expect(updateResult.isError).toBeFalsy();
const updated = JSON.parse(getFirstText(updateResult));
expect(updated.filters).toHaveLength(3);
expect(updated.filters[2]).toMatchObject({
id: created.filters[2].id,
name: 'Region (now read-only)',
constant: true,
renderMode: 'readonly',
});
});

it('should reject an unknown renderMode value (HDX-4404)', async () => {
const sourceId = traceSource._id.toString();
const result = await callTool(client, 'hyperdx_save_dashboard', {
name: 'Bad renderMode',
tiles: [traceTile(sourceId)],
filters: [
{
type: 'QUERY_EXPRESSION',
name: 'Service',
expression: 'ServiceName',
sourceId,
renderMode: 'invisible',
},
],
});
expect(result.isError).toBeTruthy();
});

it('should round-trip savedFilterValues paired with a constant filter (HDX-4404)', async () => {
// The locked-scope contract: a `constant: true` filter pulls its
// value from the dashboard-level `savedFilterValues` array. The
// MCP caller is the only path that can supply both in one shot,
// so a regression that drops `savedFilterValues` on the wire (or
// on the response) would silently break locked dashboards even
// though the per-filter round-trip above still passes. Use
// `toEqual` on the whole array to catch schema drift in either
// direction (missing fields OR unexpected extra fields).
const sourceId = traceSource._id.toString();
const savedFilterValues = [
{
type: 'lucene' as const,
condition: 'ServiceName:"hdx-private-api"',
},
];
const createResult = await callTool(client, 'hyperdx_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(client, 'hyperdx_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: 'lucene' as const,
condition: 'ServiceName:"hdx-public-api"',
},
];
const updateResult = await callTool(client, 'hyperdx_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 (`ServiceName`) where one is
// `constant: true` and the other is editable: the runtime overlay
// would have the editable side's URL value clobber the constant's
// locked value while `setFilterValue` still no-ops the writes.
// The dashboard-level sibling refinement rejects this combination.
const sourceId = traceSource._id.toString();
const result = await callTool(client, 'hyperdx_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 () => {
// The MCP schema now carries the coherence refinement directly so
// an LLM caller hits the rule at the input boundary rather than
// via a downstream server-side rejection. renderMode 'readonly'
// without `constant: true` would paint a locked-looking chip that
// the hook never overlays, so the WHERE clause never gains the
// value.
const sourceId = traceSource._id.toString();
const result = await callTool(client, 'hyperdx_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 () => {
// The clone-and-flip contract: a `constant: true` filter is
// useful only when there is a value to lock to. Without a
// matching `savedFilterValues` entry on the same Lucene-keyed
// expression, the chip renders with a lock icon but the WHERE
// clause never applies. Reject at the boundary so an MCP-
// authored template can't ship in this broken state.
const sourceId = traceSource._id.toString();
const result = await callTool(client, 'hyperdx_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();
});

it('should round-trip a table tile that uses a having clause', async () => {
// mcpTableTileSchema exposes `having` so the service_detail
// example's "Top Error Messages" pattern (groupBy StatusMessage
Expand Down
Loading
Loading