diff --git a/.changeset/dashboard-filter-constant-render-modes.md b/.changeset/dashboard-filter-constant-render-modes.md new file mode 100644 index 0000000000..15584f5aae --- /dev/null +++ b/.changeset/dashboard-filter-constant-render-modes.md @@ -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. diff --git a/packages/api/openapi.json b/packages/api/openapi.json index 6018c8300c..3e2e1bbc51 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -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\"" } } }, @@ -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" } } }, @@ -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" } @@ -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" } diff --git a/packages/api/src/mcp/__tests__/dashboards.test.ts b/packages/api/src/mcp/__tests__/dashboards.test.ts index 6ed04e2ff9..a0c8a40418 100644 --- a/packages/api/src/mcp/__tests__/dashboards.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards.test.ts @@ -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 diff --git a/packages/api/src/mcp/tools/dashboards/saveDashboard.ts b/packages/api/src/mcp/tools/dashboards/saveDashboard.ts index d6bee8eb5c..cb5f096f7e 100644 --- a/packages/api/src/mcp/tools/dashboards/saveDashboard.ts +++ b/packages/api/src/mcp/tools/dashboards/saveDashboard.ts @@ -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'; @@ -62,6 +64,23 @@ 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. ' + + 'On UPDATE, this array is overwritten as a whole: drop any ' + + 'entries for filter expressions you removed from the `filters` ' + + 'array so they do not stay as orphaned scope locks.', + ), }), }, withToolTracing( @@ -74,6 +93,7 @@ export function registerSaveDashboard( tags, containers, filters: inputFilters, + savedFilterValues: inputSavedFilterValues, }) => { if (!dashboardId) { return createDashboard({ @@ -84,6 +104,7 @@ export function registerSaveDashboard( tags, containers, inputFilters, + inputSavedFilterValues, }); } return updateDashboard({ @@ -95,6 +116,7 @@ export function registerSaveDashboard( tags, containers, inputFilters, + inputSavedFilterValues, }); }, ), @@ -147,6 +169,7 @@ async function createDashboard({ tags, containers, inputFilters, + inputSavedFilterValues, }: { teamId: string; frontendUrl: string | undefined; @@ -157,6 +180,7 @@ async function createDashboard({ inputFilters: | (ExternalDashboardFilter | ExternalDashboardFilterWithId)[] | undefined; + inputSavedFilterValues: ExternalDashboardSavedFilterValue[] | undefined; }) { const parsed = createDashboardBodySchema.safeParse({ name, @@ -164,6 +188,7 @@ async function createDashboard({ tags, containers, filters: stripFilterIds(inputFilters), + savedFilterValues: inputSavedFilterValues, }); if (!parsed.success) { return { @@ -332,6 +357,7 @@ async function updateDashboard({ tags, containers, inputFilters, + inputSavedFilterValues, }: { teamId: string; frontendUrl: string | undefined; @@ -343,6 +369,7 @@ async function updateDashboard({ inputFilters: | (ExternalDashboardFilter | ExternalDashboardFilterWithId)[] | undefined; + inputSavedFilterValues: ExternalDashboardSavedFilterValue[] | undefined; }) { if (!mongoose.Types.ObjectId.isValid(dashboardId)) { return { @@ -357,6 +384,7 @@ async function updateDashboard({ tags, containers, filters: assignFilterIds(inputFilters), + savedFilterValues: inputSavedFilterValues, }); if (!parsed.success) { return { diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index 8a0fc5e8d1..0a374ebbf5 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -7,8 +7,11 @@ import { DASHBOARD_CONTAINER_ID_MAX, DASHBOARD_MAX_CONTAINERS, DashboardContainerSchema, + DashboardFilter, DashboardFilterType, MetricsDataType, + refineDashboardFilterCoherence, + refineDashboardFiltersConstantSiblings, SearchConditionLanguageSchema, } from '@hyperdx/common-utils/dist/types'; import { z } from 'zod'; @@ -700,16 +703,58 @@ 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`; setting renderMode to "readonly" or ' + + '"hidden" WITHOUT constant: true is rejected because the chip is locked but no value ' + + 'applies.', + ), }) + // Surface the coherence rule (constant + renderMode interlock) on the + // schema itself so an MCP caller hits the same boundary the external + // API enforces, instead of learning the rule only via a downstream + // server-side rejection. + .superRefine(refineDashboardFilterCoherence) .describe( 'A dashboard-level filter the user can adjust in the dashboard filter bar. ' + 'Each filter binds a label/name to a column expression on a source. ' + "Filters are also the contract for row-click navigation: a table tile's " + - 'onClick.filters[i].expression must match a filter declared here for the value to land.', + 'onClick.filters[i].expression must match a filter declared here for the value to land. ' + + 'Two filters that share the same expression on the same dashboard must agree on `constant`: ' + + 'mixing one locked sibling and one editable sibling is rejected because the runtime ' + + "would let the editable side's URL value clobber the constant's locked value.", ); export const mcpFiltersParam = z .array(mcpDashboardFilterSchema) + // Mirror the array-level refinement the external API runs in + // `buildDashboardBodySchema`: reject mixing constant: true + editable + // siblings on the same expression so an MCP caller gets the same + // boundary error the v2 HTTP path returns, instead of a downstream + // server-side rejection wrapped in a Validation error response. + .superRefine((filters, ctx) => + refineDashboardFiltersConstantSiblings( + filters as unknown as DashboardFilter[], + ctx, + ), + ) .describe( 'Optional dashboard-level filters. These define the dropdowns in the dashboard filter ' + 'bar AND the expressions that table-tile row-click navigation can populate. ' + @@ -718,8 +763,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' + @@ -730,7 +782,18 @@ export const mcpFiltersParam = z ' { "type": "QUERY_EXPRESSION", "name": "Service", "expression": "SpanName",\n' + ' "sourceId": "", "whereLanguage": "sql",\n' + ' "appliesToSourceIds": [""] }\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": "", "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 diff --git a/packages/api/src/routers/api/dashboards.ts b/packages/api/src/routers/api/dashboards.ts index 8ac2ea2f30..1cb024e847 100644 --- a/packages/api/src/routers/api/dashboards.ts +++ b/packages/api/src/routers/api/dashboards.ts @@ -1,8 +1,11 @@ import { + DashboardFilter, DashboardSchema, DashboardWithoutIdSchema, PresetDashboard, PresetDashboardFilterSchema, + refineDashboardFilterCoherence, + refineDashboardFiltersConstantSiblings, } from '@hyperdx/common-utils/dist/types'; import express from 'express'; import _ from 'lodash'; @@ -29,6 +32,59 @@ import { objectIdSchema } from '@/utils/zod'; // create routes that will get and update dashboards const router = express.Router(); +// Apply the same dashboard-filter coherence refinements the +// external-API and MCP entry points run, but at the internal API's +// route boundary. The shared `DashboardSchema` is intentionally left +// as a plain `ZodObject` so the various `.omit()` / `.partial()` / +// `.extend()` chains (DashboardWithoutIdSchema, DashboardSchema.partial(), +// DashboardTemplateSchema) keep working; that means the cross-filter +// rules are not attached on the schema itself. Wrap the per-route +// body schemas here so a server-internal caller (or any non-MCP / +// non-external-API path) gets the same rejection surface for: +// 1. renderMode: 'readonly' | 'hidden' without constant: true +// 2. mixed `constant: true` + editable siblings on the same expression +// +// Per-filter coherence is checked via a tiny pass-through schema so +// `refineDashboardFilterCoherence` can run with its existing ctx +// signature (the helper writes `path: ['renderMode']`; the outer +// ctx prepends `['filters', i]` on issues raised through the inner +// parser). +const perFilterCoherenceSchema = z + .object({ + constant: z.boolean().optional(), + renderMode: z.enum(['editable', 'readonly', 'hidden']).optional(), + }) + .passthrough() + .superRefine(refineDashboardFilterCoherence); + +const refineDashboardFilters = (schema: T) => + schema.superRefine((data, ctx) => { + const candidate = data as { filters?: unknown }; + if (!Array.isArray(candidate.filters) || candidate.filters.length === 0) { + return; + } + const filters = candidate.filters as DashboardFilter[]; + filters.forEach((f, i) => { + const result = perFilterCoherenceSchema.safeParse(f); + if (!result.success) { + for (const issue of result.error.issues) { + ctx.addIssue({ + ...issue, + path: ['filters', i, ...issue.path], + }); + } + } + }); + refineDashboardFiltersConstantSiblings(filters, ctx); + }); + +const internalCreateDashboardBodySchema = refineDashboardFilters( + DashboardWithoutIdSchema, +); +const internalUpdateDashboardBodySchema = refineDashboardFilters( + DashboardSchema.partial(), +); + router.get('/', async (req, res, next) => { try { const { teamId } = getNonNullUserWithTeam(req); @@ -44,7 +100,7 @@ router.get('/', async (req, res, next) => { router.post( '/', validateRequest({ - body: DashboardWithoutIdSchema, + body: internalCreateDashboardBodySchema, }), async (req, res, next) => { try { @@ -67,7 +123,7 @@ router.patch( params: z.object({ id: objectIdSchema, }), - body: DashboardSchema.partial(), + body: internalUpdateDashboardBodySchema, }), async (req, res, next) => { try { diff --git a/packages/api/src/routers/external-api/__tests__/dashboards.test.ts b/packages/api/src/routers/external-api/__tests__/dashboards.test.ts index 4d8cbb7d44..62808eee19 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.test.ts @@ -921,6 +921,306 @@ describe('External API v2 Dashboards - old format', () => { expect(getResponse.body.data.filters).toEqual(response.body.data.filters); }); + it('should round-trip constant and renderMode on filters (HDX-4404)', async () => { + const dashboardPayload = { + name: 'Dashboard with locked filters', + tiles: [makeExternalChart({ sourceId: traceSource._id.toString() })], + tags: TEST_TAGS, + filters: [ + { + type: 'QUERY_EXPRESSION' as const, + name: 'Service (locked, read-only)', + expression: 'ServiceName', + sourceId: traceSource._id.toString(), + constant: true, + renderMode: 'readonly' as const, + }, + { + type: 'QUERY_EXPRESSION' as const, + name: 'Environment (hidden)', + expression: 'environment', + sourceId: traceSource._id.toString(), + constant: true, + renderMode: 'hidden' as const, + }, + { + type: 'QUERY_EXPRESSION' as const, + name: 'Region (default editable)', + expression: 'region', + sourceId: traceSource._id.toString(), + // constant + renderMode omitted -> default editable behavior. + }, + ], + // Constant filters require a matching savedFilterValues entry + // (Lucene-keyed by expression). Without this, the filter renders + // locked but applies no WHERE clause, which the schema-level + // refinement rejects at the boundary. + savedFilterValues: [ + { + type: 'lucene' as const, + condition: 'ServiceName:"hdx-private-api"', + }, + { + type: 'lucene' as const, + condition: 'environment:"production"', + }, + ], + }; + + const response = await authRequest('post', BASE_URL) + .send(dashboardPayload) + .expect(200); + + expect(response.body.data.filters).toHaveLength(3); + // Use toEqual on the full filter object so extra fields (which would + // indicate schema drift) fail the assertion. The bot deep-review at + // dashboards.test.ts:961 flagged toMatchObject for letting unexpected + // fields slip through silently. + // + // whereLanguage is intentionally absent from these expectations: the + // input payload omits it, the Zod schema is optional with no default, + // so the round-trip is undefined-in / undefined-out. Adding the field + // here would make the test fail by asserting a wire-format default + // the schema never emits. + expect(response.body.data.filters[0]).toEqual({ + id: response.body.data.filters[0].id, + type: 'QUERY_EXPRESSION', + name: 'Service (locked, read-only)', + expression: 'ServiceName', + sourceId: traceSource._id.toString(), + constant: true, + renderMode: 'readonly', + }); + expect(response.body.data.filters[1]).toEqual({ + id: response.body.data.filters[1].id, + type: 'QUERY_EXPRESSION', + name: 'Environment (hidden)', + expression: 'environment', + sourceId: traceSource._id.toString(), + constant: true, + renderMode: 'hidden', + }); + // Filter 2 omitted the new fields. They must NOT be materialized as + // defaults on read; the absence is meaningful (default behavior + // matches today's editable, non-locked filter). + expect(response.body.data.filters[2]).toEqual({ + id: response.body.data.filters[2].id, + type: 'QUERY_EXPRESSION', + name: 'Region (default editable)', + expression: 'region', + sourceId: traceSource._id.toString(), + }); + + // GET round-trip. + const getResponse = await authRequest( + 'get', + `${BASE_URL}/${response.body.data.id}`, + ).expect(200); + expect(getResponse.body.data.filters).toEqual(response.body.data.filters); + + // PUT: flip filter 2 to readonly, drop filter 1, keep filter 0. + // Both surviving constant filters need a matching savedFilterValues + // entry so the schema-level coherence rule (constant: true must have + // a locked value to apply) is satisfied. + const updatePayload = { + name: 'Dashboard with locked filters', + tiles: [makeExternalChart({ sourceId: traceSource._id.toString() })], + tags: TEST_TAGS, + filters: [ + { + id: response.body.data.filters[0].id, + type: 'QUERY_EXPRESSION' as const, + name: 'Service (locked, read-only)', + expression: 'ServiceName', + sourceId: traceSource._id.toString(), + constant: true, + renderMode: 'readonly' as const, + }, + { + id: response.body.data.filters[2].id, + type: 'QUERY_EXPRESSION' as const, + name: 'Region (now read-only)', + expression: 'region', + sourceId: traceSource._id.toString(), + constant: true, + renderMode: 'readonly' as const, + }, + ], + savedFilterValues: [ + { + type: 'lucene' as const, + condition: 'ServiceName:"hdx-private-api"', + }, + { + type: 'lucene' as const, + condition: 'region:"us-east-2"', + }, + ], + }; + const updateResponse = await authRequest( + 'put', + `${BASE_URL}/${response.body.data.id}`, + ) + .send(updatePayload) + .expect(200); + + expect(updateResponse.body.data.filters).toHaveLength(2); + expect(updateResponse.body.data.filters[1]).toEqual({ + id: response.body.data.filters[2].id, + type: 'QUERY_EXPRESSION', + name: 'Region (now read-only)', + expression: 'region', + sourceId: traceSource._id.toString(), + constant: true, + renderMode: 'readonly', + }); + }); + + it('should reject an unknown renderMode value (HDX-4404)', async () => { + const dashboardPayload = { + name: 'Bad renderMode', + tiles: [makeExternalChart({ sourceId: traceSource._id.toString() })], + tags: TEST_TAGS, + filters: [ + { + type: 'QUERY_EXPRESSION' as const, + name: 'Service', + expression: 'ServiceName', + sourceId: traceSource._id.toString(), + renderMode: 'invisible', + }, + ], + }; + + await authRequest('post', BASE_URL).send(dashboardPayload).expect(400); + }); + + it('should reject renderMode without constant: true (HDX-4404)', async () => { + const dashboardPayload = { + name: 'Locked-chip-but-no-lock', + tiles: [makeExternalChart({ sourceId: traceSource._id.toString() })], + tags: TEST_TAGS, + filters: [ + { + type: 'QUERY_EXPRESSION' as const, + name: 'Service', + expression: 'ServiceName', + sourceId: traceSource._id.toString(), + // renderMode 'readonly' without constant: true would render + // a locked chip that the hook never overlays (no WHERE + // clause ever applies). Reject at the boundary. + renderMode: 'readonly' as const, + }, + ], + }; + + await authRequest('post', BASE_URL).send(dashboardPayload).expect(400); + }); + + it('should reject mixed constant + editable siblings on the same expression (HDX-4404)', async () => { + const dashboardPayload = { + name: 'Mixed siblings', + tiles: [makeExternalChart({ sourceId: traceSource._id.toString() })], + tags: TEST_TAGS, + filters: [ + { + type: 'QUERY_EXPRESSION' as const, + name: 'Service (locked)', + expression: 'ServiceName', + sourceId: traceSource._id.toString(), + constant: true, + renderMode: 'readonly' as const, + }, + { + type: 'QUERY_EXPRESSION' as const, + name: 'Service (editable, same expression)', + expression: 'ServiceName', + sourceId: traceSource._id.toString(), + // No constant -> editable. Sharing an expression with a + // constant sibling is incoherent: the runtime overlay would + // let the editable side's URL value clobber the constant's + // locked value while setFilterValue still no-ops the writes. + }, + ], + savedFilterValues: [ + { + type: 'lucene' as const, + condition: 'ServiceName:"hdx-private-api"', + }, + ], + }; + + await authRequest('post', BASE_URL).send(dashboardPayload).expect(400); + }); + + it('should reject constant: true with no matching savedFilterValues entry (HDX-4404)', async () => { + const dashboardPayload = { + name: 'Locked-no-saved-value', + tiles: [makeExternalChart({ sourceId: traceSource._id.toString() })], + tags: TEST_TAGS, + filters: [ + { + type: 'QUERY_EXPRESSION' as const, + name: 'Service', + expression: 'ServiceName', + sourceId: traceSource._id.toString(), + constant: true, + renderMode: 'readonly' as const, + }, + ], + // No savedFilterValues -> the locked chip would render with a + // lock icon but apply no WHERE clause. Reject so the + // clone-and-flip template path can't ship a misconfigured + // dashboard. + savedFilterValues: [], + }; + + await authRequest('post', BASE_URL).send(dashboardPayload).expect(400); + }); + + it('should accept constant: true with a matching savedFilterValues entry on the same expression (HDX-4404)', async () => { + const dashboardPayload = { + name: 'Locked with matching saved value', + tiles: [makeExternalChart({ sourceId: traceSource._id.toString() })], + tags: TEST_TAGS, + filters: [ + { + type: 'QUERY_EXPRESSION' as const, + name: 'Service', + expression: 'ServiceName', + sourceId: traceSource._id.toString(), + constant: true, + renderMode: 'readonly' as const, + }, + ], + savedFilterValues: [ + { + type: 'lucene' as const, + condition: 'ServiceName:"hdx-private-api"', + }, + ], + }; + + const response = await authRequest('post', BASE_URL) + .send(dashboardPayload) + .expect(200); + + expect(response.body.data.filters).toHaveLength(1); + expect(response.body.data.filters[0]).toEqual({ + id: response.body.data.filters[0].id, + type: 'QUERY_EXPRESSION', + name: 'Service', + expression: 'ServiceName', + sourceId: traceSource._id.toString(), + constant: true, + renderMode: 'readonly', + }); + // savedFilterValues survives the round-trip. + expect(response.body.data.savedFilterValues).toEqual([ + { type: 'lucene', condition: 'ServiceName:"hdx-private-api"' }, + ]); + }); + it('should return 400 when filter source ID does not exist', async () => { const nonExistentSourceId = new ObjectId().toString(); const dashboardPayload = { diff --git a/packages/api/src/routers/external-api/v2/dashboards.ts b/packages/api/src/routers/external-api/v2/dashboards.ts index cb3735ae82..7e139d95ac 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -90,14 +90,26 @@ function getSourceConnectionMismatches( * properties: * type: * type: string - * enum: [sql] + * enum: [sql, lucene] * default: sql - * description: Filter type. Currently only "sql" is supported. - * example: "sql" + * description: | + * Language of the `condition` expression. Use `lucene` for the + * Lucene-style key:value syntax that round-trips through the + * UI's "Save default" flow (e.g. `ServiceName:"hdx-private-api"`) + * and pairs with a dashboard filter that has `constant: true`. + * Use `sql` for a raw SQL fragment evaluated as a WHERE clause + * on the matching source. + * 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 + * the form `column IN ('value')`. For `type: lucene`, a + * Lucene-style `key:value` string keyed by a dashboard + * filter's `expression`; this is the shape the UI writes + * when an author clicks "Save default" on a chip and the + * shape constant filters consume. + * example: "ServiceName:\"hdx-oss-dev-api\"" * MetricDataType: * type: string * enum: [sum, gauge, histogram, summary, exponential histogram] @@ -1370,6 +1382,31 @@ function getSourceConnectionMismatches( * is in the list; tiles using other sources are not affected by the * selected filter value(s). * example: ["65f5e4a3b9e77c001a111111"] + * constant: + * type: boolean + * description: | + * When true, the value from the dashboard's savedFilterValues matched + * by this filter's expression is applied automatically on every tile + * this filter scopes, and viewers cannot change it. Use this to lock + * a dashboard template to a single scope (clone the dashboard, save a + * different default per copy). Pairs with renderMode to control how + * the locked filter shows in the filter bar. Omit (or send false) + * for an ordinary editable filter (the implicit default behavior). + * Two filters that share the same expression on the same dashboard + * must agree on `constant`: mixing one locked sibling and one + * editable sibling on the same expression is rejected. + * example: true + * renderMode: + * type: string + * enum: [editable, readonly, hidden] + * description: | + * Controls how this filter renders in the dashboard filter bar. + * Omit for the implicit "editable" behavior (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. "readonly" and "hidden" require constant: true. + * example: "readonly" * * Filter: * allOf: @@ -1479,7 +1516,11 @@ function getSourceConnectionMismatches( * example: "sql" * 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. + * Drop any entries whose expression does not match a filter you are + * keeping in the `filters` array, otherwise they remain as orphaned + * saved values invisible to the UI editor. * items: * $ref: '#/components/schemas/SavedFilterValue' * containers: @@ -1532,7 +1573,11 @@ function getSourceConnectionMismatches( * example: "sql" * 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. + * On update, this array is overwritten as a whole. Drop any entries + * whose expression does not match a filter you kept in `filters` so + * they do not remain as orphaned scope locks. * items: * $ref: '#/components/schemas/SavedFilterValue' * containers: diff --git a/packages/api/src/routers/external-api/v2/utils/dashboards.ts b/packages/api/src/routers/external-api/v2/utils/dashboards.ts index 25c59b35f2..4c47306631 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -1,8 +1,10 @@ +import { parseKeyPath } from '@hyperdx/common-utils/dist/core/metadata'; import { displayTypeSupportsRawSqlAlerts } from '@hyperdx/common-utils/dist/core/utils'; import { validateDashboardContainersStructure, validateDashboardTileContainerRefs, } from '@hyperdx/common-utils/dist/dashboardValidation'; +import { parseLuceneFilter } from '@hyperdx/common-utils/dist/filters'; import { isHeatmapCompatibleSource, isPromqlSavedChartConfig, @@ -14,12 +16,14 @@ import { DASHBOARD_MAX_CONTAINERS, DashboardContainer, DashboardContainerSchema, + DashboardFilter, DisplayType, isLogSource, isOnClickDashboardById, isOnClickSearchById, isTraceSource, RawSqlSavedChartConfig, + refineDashboardFiltersConstantSiblings, SavedChartConfig, } from '@hyperdx/common-utils/dist/types'; import { SearchConditionLanguageSchema as whereLanguageSchema } from '@hyperdx/common-utils/dist/types'; @@ -1149,6 +1153,73 @@ function buildDashboardBodySchema(filterSchema: z.ZodTypeAny): z.ZodEffects< // (otherwise a tile that references a real preserved container // would be rejected against an empty `data.containers ?? []`). validateDashboardContainersStructure(data.containers ?? [], ctx); + + // Cross-filter check: reject mixing constant: true with editable + // siblings on the same expression. The filter-array element type + // is `unknown` from Zod's perspective here, so cast through the + // shared shape. Filters are already individually validated by the + // `filterSchema` arg, so the cast is safe. + if (data.filters && data.filters.length > 0) { + refineDashboardFiltersConstantSiblings( + data.filters as unknown as DashboardFilter[], + ctx, + ); + + // Cross-schema coherence: a filter with `constant: true` only + // applies a WHERE clause if its expression resolves to a value + // in `savedFilterValues`. Without this check the clone-and-flip + // template path silently ships a "locked" dashboard with no + // scope: the chip renders with a lock icon, `setFilterValue` + // no-ops, but every tile runs unfiltered. Reject at the + // boundary so MCP / external-API callers see the error. + // + // Lucene-only matching: SQL conditions are opaque strings the + // server doesn't parse here, so a `type: 'sql'` saved entry is + // counted as "covers all expressions" (best-effort) - the only + // way to assert otherwise would be to drag a SQL parser into + // the validator. The UI's "Save default" flow writes Lucene by + // default, and the MCP example in `mcpFiltersParam` uses + // Lucene, so the common path is fully covered. + const constantFilters = ( + data.filters as unknown as DashboardFilter[] + ).filter(f => f.constant); + if (constantFilters.length > 0) { + const saved = data.savedFilterValues ?? []; + const hasAnySqlEntry = saved.some(s => s.type === 'sql'); + const luceneCoveredExpressions = new Set(); + for (const entry of saved) { + if (entry.type !== 'lucene') continue; + const parsed = parseLuceneFilter(entry.condition); + if (!parsed) continue; + for (const term of parsed) { + luceneCoveredExpressions.add(parseKeyPath(term.key).join('.')); + } + } + for (let i = 0; i < constantFilters.length; i++) { + const f = constantFilters[i]; + const filterIndex = ( + data.filters as unknown as DashboardFilter[] + ).indexOf(f); + const norm = parseKeyPath(f.expression).join('.'); + const covered = + hasAnySqlEntry || luceneCoveredExpressions.has(norm); + if (!covered) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['filters', filterIndex], + message: + `Filter "${f.name}" has constant: true but no matching ` + + 'savedFilterValues entry on the same expression. Add a ' + + 'savedFilterValues entry whose Lucene condition references ' + + `"${f.expression}" (e.g. ` + + `{ "type": "lucene", "condition": "${f.expression}:" }), ` + + 'or remove constant: true. Without a matching saved value ' + + 'the filter renders as locked but applies no WHERE clause.', + }); + } + } + } + } }); } diff --git a/packages/api/src/utils/zod.ts b/packages/api/src/utils/zod.ts index bb6906e674..b57792291e 100644 --- a/packages/api/src/utils/zod.ts +++ b/packages/api/src/utils/zod.ts @@ -10,6 +10,7 @@ import { NumberFormatSchema, OnClickDashboardSchema, OnClickSearchSchema, + refineDashboardFilterCoherence, scheduleStartAtSchema, SearchConditionLanguageSchema as whereLanguageSchema, validateAlertScheduleOffsetMinutes, @@ -128,18 +129,28 @@ type ChartSeries = z.infer; export const tagsSchema = z.array(z.string().max(32)).max(50).optional(); -export const externalDashboardFilterSchemaWithId = DashboardFilterSchema.omit({ +// Bare object schema (no superRefine) so `.omit`/`.extend` chains still +// work on the create variant below. Apply the coherence refinement to +// each shipped schema separately. +const externalDashboardFilterBaseSchemaWithId = DashboardFilterSchema.omit({ source: true, }) .extend({ sourceId: objectIdSchema }) .strict(); +export const externalDashboardFilterSchemaWithId = + externalDashboardFilterBaseSchemaWithId.superRefine( + refineDashboardFilterCoherence, + ); + export type ExternalDashboardFilterWithId = z.infer< typeof externalDashboardFilterSchemaWithId >; export const externalDashboardFilterSchema = - externalDashboardFilterSchemaWithId.omit({ id: true }); + externalDashboardFilterBaseSchemaWithId + .omit({ id: true }) + .superRefine(refineDashboardFilterCoherence); export type ExternalDashboardFilter = z.infer< typeof externalDashboardFilterSchema @@ -150,7 +161,7 @@ export const externalDashboardSavedFilterValueSchema = z.object({ condition: z.string().max(10000), }); -type ExternalDashboardSavedFilterValue = z.infer< +export type ExternalDashboardSavedFilterValue = z.infer< typeof externalDashboardSavedFilterValueSchema >; diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 2225949dd3..baaa688234 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -127,6 +127,14 @@ import { useDashboards, useDeleteDashboard, } from '@/dashboard'; +import { + buildConstantExpressionSet, + mergeConstantFiltersForSave, + normalizeExpression, + removeSavedDefaultForExpression, + stripConstantsFromUrl, + upsertSavedDefault, +} from '@/dashboardFilterUtils'; import useDashboardContainers, { TabDeleteAction, } from '@/hooks/useDashboardContainers'; @@ -1380,18 +1388,25 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { const [showFiltersModal, setShowFiltersModal] = useState(false); const filters = dashboard?.filters ?? []; + const dashboardReady = + !!dashboard?.id && + router.isReady && + (isLocalDashboard || !isFetchingDashboard); const { filterValues, setFilterValue, setFilterQueries, ignoredFilterExpressions, getFilterQueriesForSource, - } = useDashboardFilters(filters); - - const dashboardReady = - !!dashboard?.id && - router.isReady && - (isLocalDashboard || !isFetchingDashboard); + } = useDashboardFilters(filters, { + savedFilterValues: dashboard?.savedFilterValues, + // Gate the overlay until the dashboard has finished loading. + // While `dashboardReady` is false, the hook can't tell whether a + // URL entry collides with an as-yet-unknown `constant: true` + // filter, so it returns empty values/queries and tiles wait for + // the dashboard to hydrate before issuing scoped queries. + enabled: dashboardReady, + }); // Warn when the URL has filter values that don't correspond to any declared // dashboard filter — they'd otherwise be silently dropped, and users who @@ -1416,7 +1431,10 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [dashboard?.id, dashboardReady]); - const handleSaveFilter = (filter: DashboardFilter) => { + const handleSaveFilter = ( + filter: DashboardFilter, + options?: { defaultValues?: string[] }, + ) => { if (!dashboard) return; setDashboard( @@ -1424,10 +1442,51 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { const filterIndex = draft.filters?.findIndex(p => p.id === filter.id) ?? -1; if (draft.filters && filterIndex !== -1) { + const oldExpression = draft.filters[filterIndex].expression; + // Renaming a filter's expression must take the old saved + // entry with it: otherwise an entry keyed by the previous + // expression stays in `savedFilterValues`, the renamed + // filter resolves to undefined (its new expression has no + // match), and any future "lock to default" on the same id + // would silently re-bind to the orphaned value. Skip the + // strip if another sibling still references the old + // expression (two-source locked filters legitimately share + // a saved entry). + if ( + oldExpression !== filter.expression && + normalizeExpression(oldExpression) !== + normalizeExpression(filter.expression) + ) { + const siblingStillUsesOld = draft.filters.some( + (p, i) => + i !== filterIndex && + normalizeExpression(p.expression) === + normalizeExpression(oldExpression), + ); + if (!siblingStillUsesOld) { + draft.savedFilterValues = removeSavedDefaultForExpression( + draft.savedFilterValues, + oldExpression, + ); + } + } draft.filters[filterIndex] = filter; } else { draft.filters = [...(draft.filters ?? []), filter]; } + + // When the editor sets a default value (currently used to seed + // locked filters; the chip + "Save default" flow handles editable + // filters), upsert that value into savedFilterValues keyed by the + // filter expression. An empty array means "clear the saved default + // for this expression." + if (options?.defaultValues !== undefined) { + draft.savedFilterValues = upsertSavedDefault( + draft.savedFilterValues, + filter.expression, + options.defaultValues, + ); + } }), ); }; @@ -1435,9 +1494,39 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { const handleRemoveFilter = (id: string) => { if (!dashboard) return; + // Strip the matching savedFilterValues entry alongside the filter + // delete so a later recreate-on-the-same-expression doesn't silently + // re-lock to the orphaned saved value. + // + // Two `constant: true` siblings on the same expression are + // schema-legal when `appliesToSourceIds` differs (one scoped to + // logs, one scoped to traces, both locked to the same value). In + // that case, deleting just one sibling must NOT prune the shared + // saved entry: the surviving sibling would then render with a lock + // icon but no value and apply no WHERE scoping. Only prune when no + // remaining filter references the same normalized expression. + const removed = dashboard.filters?.find(p => p.id === id); + const remainingFilters = dashboard.filters?.filter(p => p.id !== id) ?? []; + const removedExpression = removed?.expression; + const removedNormalized = removedExpression + ? normalizeExpression(removedExpression) + : null; + const siblingStillUsesExpression = + removedNormalized != null && + remainingFilters.some( + p => normalizeExpression(p.expression) === removedNormalized, + ); + const cleanedSavedValues = + removedExpression && !siblingStillUsesExpression + ? removeSavedDefaultForExpression( + dashboard.savedFilterValues, + removedExpression, + ) + : dashboard.savedFilterValues; setDashboard({ ...dashboard, - filters: dashboard.filters?.filter(p => p.id !== id) ?? [], + filters: remainingFilters, + savedFilterValues: cleanedSavedValues, }); }; @@ -1537,15 +1626,34 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { // Filter defaults: URL filters override saved defaults. If switching to a // dashboard without defaults, clear selected filters. + // + // Constant filters are excluded from the URL push: their value is + // sourced from savedFilterValues directly by the hook on every read, + // so writing them into the URL would (a) leak the locked scope into + // shared links and (b) duplicate the value across two sources of + // truth. if (!hasFiltersInUrl) { if (dashboard.savedFilterValues) { - setFilterQueries(dashboard.savedFilterValues); + const constantExpressions = buildConstantExpressionSet( + dashboard.filters, + ); + setFilterQueries( + stripConstantsFromUrl( + dashboard.savedFilterValues, + constantExpressions, + ), + ); } else if (isSwitchingDashboards) { setFilterQueries(null); } } initializedDashboard.current = dashboard.id; + // dashboard?.filters is read indirectly via the constant-expression + // helper but the `initializedDashboard.current === dashboard.id` + // guard above already prevents re-runs for the same dashboard, so + // we don't list it here to keep the effect surface stable. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ dashboard?.id, dashboard?.savedQuery, @@ -1584,9 +1692,18 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { const currentWhereLanguage = currentWhere ? formValues.whereLanguage || 'lucene' : null; - const currentFilterValues = rawFilterQueries?.length - ? rawFilterQueries - : []; + // Constant filter values are intentionally absent from the URL (the + // hook overlays them from savedFilterValues directly), so saving the + // bare URL state would clobber them. Preserve constant entries from + // the existing savedFilterValues array; everything else comes from + // the current URL state. + const constantExpressions = buildConstantExpressionSet(dashboard.filters); + const urlFilterValues = rawFilterQueries?.length ? rawFilterQueries : []; + const currentFilterValues: Filter[] = mergeConstantFiltersForSave( + dashboard.savedFilterValues, + urlFilterValues, + constantExpressions, + ); setDashboard( produce(dashboard, draft => { @@ -1619,7 +1736,22 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { produce(dashboard, draft => { draft.savedQuery = null; draft.savedQueryLanguage = null; - draft.savedFilterValues = []; + // Preserve any savedFilterValues entry whose normalized + // expression matches a `constant: true` filter. The user + // intent here is "remove the editor's saved default query + + // editable defaults", not "unlock every locked scope filter": + // a clone-and-flip template with `constant: true` filters + // would otherwise render its lock chip but apply no WHERE + // scoping, exactly the failure mode `constant` exists to + // prevent. Mirror `mergeConstantFiltersForSave` with an empty + // URL input so editable saved entries fall away and constant + // entries stay. + const constantExpressions = buildConstantExpressionSet(draft.filters); + draft.savedFilterValues = mergeConstantFiltersForSave( + draft.savedFilterValues, + [], + constantExpressions, + ); }), () => { notifications.show({ @@ -2768,6 +2900,9 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { opened={showFiltersModal} onClose={() => setShowFiltersModal(false)} filters={filters} + savedFilterValues={dashboard?.savedFilterValues} + dateRange={searchedTimeRange} + supportsConstantFilters onSaveFilter={handleSaveFilter} onRemoveFilter={handleRemoveFilter} isLoading={isSavingDashboard || isFetchingDashboard} diff --git a/packages/app/src/DashboardFilters.tsx b/packages/app/src/DashboardFilters.tsx index 78a29dfdb6..be8bdd53fb 100644 --- a/packages/app/src/DashboardFilters.tsx +++ b/packages/app/src/DashboardFilters.tsx @@ -1,7 +1,8 @@ +import { useMemo } from 'react'; import { FilterState } from '@hyperdx/common-utils/dist/filters'; import { DashboardFilter } from '@hyperdx/common-utils/dist/types'; import { Group, Stack, Text, Tooltip } from '@mantine/core'; -import { IconHelp, IconRefresh } from '@tabler/icons-react'; +import { IconHelp, IconLock, IconRefresh } from '@tabler/icons-react'; import { VirtualMultiSelect } from './components/VirtualMultiSelect/VirtualMultiSelect'; import { useDashboardFilterValues } from './hooks/useDashboardFilterValues'; @@ -29,6 +30,13 @@ const DashboardFilterSelect = ({ }: DashboardFilterSelectProps) => { const sortedValues = values?.toSorted() || []; const tooltipText = getAppliesToTooltip(filter); + // The chip is rendered locked when either `constant: true` is set + // (the value comes from savedFilterValues and the viewer cannot + // override it) or `renderMode === 'readonly'` is set explicitly. + // The `renderMode === 'hidden'` case is handled one level up at + // `visibleFilters`, which drops the chip from the bar entirely; that + // branch never reaches this component. + const isLocked = filter.renderMode === 'readonly' || !!filter.constant; return ( @@ -36,6 +44,18 @@ const DashboardFilterSelect = ({ {filter.name} + {isLocked && ( + + + + )} @@ -71,14 +91,23 @@ const DashboardFilters = ({ filterValues, onSetFilterValue, }: DashboardFilterProps) => { + // Filters with renderMode === 'hidden' still apply to tile WHERE clauses + // (via the hook) but are not rendered in the filter bar. Memoize so the + // downstream useDashboardFilterValues sees a stable reference and its + // useQueries doesn't churn isLoading on every parent re-render. + const visibleFilters = useMemo( + () => filters.filter(f => f.renderMode !== 'hidden'), + [filters], + ); + const { data: filterValuesById, isFetching } = useDashboardFilterValues({ - filters, + filters: visibleFilters, dateRange, }); return ( - {Object.values(filters).map(filter => { + {visibleFilters.map(filter => { const queriedFilterValues = filterValuesById?.get(filter.id); const included = filterValues[filter.expression]?.included; const selectedValues = included diff --git a/packages/app/src/DashboardFiltersModal.tsx b/packages/app/src/DashboardFiltersModal.tsx index c773922d12..2478e412a6 100644 --- a/packages/app/src/DashboardFiltersModal.tsx +++ b/packages/app/src/DashboardFiltersModal.tsx @@ -1,13 +1,20 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Controller, FieldError, useForm, useWatch } from 'react-hook-form'; -import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata'; +import { z } from 'zod'; +import { + parseKeyPath, + TableConnection, +} from '@hyperdx/common-utils/dist/core/metadata'; import { DashboardFilter, + DashboardFilterRenderMode, + Filter, MetricsDataType, SourceKind, TSource, } from '@hyperdx/common-utils/dist/types'; import { + Alert, Button, Center, Group, @@ -15,6 +22,7 @@ import { Modal, Paper, Radio, + Select, Stack, Text, TextInput, @@ -22,9 +30,11 @@ import { Tooltip, UnstyledButton, } from '@mantine/core'; +import { useDebouncedValue } from '@mantine/hooks'; import { IconFilter, IconInfoCircle, + IconLock, IconPencil, IconRefresh, IconSearch, @@ -35,12 +45,15 @@ import SearchWhereInput, { getStoredLanguage, } from '@/components/SearchInput/SearchWhereInput'; import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor'; +import { VirtualMultiSelect } from '@/components/VirtualMultiSelect/VirtualMultiSelect'; +import { parseQuery } from '@/searchFilters'; import { SourceMultiSelectControlled } from './components/SourceMultiSelect'; import SourceSchemaPreview, { isSourceSchemaPreviewEnabled, } from './components/SourceSchemaPreview'; import { SourceSelectControlled } from './components/SourceSelect'; +import { useDashboardFilterValues } from './hooks/useDashboardFilterValues'; import { useSource, useSources } from './source'; import { getMetricTableName } from './utils'; @@ -48,6 +61,66 @@ import styles from '../styles/DashboardFiltersModal.module.scss'; const MODAL_SIZE = 'md'; +// The Visibility select presents one combined control over the two +// orthogonal schema fields `constant` and `renderMode`. The three preset +// values are the only meaningful combinations in v1; the schema admits +// more, leaving room for MCP / external API callers to express future +// variants (e.g. constant + editable). +// +// Deliberate normalization: an MCP-authored filter that arrives with +// only `constant: true` (no `renderMode`) opens in the editor as the +// "Read-only" preset. If the user saves the form without changing +// Visibility, `applyFilterVisibility('readonly')` writes back +// `{ constant: true, renderMode: 'readonly' }`, so the inbound wire +// shape gains an explicit `renderMode: 'readonly'`. This is +// intentional: the editor has only three preset shapes (editable, +// readonly, hidden) and persisting the resolved shape keeps the +// wire format aligned with what the editor displays. Round-trip +// tests pin the shape at the persistence boundary +// (DashboardFiltersModal.test.tsx). +type FilterVisibility = z.infer; + +export const getFilterVisibility = (filter: { + constant?: boolean; + renderMode?: FilterVisibility; +}): FilterVisibility => { + if (filter.renderMode === 'hidden') return 'hidden'; + if (filter.renderMode === 'readonly' || filter.constant) return 'readonly'; + return 'editable'; +}; + +export const applyFilterVisibility = ( + visibility: FilterVisibility, +): Pick => { + switch (visibility) { + case 'readonly': + return { constant: true, renderMode: 'readonly' }; + case 'hidden': + return { constant: true, renderMode: 'hidden' }; + case 'editable': + default: + // The submit path destructures `constant` and `renderMode` out of + // the form values before spreading, so an empty object here is + // intentional: the resulting persisted filter has neither key. + // That keeps the wire format identical to today's editable filter + // (no spurious `constant: undefined` / `renderMode: undefined` + // entries that would diff against a server round-trip). + return {}; + } +}; + +const VISIBILITY_OPTIONS: { value: FilterVisibility; label: string }[] = [ + { value: 'editable', label: 'Editable' }, + { value: 'readonly', label: 'Read-only (locked to saved default)' }, + { value: 'hidden', label: 'Hidden (locked, no chip in the filter bar)' }, +]; + +// Sentinel time range used to satisfy the `useDashboardFilterValues` hook +// signature when the form is not configured enough to actually fetch +// values (queryReady=false). The hook only fires when filtersForQuery is +// non-empty, so this placeholder never reaches ClickHouse. +const NEVER_USED_RANGE: [Date, Date] = [new Date(0), new Date(0)]; + interface CustomInputWrapperProps { children: React.ReactNode; label: string; @@ -84,41 +157,217 @@ const CustomInputWrapper = ({ ); }; +// Look up the current default values for a filter expression by parsing +// the dashboard's savedFilterValues (Lucene-encoded Filter[]) and reading +// the included set keyed by the normalized expression. +const getDefaultValuesForExpression = ( + expression: string, + savedFilterValues: Filter[] | null | undefined, +): string[] => { + if (!savedFilterValues?.length) return []; + const { filters: parsed } = parseQuery(savedFilterValues); + const norm = parseKeyPath(expression).join('.'); + for (const [key, value] of Object.entries(parsed)) { + if (parseKeyPath(key).join('.') === norm) { + return Array.from(value.included).map(v => v.toString()); + } + } + return []; +}; + +// Default-value picker for the filter editor. Reuses the same value +// query the filter chip uses (useDashboardFilterValues) so the author +// gets autocomplete from the configured source / expression / WHERE +// triple, with the same UX as the runtime chip dropdown. +// +// The query runs only when the filter is configured enough to fetch +// values (source + expression). Until then, the picker still accepts +// free-form input so the author can pre-populate a value before the +// source schema is fully loaded. +interface FilterDefaultValueSelectProps { + filter: Pick< + DashboardFilter, + | 'id' + | 'type' + | 'name' + | 'source' + | 'expression' + | 'where' + | 'whereLanguage' + | 'sourceMetricType' + > | null; + dateRange?: [Date, Date]; + value: string[]; + onChange: (values: string[]) => void; +} + +const FilterDefaultValueSelect = ({ + filter, + dateRange, + value, + onChange, +}: FilterDefaultValueSelectProps) => { + // The hook always runs but is a no-op when the filter has no source + // or no dateRange (no query is issued). + const queryReady = + !!filter && !!filter.source && !!filter.expression && !!dateRange; + const filtersForQuery = useMemo( + () => (queryReady && filter ? [filter] : []), + [queryReady, filter], + ); + + const { data: filterValuesById, isLoading } = useDashboardFilterValues({ + filters: filtersForQuery, + dateRange: dateRange ?? NEVER_USED_RANGE, + }); + + const queriedValues = useMemo(() => { + if (!filter) return []; + const entry = filterValuesById.get(filter.id); + return entry?.values ?? []; + }, [filterValuesById, filter]); + + // Always include the currently-selected values so they remain visible + // as pills even if the source / WHERE narrowing wouldn't return them + // (e.g. the author selected a value that's no longer in the + // dropdown's current result set). + const options = useMemo(() => { + const set = new Set([...queriedValues, ...value]); + return Array.from(set).sort(); + }, [queriedValues, value]); + + return ( + + ); +}; + interface DashboardFilterEditFormProps { filter: DashboardFilter; isNew: boolean; source: TSource | undefined; - onSave: (definition: DashboardFilter) => void; + savedFilterValues?: Filter[] | null; + /** + * Time range used by the default-value picker to query autocomplete + * options. Same range the runtime chip uses, so the editor preview + * matches what the viewer would see. + */ + dateRange?: [Date, Date]; + /** + * Whether this filter editor supports the locked / constant filter + * flow. Set to true for regular dashboards (which carry + * savedFilterValues for storing the locked value); leave false for + * preset dashboards (Services) where the locked value has nowhere to + * be stored in v1. + */ + supportsConstantFilters?: boolean; + onSave: ( + definition: DashboardFilter, + options?: { defaultValues?: string[] }, + ) => void; onClose: () => void; onCancel: () => void; } +interface FilterEditFormValues extends DashboardFilter { + // UI-only synthetic field that maps to (constant, renderMode) on save. + // Kept in form state so users see the chosen preset reflected when + // editing an existing filter. + visibility: FilterVisibility; + // UI-only synthetic field that maps to a savedFilterValues entry for + // this filter's expression. Surfaced so the author can set the locked + // default value for read-only and hidden filters without relying on + // the filter chip (which is disabled or absent in those modes). + defaultValues: string[]; +} + const DashboardFilterEditForm = ({ filter, isNew, source: presetSource, + savedFilterValues, + dateRange, + supportsConstantFilters = false, onSave, onClose, onCancel, }: DashboardFilterEditFormProps) => { + // Snapshot the saved default value once per filter.id. A background + // savedFilterValues refetch that lands while the modal is open would + // otherwise re-derive `initialDefaultValues`, fire the reset effect, + // and wipe any in-progress edit to the `defaultValues` field. The + // reset still runs when the user switches which filter is being + // edited (filter.id changes). + // + // State (not a ref) so the initial form values can read the snapshot + // during render without tripping the react-hooks/refs lint, and so + // the dep array on the reset effect omits savedFilterValues by design. + const [initialDefaultValues, setInitialDefaultValues] = useState( + () => getDefaultValuesForExpression(filter.expression, savedFilterValues), + ); + const { handleSubmit, register, formState, control, reset } = - useForm({ + useForm({ defaultValues: { ...filter, where: filter.where ?? '', whereLanguage: filter.whereLanguage ?? getStoredLanguage() ?? 'sql', appliesToSourceIds: filter.appliesToSourceIds ?? [], + visibility: getFilterVisibility(filter), + defaultValues: initialDefaultValues, }, }); + // Re-snapshot and reset when the user switches which filter is being + // edited. Omitting `savedFilterValues` from the dep array is deliberate: + // a background refetch should not wipe in-progress edits. The latest + // value is still read on the next filter switch via the closure on the + // savedFilterValues prop. useEffect(() => { + const next = getDefaultValuesForExpression( + filter.expression, + savedFilterValues, + ); + setInitialDefaultValues(next); reset({ ...filter, where: filter.where ?? '', whereLanguage: filter.whereLanguage ?? getStoredLanguage() ?? 'sql', appliesToSourceIds: filter.appliesToSourceIds ?? [], + visibility: getFilterVisibility(filter), + defaultValues: next, }); - }, [filter, reset]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filter.id, reset]); + + const watchedVisibility = useWatch({ control, name: 'visibility' }); + const watchedDefaultValues = useWatch({ control, name: 'defaultValues' }); + const watchedExpression = useWatch({ control, name: 'expression' }); + const watchedWhere = useWatch({ control, name: 'where' }); + const watchedWhereLanguage = useWatch({ control, name: 'whereLanguage' }); + + // CodeMirror-backed inputs (`expression`, `where`, `whereLanguage`) emit + // a `useWatch` update on every keystroke, so feeding them straight into + // `filterForValueQuery` below would fire a new ClickHouse autocomplete + // request per character and frequently with mid-edit partial WHERE + // strings that the proxy rejects server-side. Debounce on a stable + // 300ms window (matches `MetricAttributeHelperPanel`) so the picker + // only re-queries once the author pauses typing. + const [debouncedExpression] = useDebouncedValue(watchedExpression, 300); + const [debouncedWhere] = useDebouncedValue(watchedWhere, 300); + const [debouncedWhereLanguage] = useDebouncedValue(watchedWhereLanguage, 300); const sourceId = useWatch({ control, name: 'source' }); const { data: source } = useSource({ id: sourceId }); @@ -138,6 +387,43 @@ const DashboardFilterEditForm = ({ source?.kind === SourceKind.Metric ? source.metricTables?.[type] : false, ); + // Build a stable filter shape from the watched form fields so the + // default-value picker can query autocomplete values matching the + // configured source / expression / WHERE. Memoize on the primitive + // bits so we don't re-issue the same query on unrelated form edits. + // + // Gate on `tableName` (not just `sourceId`): a metric source with no + // metricType picked yet resolves to `source.from.tableName`, which is + // typically empty on a metric source. Firing the autocomplete in that + // state produces a malformed DESCRIBE ({db}.{} with an empty Identifier + // substitution) that the ClickHouse proxy rejects with a 500. The + // query only makes sense once the form has enough information to name + // a real table. + const filterForValueQuery = useMemo(() => { + if (!sourceId || !debouncedExpression || !tableName) return null; + return { + id: filter.id, + type: 'QUERY_EXPRESSION' as const, + name: filter.name || 'preview', + source: sourceId, + expression: debouncedExpression, + where: debouncedWhere?.trim() ? debouncedWhere : undefined, + whereLanguage: debouncedWhere?.trim() + ? (debouncedWhereLanguage ?? 'sql') + : undefined, + sourceMetricType: metricType, + }; + }, [ + filter.id, + filter.name, + sourceId, + debouncedExpression, + debouncedWhere, + debouncedWhereLanguage, + metricType, + tableName, + ]); + const [modalContentRef, setModalContentRef] = useState( null, ); @@ -158,14 +444,68 @@ const DashboardFilterEditForm = ({ const appliesTo = values.appliesToSourceIds?.filter( id => !!id?.length, ); - onSave({ - ...values, - where: trimmedWhere || undefined, - whereLanguage: trimmedWhere - ? (values.whereLanguage ?? 'sql') + // Strip the UI-only synthetic fields AND the schema's + // `constant` / `renderMode` pair before saving: `visibility` + // is the canonical UI input and `applyFilterVisibility` + // below produces the (constant, renderMode) pair to spread + // on top of `rest`. Pulling `constant` and `renderMode` + // out of `rest` here ensures the spread of + // `visibilityFields` is authoritative: when the author + // flips a saved readonly/hidden filter back to "Editable", + // the persisted filter ends up without those keys (matching + // a fresh editable filter), not still carrying the stale + // `constant: true` / `renderMode: 'readonly'` from before + // the visibility change. + const { + visibility, + defaultValues: editedDefaultValues, + constant: _droppedConstant, + renderMode: _droppedRenderMode, + ...rest + } = values; + // Visibility / default-value editing is gated on + // `supportsConstantFilters`. When the parent (e.g. the + // preset dashboard editor) doesn't opt in, those UI fields + // are not rendered and the saved filter keeps whatever + // constant/renderMode it already had. + const visibilityFields = supportsConstantFilters + ? applyFilterVisibility(visibility) + : {}; + // Only round-trip the editor's default value when it would + // actually change the saved state. Editable filters keep using + // the chip + "Save default" flow; the editor only takes + // ownership of the saved value when the author is in a locked + // mode (or when they're clearing a previously-saved default + // they could no longer reach via the chip). + const sanitizedDefaults = + editedDefaultValues + ?.map(v => v.trim()) + .filter(v => v.length > 0) ?? []; + const initialNormalized = initialDefaultValues.map(v => v.trim()); + // Set equality so reordering the same values doesn't fire a + // spurious save write. + const sanitizedSet = new Set(sanitizedDefaults); + const initialSet = new Set(initialNormalized); + const defaultsChanged = + sanitizedSet.size !== initialSet.size || + [...sanitizedSet].some(v => !initialSet.has(v)); + const shouldUpdateDefaults = + supportsConstantFilters && + (visibility !== 'editable' || defaultsChanged); + onSave( + { + ...rest, + ...visibilityFields, + where: trimmedWhere || undefined, + whereLanguage: trimmedWhere + ? (values.whereLanguage ?? 'sql') + : undefined, + appliesToSourceIds: appliesTo?.length ? appliesTo : undefined, + }, + shouldUpdateDefaults + ? { defaultValues: sanitizedDefaults } : undefined, - appliesToSourceIds: appliesTo?.length ? appliesTo : undefined, - }); + ); })} > @@ -272,6 +612,63 @@ const DashboardFilterEditForm = ({ /> + {supportsConstantFilters && ( + <> + + ( + + )} + /> + + + + ( +