From 7abd1dcbef41d64b73637a942070e605d9752b47 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Fri, 29 May 2026 23:44:47 +0000 Subject: [PATCH 1/8] feat(dashboards): support constant values and render modes for filters --- .../dashboard-filter-constant-render-modes.md | 17 + packages/api/openapi.json | 17 + .../api/src/mcp/__tests__/dashboards.test.ts | 121 ++++++ .../api/src/mcp/tools/dashboards/schemas.ts | 28 +- .../external-api/__tests__/dashboards.test.ts | 121 ++++++ .../src/routers/external-api/v2/dashboards.ts | 22 + packages/app/src/DBDashboardPage.tsx | 129 +++++- packages/app/src/DashboardFilters.tsx | 25 +- packages/app/src/DashboardFiltersModal.tsx | 389 +++++++++++++++++- .../__tests__/useDashboardFilters.test.tsx | 163 ++++++++ .../app/src/hooks/useDashboardFilters.tsx | 64 ++- .../src/__tests__/dashboardFilter.test.ts | 96 +++++ packages/common-utils/src/types.ts | 16 + 13 files changed, 1170 insertions(+), 38 deletions(-) create mode 100644 .changeset/dashboard-filter-constant-render-modes.md create mode 100644 packages/common-utils/src/__tests__/dashboardFilter.test.ts 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..0af86410b6 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -2359,6 +2359,23 @@ "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.\n", + "default": false, + "example": true + }, + "renderMode": { + "type": "string", + "enum": [ + "editable", + "readonly", + "hidden" + ], + "description": "Controls how this filter renders in the dashboard filter bar.\n\"editable\" (default) shows a normal dropdown the viewer can change.\n\"readonly\" shows a disabled chip with a lock icon; the viewer\nsees the locked value but cannot edit it. \"hidden\" omits the chip\nentirely; the locked value still scopes every matching tile.\n", + "default": "editable", + "example": "readonly" } } }, diff --git a/packages/api/src/mcp/__tests__/dashboards.test.ts b/packages/api/src/mcp/__tests__/dashboards.test.ts index 6ed04e2ff9..6e123387e6 100644 --- a/packages/api/src/mcp/__tests__/dashboards.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards.test.ts @@ -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 diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index 8a0fc5e8d1..4d0403be2f 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -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. ' + @@ -718,8 +739,13 @@ 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.\n\n' + 'Example (broadcast to every tile):\n' + '[\n' + ' { "type": "QUERY_EXPRESSION", "name": "Service", "expression": "ServiceName",\n' + 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..20bc9ebf05 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,127 @@ 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. + }, + ], + }; + + const response = await authRequest('post', BASE_URL) + .send(dashboardPayload) + .expect(200); + + expect(response.body.data.filters).toHaveLength(3); + expect(response.body.data.filters[0]).toMatchObject({ + name: 'Service (locked, read-only)', + constant: true, + renderMode: 'readonly', + }); + expect(response.body.data.filters[1]).toMatchObject({ + name: 'Environment (hidden)', + 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].constant).toBeUndefined(); + expect(response.body.data.filters[2].renderMode).toBeUndefined(); + + // 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. + 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, + }, + ], + }; + 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]).toMatchObject({ + id: response.body.data.filters[2].id, + name: 'Region (now read-only)', + 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 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..e152c7ca7f 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -1370,6 +1370,28 @@ 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. + * default: false + * example: true + * renderMode: + * type: string + * enum: [editable, readonly, hidden] + * description: | + * 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. + * default: "editable" + * example: "readonly" * * Filter: * allOf: diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 2225949dd3..58f93fbb5a 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -18,13 +18,17 @@ import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs'; import { ErrorBoundary } from 'react-error-boundary'; import RGL, { WidthProvider } from 'react-grid-layout'; import { useForm, useWatch } from 'react-hook-form'; -import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata'; +import { + parseKeyPath, + TableConnection, +} from '@hyperdx/common-utils/dist/core/metadata'; import { convertToDashboardTemplate, displayTypeSupportsBuilderAlerts, displayTypeSupportsRawSqlAlerts, Granularity, } from '@hyperdx/common-utils/dist/core/utils'; +import { filtersToQuery } from '@hyperdx/common-utils/dist/filters'; import { displayTypeRequiresSource, isBuilderChartConfig, @@ -164,6 +168,7 @@ import { } from './GranularityPicker'; import HDXMarkdownChart from './HDXMarkdownChart'; import { withAppNav } from './layout'; +import { parseQuery } from './searchFilters'; import { getFirstTimestampValueExpression, useSource, @@ -1386,7 +1391,9 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { setFilterQueries, ignoredFilterExpressions, getFilterQueriesForSource, - } = useDashboardFilters(filters); + } = useDashboardFilters(filters, { + savedFilterValues: dashboard?.savedFilterValues, + }); const dashboardReady = !!dashboard?.id && @@ -1416,7 +1423,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( @@ -1428,6 +1438,33 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { } 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) { + const existing = draft.savedFilterValues ?? []; + const { filters: parsed, passthroughFilters } = parseQuery(existing); + const norm = parseKeyPath(filter.expression).join('.'); + const remaining: typeof parsed = {}; + for (const [key, value] of Object.entries(parsed)) { + if (parseKeyPath(key).join('.') !== norm) { + remaining[key] = value; + } + } + if (options.defaultValues.length > 0) { + remaining[filter.expression] = { + included: new Set(options.defaultValues), + excluded: new Set(), + }; + } + draft.savedFilterValues = [ + ...filtersToQuery(remaining), + ...passthroughFilters, + ]; + } }), ); }; @@ -1537,9 +1574,42 @@ 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 = new Set(); + for (const f of dashboard.filters ?? []) { + if (f.constant) { + constantExpressions.add(parseKeyPath(f.expression).join('.')); + } + } + if (constantExpressions.size === 0) { + setFilterQueries(dashboard.savedFilterValues); + } else { + // Filter out lucene/sql Filter[] entries that resolve to a + // constant expression. The hook overlays these from + // savedFilterValues itself. + const { filters: parsedSaved, passthroughFilters } = parseQuery( + dashboard.savedFilterValues, + ); + const remaining: typeof parsedSaved = {}; + for (const [key, value] of Object.entries(parsedSaved)) { + const norm = parseKeyPath(key).join('.'); + if (!constantExpressions.has(norm)) { + remaining[key] = value; + } + } + const remainingQueries = [ + ...filtersToQuery(remaining), + ...passthroughFilters, + ]; + setFilterQueries(remainingQueries.length ? remainingQueries : null); + } } else if (isSwitchingDashboards) { setFilterQueries(null); } @@ -1551,6 +1621,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { dashboard?.savedQuery, dashboard?.savedQueryLanguage, dashboard?.savedFilterValues, + dashboard?.filters, isLocalDashboard, isFetchingDashboard, router.isReady, @@ -1584,9 +1655,50 @@ 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 = new Set(); + for (const f of dashboard.filters ?? []) { + if (f.constant) { + constantExpressions.add(parseKeyPath(f.expression).join('.')); + } + } + const urlFilterValues = rawFilterQueries?.length ? rawFilterQueries : []; + let currentFilterValues: Filter[] = urlFilterValues; + if (constantExpressions.size > 0 && dashboard.savedFilterValues?.length) { + const { filters: parsedSaved, passthroughFilters: savedPassthrough } = + parseQuery(dashboard.savedFilterValues); + const preservedSaved: typeof parsedSaved = {}; + for (const [key, value] of Object.entries(parsedSaved)) { + const norm = parseKeyPath(key).join('.'); + if (constantExpressions.has(norm)) { + preservedSaved[key] = value; + } + } + const preservedQueries = [ + ...filtersToQuery(preservedSaved), + ...savedPassthrough, + ]; + // Drop any URL entry whose expression collides with a constant; the + // preserved saved entry is the source of truth. + const { filters: parsedUrl, passthroughFilters: urlPassthrough } = + parseQuery(urlFilterValues); + const filteredUrl: typeof parsedUrl = {}; + for (const [key, value] of Object.entries(parsedUrl)) { + const norm = parseKeyPath(key).join('.'); + if (!constantExpressions.has(norm)) { + filteredUrl[key] = value; + } + } + currentFilterValues = [ + ...preservedQueries, + ...filtersToQuery(filteredUrl), + ...urlPassthrough, + ]; + } setDashboard( produce(dashboard, draft => { @@ -2768,6 +2880,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..9b7546455b 100644 --- a/packages/app/src/DashboardFilters.tsx +++ b/packages/app/src/DashboardFilters.tsx @@ -1,7 +1,7 @@ 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 +29,7 @@ const DashboardFilterSelect = ({ }: DashboardFilterSelectProps) => { const sortedValues = values?.toSorted() || []; const tooltipText = getAppliesToTooltip(filter); + const isReadOnly = filter.renderMode === 'readonly' || !!filter.constant; return ( @@ -36,6 +37,18 @@ const DashboardFilterSelect = ({ {filter.name} + {isReadOnly && ( + + + + )} @@ -71,14 +84,18 @@ 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. + const visibleFilters = filters.filter(f => f.renderMode !== 'hidden'); + 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..1f508bdffa 100644 --- a/packages/app/src/DashboardFiltersModal.tsx +++ b/packages/app/src/DashboardFiltersModal.tsx @@ -1,13 +1,18 @@ -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 { + parseKeyPath, + TableConnection, +} from '@hyperdx/common-utils/dist/core/metadata'; import { DashboardFilter, + Filter, MetricsDataType, SourceKind, TSource, } from '@hyperdx/common-utils/dist/types'; import { + Alert, Button, Center, Group, @@ -15,6 +20,7 @@ import { Modal, Paper, Radio, + Select, Stack, Text, TextInput, @@ -25,6 +31,7 @@ import { import { IconFilter, IconInfoCircle, + IconLock, IconPencil, IconRefresh, IconSearch, @@ -35,12 +42,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 +58,44 @@ 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). +type FilterVisibility = 'editable' | 'readonly' | 'hidden'; + +const getFilterVisibility = (filter: { + constant?: boolean; + renderMode?: 'editable' | 'readonly' | 'hidden'; +}): FilterVisibility => { + if (filter.renderMode === 'hidden') return 'hidden'; + if (filter.renderMode === 'readonly' || filter.constant) return 'readonly'; + return 'editable'; +}; + +const applyFilterVisibility = ( + visibility: FilterVisibility, +): Pick => { + switch (visibility) { + case 'readonly': + return { constant: true, renderMode: 'readonly' }; + case 'hidden': + return { constant: true, renderMode: 'hidden' }; + case 'editable': + default: + // Leave both fields undefined so dashboards saved with the default + // visibility round-trip with no constant / renderMode keys present. + return { constant: undefined, renderMode: undefined }; + } +}; + +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)' }, +]; + interface CustomInputWrapperProps { children: React.ReactNode; label: string; @@ -84,30 +132,174 @@ 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 as DashboardFilter] : []), + [queryReady, filter], + ); + + // Fallback range for the disabled state (queryReady=false). The hook + // only fires when filtersForQuery is non-empty, so this placeholder + // never reaches ClickHouse; it just satisfies the hook's signature. + const NEVER_USED_RANGE = useMemo( + (): [Date, Date] => [new Date(0), new Date(0)], + [], + ); + 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) => { + const initialDefaultValues = useMemo( + () => getDefaultValuesForExpression(filter.expression, savedFilterValues), + [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, }, }); @@ -117,8 +309,16 @@ const DashboardFilterEditForm = ({ where: filter.where ?? '', whereLanguage: filter.whereLanguage ?? getStoredLanguage() ?? 'sql', appliesToSourceIds: filter.appliesToSourceIds ?? [], + visibility: getFilterVisibility(filter), + defaultValues: initialDefaultValues, }); - }, [filter, reset]); + }, [filter, initialDefaultValues, 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' }); const sourceId = useWatch({ control, name: 'source' }); const { data: source } = useSource({ id: sourceId }); @@ -138,6 +338,34 @@ 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. + const filterForValueQuery = useMemo(() => { + if (!sourceId || !watchedExpression) return null; + return { + id: filter.id, + type: 'QUERY_EXPRESSION' as const, + name: filter.name || 'preview', + source: sourceId, + expression: watchedExpression, + where: watchedWhere?.trim() ? watchedWhere : undefined, + whereLanguage: watchedWhere?.trim() + ? (watchedWhereLanguage ?? 'sql') + : undefined, + sourceMetricType: metricType, + }; + }, [ + filter.id, + filter.name, + sourceId, + watchedExpression, + watchedWhere, + watchedWhereLanguage, + metricType, + ]); + const [modalContentRef, setModalContentRef] = useState( null, ); @@ -158,14 +386,55 @@ 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 before saving. The + // visibility preset translates to the orthogonal + // (constant, renderMode) pair the schema expects. + const { + visibility, + defaultValues: editedDefaultValues, + ...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()); + const defaultsChanged = + sanitizedDefaults.length !== initialNormalized.length || + sanitizedDefaults.some( + (v, i) => v !== (initialNormalized[i] ?? ''), + ); + 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 +541,63 @@ const DashboardFilterEditForm = ({ /> + {supportsConstantFilters && ( + <> + + ( + + )} + /> + + + + ( +