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.
15 changes: 15 additions & 0 deletions packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2359,6 +2359,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).\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
121 changes: 121 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,127 @@ 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,
},
],
});
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.
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',
},
],
});
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 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
33 changes: 29 additions & 4 deletions packages/api/src/mcp/tools/dashboards/saveDashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ import {
resolveSavedQueryLanguage,
updateDashboardBodySchema,
} from '@/routers/external-api/v2/utils/dashboards';
import type {
ExternalDashboardFilter,
ExternalDashboardFilterWithId,
ExternalDashboardTileWithId,
import {
type ExternalDashboardFilter,
type ExternalDashboardFilterWithId,
type ExternalDashboardSavedFilterValue,
externalDashboardSavedFilterValueSchema,
type ExternalDashboardTileWithId,
} from '@/utils/zod';

import { withToolTracing } from '../../utils/tracing';
Expand Down Expand Up @@ -62,6 +64,20 @@ export function registerSaveDashboard(
tags: z.array(z.string()).optional().describe('Dashboard tags'),
containers: mcpContainersParam.optional(),
filters: mcpFiltersParam.optional(),
savedFilterValues: z
.array(externalDashboardSavedFilterValueSchema)
.optional()
.describe(
'Optional saved default values for the dashboard filters. Each ' +
'entry is a Lucene or SQL `condition` string keyed by a filter ' +
'expression (e.g. `ServiceName:"hdx-private-api"`). ' +
'Pair this with `constant: true` on a filter in the `filters` ' +
'array to lock that filter to a specific value: the matching ' +
'savedFilterValues entry is applied automatically on every ' +
'tile and the viewer cannot override it. ' +
'If you set `constant: true` without a corresponding ' +
'savedFilterValues entry, the filter has no effect.',
),
}),
},
withToolTracing(
Expand All @@ -74,6 +90,7 @@ export function registerSaveDashboard(
tags,
containers,
filters: inputFilters,
savedFilterValues: inputSavedFilterValues,
}) => {
if (!dashboardId) {
return createDashboard({
Expand All @@ -84,6 +101,7 @@ export function registerSaveDashboard(
tags,
containers,
inputFilters,
inputSavedFilterValues,
});
}
return updateDashboard({
Expand All @@ -95,6 +113,7 @@ export function registerSaveDashboard(
tags,
containers,
inputFilters,
inputSavedFilterValues,
});
},
),
Expand Down Expand Up @@ -147,6 +166,7 @@ async function createDashboard({
tags,
containers,
inputFilters,
inputSavedFilterValues,
}: {
teamId: string;
frontendUrl: string | undefined;
Expand All @@ -157,13 +177,15 @@ async function createDashboard({
inputFilters:
| (ExternalDashboardFilter | ExternalDashboardFilterWithId)[]
| undefined;
inputSavedFilterValues: ExternalDashboardSavedFilterValue[] | undefined;
}) {
const parsed = createDashboardBodySchema.safeParse({
name,
tiles: inputTiles,
tags,
containers,
filters: stripFilterIds(inputFilters),
savedFilterValues: inputSavedFilterValues,
});
if (!parsed.success) {
return {
Expand Down Expand Up @@ -332,6 +354,7 @@ async function updateDashboard({
tags,
containers,
inputFilters,
inputSavedFilterValues,
}: {
teamId: string;
frontendUrl: string | undefined;
Expand All @@ -343,6 +366,7 @@ async function updateDashboard({
inputFilters:
| (ExternalDashboardFilter | ExternalDashboardFilterWithId)[]
| undefined;
inputSavedFilterValues: ExternalDashboardSavedFilterValue[] | undefined;
}) {
if (!mongoose.Types.ObjectId.isValid(dashboardId)) {
return {
Expand All @@ -357,6 +381,7 @@ async function updateDashboard({
tags,
containers,
filters: assignFilterIds(inputFilters),
savedFilterValues: inputSavedFilterValues,
});
if (!parsed.success) {
return {
Expand Down
43 changes: 41 additions & 2 deletions packages/api/src/mcp/tools/dashboards/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,27 @@ const mcpDashboardFilterSchema = z
'Useful on mixed-source dashboards where a column (e.g. SpanName) only exists on ' +
'a subset of sources.',
),
constant: z
.boolean()
.optional()
.describe(
'Optional. When true, the dashboard "scope" filter pattern: the value from the dashboard\'s ' +
"savedFilterValues matched by this filter's `expression` is applied automatically on " +
'every matching tile, and viewers cannot change it. Use this to lock a dashboard template ' +
'to a specific scope so it can be cloned and re-pointed by saving a different default ' +
'value per copy. Pair with `renderMode` to control how the locked filter shows in the bar.',
),
renderMode: z
.enum(['editable', 'readonly', 'hidden'])
.optional()
.describe(
'Optional. Controls how this filter renders in the dashboard filter bar. ' +
'"editable" (default) shows a normal dropdown the viewer can change. ' +
'"readonly" shows a disabled chip with a lock icon; the viewer sees the locked value ' +
'but cannot edit it. ' +
'"hidden" omits the chip entirely; the locked value still scopes every matching tile. ' +
'Typically used together with `constant: true`.',
),
})
.describe(
'A dashboard-level filter the user can adjust in the dashboard filter bar. ' +
Expand All @@ -718,8 +739,15 @@ export const mcpFiltersParam = z
'dropped on arrival and the destination opens unfiltered.\n\n' +
'By default a filter applies to every tile on the dashboard. On mixed-source dashboards, ' +
'use the optional `appliesToSourceIds` field to restrict a filter to only the tiles whose ' +
'source carries the referenced column leave `appliesToSourceIds` omitted to keep the ' +
'source carries the referenced column; leave `appliesToSourceIds` omitted to keep the ' +
'broadcast-to-all-tiles default.\n\n' +
'For dashboards meant as cloneable templates, set `constant: true` on a filter to lock ' +
"its value to the dashboard's saved default (matched by `expression`); pair with " +
'`renderMode: "readonly"` to show a disabled chip or `"hidden"` to drop the chip ' +
'entirely while keeping the WHERE clause active. Locked filters cannot be cleared by ' +
'the viewer. The locked value comes from the dashboard-level `savedFilterValues` ' +
"array (matched by this filter's `expression`); set both together in the same " +
'`hyperdx_save_dashboard` call.\n\n' +
'Example (broadcast to every tile):\n' +
'[\n' +
' { "type": "QUERY_EXPRESSION", "name": "Service", "expression": "ServiceName",\n' +
Expand All @@ -730,7 +758,18 @@ export const mcpFiltersParam = z
' { "type": "QUERY_EXPRESSION", "name": "Service", "expression": "SpanName",\n' +
' "sourceId": "<trace-source-id>", "whereLanguage": "sql",\n' +
' "appliesToSourceIds": ["<trace-source-id>"] }\n' +
']',
']\n\n' +
'Example (locked scope-filter template) - pair with the top-level ' +
'savedFilterValues array on the dashboard call so the constant filter ' +
'has a value to apply:\n' +
'[\n' +
' { "type": "QUERY_EXPRESSION", "name": "Service", "expression": "ServiceName",\n' +
' "sourceId": "<trace-source-id>", "whereLanguage": "sql",\n' +
' "constant": true, "renderMode": "readonly" }\n' +
']\n' +
'plus on the dashboard call body:\n' +
' "savedFilterValues": [ { "type": "lucene",\n' +
' "condition": "ServiceName:\\"hdx-private-api\\"" } ]',
);

export const mcpContainersParam = z
Expand Down
Loading
Loading