From 523c74f53bc60f386cc9fb3d242aab14c8c493a5 Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Fri, 22 May 2026 11:00:54 -0400 Subject: [PATCH] feat: Add source scoping to dashboard filters --- .changeset/purple-pets-fail.md | 7 + packages/api/openapi.json | 10 + .../api/src/mcp/prompts/dashboards/content.ts | 36 ++-- .../api/src/mcp/tools/dashboards/schemas.ts | 24 ++- .../external-api/__tests__/dashboards.test.ts | 68 +++++++ .../src/routers/external-api/v2/dashboards.ts | 11 + packages/app/src/DBDashboardImportPage.tsx | 164 ++++++++++++--- packages/app/src/DBDashboardPage.tsx | 189 +++++++++--------- packages/app/src/DashboardFilters.tsx | 56 ++++-- packages/app/src/DashboardFiltersModal.tsx | 131 ++++++++---- .../app/src/components/SourceMultiSelect.tsx | 88 ++++++++ packages/app/src/components/SourceSelect.tsx | 50 ++--- .../__tests__/sourceSelectUtils.test.tsx | 123 ++++++++++++ .../app/src/components/sourceSelectUtils.tsx | 49 +++++ .../usePresetDashboardFilters.test.tsx | 1 + .../app/src/hooks/useDashboardFilters.tsx | 50 +++++ .../dashboard-template-import.spec.ts | 89 +++++++++ .../app/tests/e2e/features/dashboard.spec.ts | 107 ++++++++++ .../e2e/page-objects/DashboardImportPage.ts | 11 +- .../tests/e2e/page-objects/DashboardPage.ts | 28 ++- .../common-utils/src/__tests__/utils.test.ts | 110 ++++++++++ packages/common-utils/src/core/utils.ts | 8 + packages/common-utils/src/types.ts | 3 + 23 files changed, 1195 insertions(+), 218 deletions(-) create mode 100644 .changeset/purple-pets-fail.md create mode 100644 packages/app/src/components/SourceMultiSelect.tsx create mode 100644 packages/app/src/components/__tests__/sourceSelectUtils.test.tsx create mode 100644 packages/app/src/components/sourceSelectUtils.tsx diff --git a/.changeset/purple-pets-fail.md b/.changeset/purple-pets-fail.md new file mode 100644 index 0000000000..13dd16eec0 --- /dev/null +++ b/.changeset/purple-pets-fail.md @@ -0,0 +1,7 @@ +--- +"@hyperdx/common-utils": patch +"@hyperdx/api": patch +"@hyperdx/app": patch +--- + +feat: Add source scoping to dashboard filters diff --git a/packages/api/openapi.json b/packages/api/openapi.json index 86fc0e1eb2..6018c8300c 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -2349,6 +2349,16 @@ "description": "Language of the where condition", "default": "sql", "example": "lucene" + }, + "appliesToSourceIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional list of source IDs this filter applies to. Omit or provide\nan empty array to apply the filter to ALL tiles regardless of source.\nA non-empty array restricts the filter to only tiles whose source ID\nis in the list; tiles using other sources are not affected by the\nselected filter value(s).\n", + "example": [ + "65f5e4a3b9e77c001a111111" + ] } } }, diff --git a/packages/api/src/mcp/prompts/dashboards/content.ts b/packages/api/src/mcp/prompts/dashboards/content.ts index 28bb45061b..6ba6f2c437 100644 --- a/packages/api/src/mcp/prompts/dashboards/content.ts +++ b/packages/api/src/mcp/prompts/dashboards/content.ts @@ -102,7 +102,7 @@ Apply these before calling hyperdx_save_dashboard. Each rule is enforced by the 8. HEATMAPS FOR DISTRIBUTIONS. Trace duration buckets, payload size buckets. Trace sources only. Set numberFormat: { output: "duration", factor: 0.000000001 } on the chart config so the y-axis reads in human time. -9. FOR FOCUSED PER-DIMENSION DASHBOARDS, DECLARE A DASHBOARD-LEVEL FILTER. Pass filters: [{ type: "QUERY_EXPRESSION", name, expression, sourceId }] at the top level. The user gets a dropdown in the dashboard header; every tile on the same source re-scopes when a value is picked. Do NOT hardcode the dimension into each tile's where clause. +9. FOR FOCUSED PER-DIMENSION DASHBOARDS, DECLARE A DASHBOARD-LEVEL FILTER. Pass filters: [{ type: "QUERY_EXPRESSION", name, expression, sourceId }] at the top level. The user gets a dropdown in the dashboard header; by default every tile is re-scoped when a value is picked. On mixed-source dashboards add appliesToSourceIds: ["", ...] to restrict the filter to only the tiles whose source carries that column. Omit the field to keep the broadcast-to-all-tiles default. Do NOT hardcode the dimension into each tile's where clause. 10. UPDATE IS REPLACE, NOT MERGE. hyperdx_save_dashboard with an id overwrites tiles, containers, and filters in their entirety. Call hyperdx_get_dashboard first when you only want to add or rename one entry; do not send a partial set or you will silently drop everything you omitted. @@ -958,24 +958,36 @@ to plot the first as a ratio of the second. Useful for error rates: == DASHBOARD FILTERS == -Optional dashboard-level filter declarations. Each entry adds a dropdown to the dashboard header that scopes every tile against the same source. Use this for focused per-dimension dashboards (per-service, per-tenant, per-endpoint) instead of hardcoding the dimension into every tile's where clause. +Optional dashboard-level filter declarations. Each entry adds a dropdown to the dashboard header that scopes tiles against the filter's expression. Use this for focused per-dimension dashboards (per-service, per-tenant, per-endpoint) instead of hardcoding the dimension into every tile's where clause. Filter shape: - { type, name, expression, sourceId, where?, whereLanguage? } + { type, name, expression, sourceId, where?, whereLanguage?, appliesToSourceIds? } - type "QUERY_EXPRESSION" (the only currently supported type). - name Human label shown in the filter dropdown (e.g. "Service"). - expression Column or attribute path the filter scopes (e.g. "ServiceName" or "SpanAttributes['tenant.id']"). - sourceId Which source the expression resolves against. Tiles on a different source are NOT scoped by the filter. - where Optional pre-filter that narrows the set of distinct values offered in the dropdown. - whereLanguage "lucene" or "sql". Defaults to "lucene". + type "QUERY_EXPRESSION" (the only currently supported type). + name Human label shown in the filter dropdown (e.g. "Service"). + expression Column or attribute path the filter scopes (e.g. "ServiceName" or "SpanAttributes['tenant.id']"). + sourceId Which source the dropdown VALUES are queried from. Independent of which tiles get filtered (see appliesToSourceIds below). + where Optional pre-filter that narrows the set of distinct values offered in the dropdown. + whereLanguage "lucene" or "sql". Defaults to "lucene". + appliesToSourceIds Optional list of source IDs the filter applies to. Omit (or pass undefined) to apply the filter to EVERY tile regardless of source (the recommended default). Pass a non-empty array to restrict the filter to tiles whose source is in that list, useful on mixed-source dashboards where the column only exists on some sources. -Example: +Example (broadcast to every tile, the common case): filters: [ { type: "QUERY_EXPRESSION", name: "Service", expression: "ServiceName", sourceId: "" } ] -When a value is picked in the dropdown, the renderer combines it with each tile's existing where clause via AND. Tiles do NOT need to reference the filter name; the source match alone is enough. +Example (scoped to the trace source only on a mixed log/trace/metric dashboard): + filters: [ + { + type: "QUERY_EXPRESSION", + name: "Service", + expression: "SpanName", + sourceId: "", + appliesToSourceIds: [""] + } + ] + +When a value is picked in the dropdown, the renderer combines it with each in-scope tile's existing where clause via AND. Tiles do NOT need to reference the filter name; matching the scope (or no scope set) is enough. == TABLE TILE LINKING (config.onClick) == @@ -1197,7 +1209,7 @@ Example: find top patterns for production services over the last 4 hours: 7. Hardcoding a focus dimension into every tile's where clause Wrong: five tiles, each with where: "ServiceName:checkout" Correct: filters: [{ type: "QUERY_EXPRESSION", name: "Service", expression: "ServiceName", sourceId: "" }] - The dashboard filter applies globally; tiles do not need the literal. + The dashboard filter applies globally; tiles do not need the literal. On mixed-source dashboards where only some tiles carry the column, add appliesToSourceIds: ["", ...] to scope the filter to just those sources instead of breaking the unrelated tiles. 8. Forgetting to validate tiles after saving Always call hyperdx_query_tile on EVERY tile after hyperdx_save_dashboard, not just one. Save validates input shape but not query semantics. Several known gaps (Lucene comparison/wildcard on map attributes, builder tiles on metric sources, malformed having) pass save and fail at render time. A dashboard with one bad tile renders the whole page in a degraded state; the user sees "Error loading chart" with no way to know which tile broke unless you validated. If query_tile returns an error, fix the where / SQL and re-save before declaring the dashboard ready. diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index 77e00a8d73..8a0fc5e8d1 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -688,6 +688,18 @@ const mcpDashboardFilterSchema = z whereLanguage: SearchConditionLanguageSchema.describe( 'Filter language for `where` ("lucene" or "sql"). Optional, but set it explicitly.', ), + appliesToSourceIds: z + .array(objectIdSchema) + .optional() + .describe( + 'Optional list of source IDs that this filter is applied to. ' + + 'Omit (or pass `undefined`) to apply the filter to ALL tiles regardless of source ' + + '— this is the recommended default. ' + + 'A non-empty array restricts the filter to only tiles whose source ID is in the list; ' + + 'tiles on other sources are not affected by the dropdown value at all. ' + + 'Useful on mixed-source dashboards where a column (e.g. SpanName) only exists on ' + + 'a subset of sources.', + ), }) .describe( 'A dashboard-level filter the user can adjust in the dashboard filter bar. ' + @@ -704,10 +716,20 @@ export const mcpFiltersParam = z 'If another tile\'s onClick targets THIS dashboard with `filters: [{ expression: "X", ... }]`, ' + 'this array MUST declare a filter whose `expression` is "X". Otherwise the value is ' + 'dropped on arrival and the destination opens unfiltered.\n\n' + - 'Example:\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 ' + + 'broadcast-to-all-tiles default.\n\n' + + 'Example (broadcast to every tile):\n' + '[\n' + ' { "type": "QUERY_EXPRESSION", "name": "Service", "expression": "ServiceName",\n' + ' "sourceId": "", "whereLanguage": "sql" }\n' + + ']\n\n' + + 'Example (scoped on a mixed log/trace/metric dashboard):\n' + + '[\n' + + ' { "type": "QUERY_EXPRESSION", "name": "Service", "expression": "SpanName",\n' + + ' "sourceId": "", "whereLanguage": "sql",\n' + + ' "appliesToSourceIds": [""] }\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 d964fd1e75..4d8cbb7d44 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.test.ts @@ -847,6 +847,9 @@ describe('External API v2 Dashboards - old format', () => { expression: 'service_name', sourceId: traceSource._id.toString(), sourceMetricType: undefined, + // Scope to a single source (the common case for mixed-source + // dashboards) — exercises the array round-trip. + appliesToSourceIds: [traceSource._id.toString()], }, { type: 'QUERY_EXPRESSION' as const, @@ -855,6 +858,11 @@ describe('External API v2 Dashboards - old format', () => { sourceId: traceSource._id.toString(), where: "environment = 'production'", whereLanguage: 'sql' as const, + // Scope to multiple sources to exercise multi-entry arrays. + appliesToSourceIds: [ + traceSource._id.toString(), + metricSource._id.toString(), + ], }, ], }; @@ -885,14 +893,25 @@ describe('External API v2 Dashboards - old format', () => { ); expect(response.body.data.filters[0].name).toBe('Environment'); expect(response.body.data.filters[0].expression).toBe('environment'); + // Filter 0 omitted appliesToSourceIds (broadcast-to-all) — must NOT be + // materialized as an empty array on read; the field stays absent so + // the default semantics survive a save/load round-trip. + expect(response.body.data.filters[0].appliesToSourceIds).toBeUndefined(); expect(response.body.data.filters[1].name).toBe('Service Filter'); expect(response.body.data.filters[1].expression).toBe('service_name'); + expect(response.body.data.filters[1].appliesToSourceIds).toEqual([ + traceSource._id.toString(), + ]); expect(response.body.data.filters[2].name).toBe('Region (Filtered)'); expect(response.body.data.filters[2].expression).toBe('region'); expect(response.body.data.filters[2].where).toBe( "environment = 'production'", ); expect(response.body.data.filters[2].whereLanguage).toBe('sql'); + expect(response.body.data.filters[2].appliesToSourceIds).toEqual([ + traceSource._id.toString(), + metricSource._id.toString(), + ]); const getResponse = await authRequest( 'get', @@ -1045,6 +1064,7 @@ describe('External API v2 Dashboards - old format', () => { name: 'Updated Filter 1', expression: 'environment', sourceId: traceSource._id.toString(), + // Broadcast filter: appliesToSourceIds intentionally omitted. }, { id: filterId2, @@ -1052,6 +1072,11 @@ describe('External API v2 Dashboards - old format', () => { name: 'Updated Filter 2', expression: 'service_name', sourceId: traceSource._id.toString(), + // Multi-source scope to exercise array round-trip on PUT. + appliesToSourceIds: [ + traceSource._id.toString(), + metricSource._id.toString(), + ], }, ], }, @@ -1069,6 +1094,9 @@ describe('External API v2 Dashboards - old format', () => { expression: 'environment', sourceId: traceSource._id.toString(), }); + // Broadcast filter must stay broadcast on read — the field must not + // be materialized into an empty array by save/load. + expect(response.body.data.filters[0].appliesToSourceIds).toBeUndefined(); expect(response.body.data.filters[1]).toMatchObject({ id: expect.any(String), type: 'QUERY_EXPRESSION', @@ -1076,6 +1104,10 @@ describe('External API v2 Dashboards - old format', () => { expression: 'service_name', sourceId: traceSource._id.toString(), }); + expect(response.body.data.filters[1].appliesToSourceIds).toEqual([ + traceSource._id.toString(), + metricSource._id.toString(), + ]); const getResponse = await authRequest( 'get', @@ -1094,6 +1126,8 @@ describe('External API v2 Dashboards - old format', () => { name: 'Existing Filter 1', expression: 'environment', sourceId: traceSource._id.toString(), + // Stored with a scope — a no-filters PUT must preserve it intact. + appliesToSourceIds: [traceSource._id.toString()], }, { id: existingFilterId2, @@ -3039,6 +3073,9 @@ describe('External API v2 Dashboards - new format', () => { expression: 'service_name', sourceId: traceSource._id.toString(), sourceMetricType: undefined, + // Scope to a single source (the common case for mixed-source + // dashboards) — exercises the array round-trip. + appliesToSourceIds: [traceSource._id.toString()], }, { type: 'QUERY_EXPRESSION' as const, @@ -3047,6 +3084,11 @@ describe('External API v2 Dashboards - new format', () => { sourceId: traceSource._id.toString(), where: "environment = 'production'", whereLanguage: 'sql' as const, + // Scope to multiple sources to exercise multi-entry arrays. + appliesToSourceIds: [ + traceSource._id.toString(), + metricSource._id.toString(), + ], }, ], }; @@ -3077,14 +3119,25 @@ describe('External API v2 Dashboards - new format', () => { ); expect(response.body.data.filters[0].name).toBe('Environment'); expect(response.body.data.filters[0].expression).toBe('environment'); + // Filter 0 omitted appliesToSourceIds (broadcast-to-all) — must NOT be + // materialized as an empty array on read; the field stays absent so + // the default semantics survive a save/load round-trip. + expect(response.body.data.filters[0].appliesToSourceIds).toBeUndefined(); expect(response.body.data.filters[1].name).toBe('Service Filter'); expect(response.body.data.filters[1].expression).toBe('service_name'); + expect(response.body.data.filters[1].appliesToSourceIds).toEqual([ + traceSource._id.toString(), + ]); expect(response.body.data.filters[2].name).toBe('Region (Filtered)'); expect(response.body.data.filters[2].expression).toBe('region'); expect(response.body.data.filters[2].where).toBe( "environment = 'production'", ); expect(response.body.data.filters[2].whereLanguage).toBe('sql'); + expect(response.body.data.filters[2].appliesToSourceIds).toEqual([ + traceSource._id.toString(), + metricSource._id.toString(), + ]); const getResponse = await authRequest( 'get', @@ -3206,6 +3259,7 @@ describe('External API v2 Dashboards - new format', () => { name: 'Updated Filter 1', expression: 'environment', sourceId: traceSource._id.toString(), + // Broadcast filter: appliesToSourceIds intentionally omitted. }, { id: filterId2, @@ -3213,6 +3267,11 @@ describe('External API v2 Dashboards - new format', () => { name: 'Updated Filter 2', expression: 'service_name', sourceId: traceSource._id.toString(), + // Multi-source scope to exercise array round-trip on PUT. + appliesToSourceIds: [ + traceSource._id.toString(), + metricSource._id.toString(), + ], }, ], }, @@ -3230,6 +3289,9 @@ describe('External API v2 Dashboards - new format', () => { expression: 'environment', sourceId: traceSource._id.toString(), }); + // Broadcast filter must stay broadcast on read — the field must not + // be materialized into an empty array by save/load. + expect(response.body.data.filters[0].appliesToSourceIds).toBeUndefined(); expect(response.body.data.filters[1]).toMatchObject({ id: expect.any(String), type: 'QUERY_EXPRESSION', @@ -3237,6 +3299,10 @@ describe('External API v2 Dashboards - new format', () => { expression: 'service_name', sourceId: traceSource._id.toString(), }); + expect(response.body.data.filters[1].appliesToSourceIds).toEqual([ + traceSource._id.toString(), + metricSource._id.toString(), + ]); const getResponse = await authRequest( 'get', @@ -3255,6 +3321,8 @@ describe('External API v2 Dashboards - new format', () => { name: 'Existing Filter 1', expression: 'environment', sourceId: traceSource._id.toString(), + // Stored with a scope — a no-filters PUT must preserve it intact. + appliesToSourceIds: [traceSource._id.toString()], }, { id: existingFilterId2, diff --git a/packages/api/src/routers/external-api/v2/dashboards.ts b/packages/api/src/routers/external-api/v2/dashboards.ts index 3f73418553..cb3735ae82 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -1359,6 +1359,17 @@ function getSourceConnectionMismatches( * description: Language of the where condition * default: "sql" * example: "lucene" + * appliesToSourceIds: + * type: array + * items: + * type: string + * description: | + * Optional list of source IDs this filter applies to. Omit or provide + * an empty array to apply the filter to ALL tiles regardless of source. + * A non-empty array restricts the filter to only tiles whose source ID + * is in the list; tiles using other sources are not affected by the + * selected filter value(s). + * example: ["65f5e4a3b9e77c001a111111"] * * Filter: * allOf: diff --git a/packages/app/src/DBDashboardImportPage.tsx b/packages/app/src/DBDashboardImportPage.tsx index ee006458df..9ce75353a5 100644 --- a/packages/app/src/DBDashboardImportPage.tsx +++ b/packages/app/src/DBDashboardImportPage.tsx @@ -24,6 +24,7 @@ import { isOnClickSearchById, isTraceSource, SavedChartConfig, + TSource, } from '@hyperdx/common-utils/dist/types'; import { Anchor, @@ -38,6 +39,7 @@ import { TagsInput, Text, TextInput, + Tooltip, } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; import { useDisclosure } from '@mantine/hooks'; @@ -50,6 +52,7 @@ import { } from '@tabler/icons-react'; import SelectControlled from './components/SelectControlled'; +import { SourceMultiSelectControlled } from './components/SourceMultiSelect'; import { useBrandDisplayName } from './theme/ThemeProvider'; import api from './api'; import { useConnections } from './connection'; @@ -214,6 +217,37 @@ function FileSelection({ ); } +/** + * Resolve a filter's templated `appliesToSourceIds` (an ordered list of + * source names from the exported file) against the current workspace's + * sources. Names that don't match any current source are dropped. + * + * `resolvedIndexOf(name)` returns the position of `name` in the surviving + * `ids` array (case-insensitive), or -1 if the name wasn't present in the + * template or didn't resolve. + */ +function resolveAppliesToSources( + templateNames: string[] | undefined, + sources: TSource[] | undefined, +): { ids: string[]; resolvedIndexOf: (name: string) => number } { + const ids: string[] = []; + const resolvedIndexByLowerName = new Map(); + templateNames?.forEach(name => { + const match = sources?.find( + source => source.name.toLowerCase() === name.toLowerCase(), + ); + if (match) { + resolvedIndexByLowerName.set(name.toLowerCase(), ids.length); + ids.push(match.id); + } + }); + return { + ids, + resolvedIndexOf: name => + resolvedIndexByLowerName.get(name.toLowerCase()) ?? -1, + }; +} + const MappingFormStateSchema = z.object({ dashboardName: z.string().min(1), tags: z.array(z.string()), @@ -223,6 +257,12 @@ const MappingFormStateSchema = z.object({ connectionMappings: z.array(z.string()), /** A list of filter source mappings, ordered by input filter index */ filterSourceMappings: z.array(z.string()).optional(), + /** + * Per-filter applies-to source mappings. Each entry is an array of mapped + * source IDs in the same order as the filter's `appliesToSourceIds` names + * from the template. Empty arrays mean "applies to all" after import. + */ + filterAppliesToSourceMappings: z.array(z.array(z.string())).optional(), /** A list of onClick source mappings, ordered by input tile index */ onClickSourceMappings: z.array(z.string()).optional(), /** A list of onClick dashboard mappings, ordered by input tile index */ @@ -239,7 +279,7 @@ function Mapping({ input }: { input: DashboardTemplate }) { const { data: existingTags } = api.useTags(); const [dashboardId] = useQueryState('dashboardId', parseAsString); - const { handleSubmit, getFieldState, control, setValue } = + const { handleSubmit, getFieldState, control, setValue, getValues } = useForm({ resolver: zodResolver(MappingFormStateSchema), defaultValues: { @@ -248,6 +288,7 @@ function Mapping({ input }: { input: DashboardTemplate }) { tileSourceMappings: input.tiles.map(() => ''), connectionMappings: input.tiles.map(() => ''), filterSourceMappings: input.filters?.map(() => '') ?? [], + filterAppliesToSourceMappings: input.filters?.map(() => []) ?? [], onClickSourceMappings: input.tiles.map(() => ''), onClickDashboardMappings: input.tiles.map(() => ''), }, @@ -282,6 +323,14 @@ function Mapping({ input }: { input: DashboardTemplate }) { return match?.id || ''; }); + // For each filter, map its declared applies-to source names to IDs in + // the current workspace via the shared resolver. Names that don't + // resolve are dropped — the surviving IDs become the multiselect's + // initial value. + const filterAppliesToSourceMappings = input.filters?.map( + filter => resolveAppliesToSources(filter.appliesToSourceIds, sources).ids, + ); + // onClick targets in a template carry the source/dashboard *name* in // target.id (see convertToDashboardTemplate). Map those names back to // the corresponding id in the current workspace. Template-mode targets @@ -309,6 +358,9 @@ function Mapping({ input }: { input: DashboardTemplate }) { setValue('tileSourceMappings', tileSourceMappings); setValue('connectionMappings', connectionMappings); setValue('filterSourceMappings', filterSourceMappings); + filterAppliesToSourceMappings?.forEach((mapping, idx) => { + setValue(`filterAppliesToSourceMappings.${idx}`, mapping); + }); setValue('onClickSourceMappings', onClickSourceMappings); setValue('onClickDashboardMappings', onClickDashboardMappings); }, [setValue, sources, connections, dashboards, input]); @@ -348,7 +400,9 @@ function Mapping({ input }: { input: DashboardTemplate }) { // Find the changed tile source mapping, if any if (tileSourceMappings) { const idx = tileSourceMappings.findIndex( - (mapping, i) => mapping !== prevSourceMappingsRef.current?.[i], + (mapping, i) => + mapping !== prevSourceMappingsRef.current?.[i] && + getFieldState(`tileSourceMappings.${i}`).isDirty, ); if (idx !== -1) { prevSourceMappingsRef.current = tileSourceMappings; @@ -360,7 +414,9 @@ function Mapping({ input }: { input: DashboardTemplate }) { // If no tile source mapping was changed, check the filter source mappings for changes if (inputSourceName == null && filterSourceMappings) { const idx = filterSourceMappings.findIndex( - (mapping, i) => mapping !== prevFilterSourceMappingsRef.current?.[i], + (mapping, i) => + mapping !== prevFilterSourceMappingsRef.current?.[i] && + getFieldState(`filterSourceMappings.${i}`).isDirty, ); if (idx !== -1) { prevFilterSourceMappingsRef.current = filterSourceMappings; @@ -372,7 +428,9 @@ function Mapping({ input }: { input: DashboardTemplate }) { // If no filter source mapping was changed, check the onClick source mappings for changes if (inputSourceName == null && onClickSourceMappings) { const idx = onClickSourceMappings.findIndex( - (mapping, i) => mapping !== prevOnClickSourceMappingsRef.current?.[i], + (mapping, i) => + mapping !== prevOnClickSourceMappingsRef.current?.[i] && + getFieldState(`onClickSourceMappings.${i}`).isDirty, ); if (idx !== -1) { prevOnClickSourceMappingsRef.current = onClickSourceMappings; @@ -420,6 +478,32 @@ function Mapping({ input }: { input: DashboardTemplate }) { setValue(key, selectedSourceId, { shouldValidate: true }); } } + + // Propagate changes to applies-to multiselects. Anchor the splice on + // the resolver's stripped-list index so it lines up with the form-state + // array (which has unresolved template names dropped). + input.filters?.forEach((filter, filterIdx) => { + if (!filter.appliesToSourceIds?.includes(inputSourceName)) return; + const key = `filterAppliesToSourceMappings.${filterIdx}` as const; + if (getFieldState(key).isDirty) return; + const currentIdMappings = getValues(key)?.slice() ?? []; + if (selectedSourceId && currentIdMappings.includes(selectedSourceId)) + return; + + const { resolvedIndexOf } = resolveAppliesToSources( + filter.appliesToSourceIds, + sources, + ); + const indexToUpdate = resolvedIndexOf(inputSourceName); + if (indexToUpdate >= 0) { + const next = [ + ...currentIdMappings.slice(0, indexToUpdate), + selectedSourceId, + ...currentIdMappings.slice(indexToUpdate + 1), + ]; + setValue(key, next, { shouldValidate: true }); + } + }); isUpdatingRef.current = false; }, [ tileSourceMappings, @@ -428,7 +512,9 @@ function Mapping({ input }: { input: DashboardTemplate }) { input.tiles, input.filters, getFieldState, + getValues, setValue, + sources, ]); // Propagate connection mapping changes to other RawSQL tiles with the same input connection @@ -437,7 +523,9 @@ function Mapping({ input }: { input: DashboardTemplate }) { if (!connectionMappings || !input.tiles) return; const changedIdx = connectionMappings.findIndex( - (mapping, idx) => mapping !== prevConnectionMappingsRef.current?.[idx], + (mapping, idx) => + mapping !== prevConnectionMappingsRef.current?.[idx] && + getFieldState(`connectionMappings.${idx}`).isDirty, ); if (changedIdx === -1) return; @@ -480,7 +568,8 @@ function Mapping({ input }: { input: DashboardTemplate }) { const changedIdx = onClickDashboardMappings.findIndex( (mapping, idx) => - mapping !== prevOnClickDashboardMappingsRef.current?.[idx], + mapping !== prevOnClickDashboardMappingsRef.current?.[idx] && + getFieldState(`onClickDashboardMappings.${idx}`).isDirty, ); if (changedIdx === -1) return; @@ -576,9 +665,13 @@ function Mapping({ input }: { input: DashboardTemplate }) { // Zip the source mappings with the input filters const zippedFilters = input.filters?.map((filter, idx) => { const source = findSource(data.filterSourceMappings?.[idx]); + const appliesTo = data.filterAppliesToSourceMappings?.[idx]?.filter( + id => !!id?.length, + ); return { ...filter, source: source!.id, + appliesToSourceIds: appliesTo?.length ? appliesTo : undefined, }; }); @@ -746,24 +839,47 @@ function Mapping({ input }: { input: DashboardTemplate }) { {/** Map filter sources */} {input.filters?.map((filter, i) => ( - - {filter.name} (Filter) - Data Source - {filter.source} - - ({ - value: source.id, - label: source.name, - }))} - placeholder="Select a source" - /> - - - - + + + {filter.name} (Filter) + Data Source + {filter.source} + + ({ + value: source.id, + label: source.name, + }))} + placeholder="Select a source" + /> + + + + + {!!filter.appliesToSourceIds?.length && ( + + {filter.name} (Filter) + + + Applies to Sources + + + {filter.appliesToSourceIds.join(', ')} + + + + + + + )} + ))} diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index cd9c73f466..c911a30321 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -1356,9 +1356,9 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { const { filterValues, setFilterValue, - filterQueries, setFilterQueries, ignoredFilterExpressions, + getFilterQueriesForSource, } = useDashboardFilters(filters); const dashboardReady = @@ -1761,97 +1761,106 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ); const renderTileComponent = useCallback( - (chart: Tile) => ( - setEditedTile(chart)} - granularity={ - isRefreshEnabled ? granularityOverride : (granularity ?? undefined) - } - filters={[ - { - type: whereLanguage === 'sql' ? 'sql' : 'lucene', - condition: where, - }, - ...(filterQueries ?? []), - ]} - onTimeRangeSelect={onTimeRangeSelect} - isHighlighted={highlightedTileId === chart.id} - onUpdateChart={newChart => { - if (!dashboard) return; - setDashboard( - produce(dashboard, draft => { - const chartIndex = draft.tiles.findIndex(c => c.id === chart.id); - if (chartIndex === -1) return; - draft.tiles[chartIndex] = newChart; - }), - ); - }} - onDuplicateClick={async () => { - if (dashboard != null) { - if ( - !(await confirm( - <> - Duplicate {'"'} - - {chart.config.name} - - {'"'}? - , - 'Duplicate', - )) - ) { - return; - } - setDashboard({ - ...dashboard, - tiles: [ - ...dashboard.tiles, - { - ...chart, - id: makeId(), - config: { - ...chart.config, - alert: undefined, - }, - }, - ], - }); + (chart: Tile) => { + // Resolve the tile's source ID so per-source-scoped filters can be + // narrowed to only the tiles they target. Builder and RawSQL configs + // both carry a `source` field; markdown / other configs don't. + const tileSourceId = + 'source' in chart.config ? chart.config.source : undefined; + return ( + setEditedTile(chart)} + granularity={ + isRefreshEnabled ? granularityOverride : (granularity ?? undefined) } - }} - onDeleteClick={async () => { - if (dashboard != null) { - if ( - !(await confirm( - <> - Delete{' '} - - {chart.config.name} - - ? - , - 'Delete', - { variant: 'danger' }, - )) - ) { - return; + filters={[ + { + type: whereLanguage === 'sql' ? 'sql' : 'lucene', + condition: where, + }, + ...getFilterQueriesForSource(tileSourceId), + ]} + onTimeRangeSelect={onTimeRangeSelect} + isHighlighted={highlightedTileId === chart.id} + onUpdateChart={newChart => { + if (!dashboard) return; + setDashboard( + produce(dashboard, draft => { + const chartIndex = draft.tiles.findIndex( + c => c.id === chart.id, + ); + if (chartIndex === -1) return; + draft.tiles[chartIndex] = newChart; + }), + ); + }} + onDuplicateClick={async () => { + if (dashboard != null) { + if ( + !(await confirm( + <> + Duplicate {'"'} + + {chart.config.name} + + {'"'}? + , + 'Duplicate', + )) + ) { + return; + } + setDashboard({ + ...dashboard, + tiles: [ + ...dashboard.tiles, + { + ...chart, + id: makeId(), + config: { + ...chart.config, + alert: undefined, + }, + }, + ], + }); } - setDashboard({ - ...dashboard, - tiles: dashboard.tiles.filter(c => c.id !== chart.id), - }); + }} + onDeleteClick={async () => { + if (dashboard != null) { + if ( + !(await confirm( + <> + Delete{' '} + + {chart.config.name} + + ? + , + 'Delete', + { variant: 'danger' }, + )) + ) { + return; + } + setDashboard({ + ...dashboard, + tiles: dashboard.tiles.filter(c => c.id !== chart.id), + }); + } + }} + moveTargets={moveTargetContainers} + onMoveToGroup={(containerId, tabId) => + handleMoveTileToGroup(chart.id, containerId, tabId) } - }} - moveTargets={moveTargetContainers} - onMoveToGroup={(containerId, tabId) => - handleMoveTileToGroup(chart.id, containerId, tabId) - } - isSelected={selectedTileIds.has(chart.id)} - onSelect={handleToggleTileSelect} - /> - ), + isSelected={selectedTileIds.has(chart.id)} + onSelect={handleToggleTileSelect} + /> + ); + }, [ dashboard, searchedTimeRange, @@ -1864,7 +1873,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { where, whereLanguage, onTimeRangeSelect, - filterQueries, + getFilterQueriesForSource, moveTargetContainers, handleMoveTileToGroup, selectedTileIds, diff --git a/packages/app/src/DashboardFilters.tsx b/packages/app/src/DashboardFilters.tsx index 38cc9d0d72..21ad1ac5dd 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, MultiSelect } from '@mantine/core'; -import { IconRefresh } from '@tabler/icons-react'; +import { Group, MultiSelect, Stack, Text, Tooltip } from '@mantine/core'; +import { IconHelp, IconRefresh } from '@tabler/icons-react'; import { useDashboardFilterValues } from './hooks/useDashboardFilterValues'; @@ -13,6 +13,12 @@ interface DashboardFilterSelectProps { isLoading?: boolean; } +const getAppliesToTooltip = (filter: DashboardFilter) => { + const count = filter.appliesToSourceIds?.length ?? 0; + if (count === 0) return 'Applies to all sources'; + return `Applies to ${count} source${count === 1 ? '' : 's'}`; +}; + const DashboardFilterSelect = ({ filter, onChange, @@ -25,22 +31,38 @@ const DashboardFilterSelect = ({ label: value, })); + const tooltipText = getAppliesToTooltip(filter); + return ( - + + + + {filter.name} + + + + + + + ); }; diff --git a/packages/app/src/DashboardFiltersModal.tsx b/packages/app/src/DashboardFiltersModal.tsx index c173dbf6dc..94b5fcb77a 100644 --- a/packages/app/src/DashboardFiltersModal.tsx +++ b/packages/app/src/DashboardFiltersModal.tsx @@ -27,7 +27,7 @@ import { IconInfoCircle, IconPencil, IconRefresh, - IconStack, + IconSearch, IconTrash, } from '@tabler/icons-react'; @@ -36,6 +36,7 @@ import SearchWhereInput, { } from '@/components/SearchInput/SearchWhereInput'; import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor'; +import { SourceMultiSelectControlled } from './components/SourceMultiSelect'; import SourceSchemaPreview from './components/SourceSchemaPreview'; import { SourceSelectControlled } from './components/SourceSelect'; import { useSource, useSources } from './source'; @@ -104,6 +105,7 @@ const DashboardFilterEditForm = ({ ...filter, where: filter.where ?? '', whereLanguage: filter.whereLanguage ?? getStoredLanguage() ?? 'sql', + appliesToSourceIds: filter.appliesToSourceIds ?? [], }, }); @@ -112,6 +114,7 @@ const DashboardFilterEditForm = ({ ...filter, where: filter.where ?? '', whereLanguage: filter.whereLanguage ?? getStoredLanguage() ?? 'sql', + appliesToSourceIds: filter.appliesToSourceIds ?? [], }); }, [filter, reset]); @@ -148,12 +151,16 @@ const DashboardFilterEditForm = ({
{ const trimmedWhere = values.where?.trim() ?? ''; + const appliesTo = values.appliesToSourceIds?.filter( + id => !!id?.length, + ); onSave({ ...values, where: trimmedWhere || undefined, whereLanguage: trimmedWhere ? (values.whereLanguage ?? 'sql') : undefined, + appliesToSourceIds: appliesTo?.length ? appliesTo : undefined, }); })} > @@ -182,6 +189,20 @@ const DashboardFilterEditForm = ({ disabled={!!presetSource} /> + {!presetSource && ( + + + + )} {sourceIsMetric && ( { interface DashboardFiltersListProps { filters: DashboardFilter[]; isLoading?: boolean; + hideAppliesTo?: boolean; onEdit: (filter: DashboardFilter) => void; onRemove: (id: string) => void; onClose: () => void; @@ -306,6 +328,7 @@ interface DashboardFiltersListProps { const DashboardFiltersList = ({ filters, isLoading, + hideAppliesTo, onEdit, onRemove, onClose, @@ -326,42 +349,79 @@ const DashboardFiltersList = ({ gap="xs" data-testid="dashboard-filters-list" > - {filters.map(filter => ( - - - {filter.name} - - onEdit(filter)} - className={styles.filterActionButton} - data-testid={`edit-filter-button-${filter.name}`} - > - - - onRemove(filter.id)} - className={`${styles.filterActionButton} ${styles.deleteButton}`} - data-testid={`delete-filter-button-${filter.name}`} + {filters.map(filter => { + const queriedSourceName = sources?.find( + s => s.id === filter.source, + )?.name; + const appliedSourceNames = filter.appliesToSourceIds?.length + ? filter.appliesToSourceIds + .map(id => sources?.find(s => s.id === id)?.name) + .filter((name): name is string => !!name) + : undefined; + const appliedDisplay = appliedSourceNames + ? appliedSourceNames.join(', ') + : 'All sources'; + return ( + + + {filter.name} + + onEdit(filter)} + className={styles.filterActionButton} + data-testid={`edit-filter-button-${filter.name}`} + > + + + onRemove(filter.id)} + className={`${styles.filterActionButton} ${styles.deleteButton}`} + data-testid={`delete-filter-button-${filter.name}`} + > + + + + + + - - + + + + {queriedSourceName} + - - - - - {sources?.find(s => s.id === filter.source)?.name} - - - - ))} + {!hideAppliesTo && ( + + + + + + {appliedDisplay} + + + )} + + ); + })} {isLoading && (
@@ -473,6 +533,7 @@ const DashboardFiltersModal = ({ onClose={onClose} onAddNew={handleAddNewFilter} isLoading={isLoading} + hideAppliesTo={!!source} /> ); } diff --git a/packages/app/src/components/SourceMultiSelect.tsx b/packages/app/src/components/SourceMultiSelect.tsx new file mode 100644 index 0000000000..665761057e --- /dev/null +++ b/packages/app/src/components/SourceMultiSelect.tsx @@ -0,0 +1,88 @@ +import { memo, useCallback } from 'react'; +import { useController, UseControllerProps } from 'react-hook-form'; +import { SourceKind } from '@hyperdx/common-utils/dist/types'; +import { + ComboboxItem, + Group, + MultiSelect, + MultiSelectProps, +} from '@mantine/core'; +import { IconCheck } from '@tabler/icons-react'; + +import { + SOURCE_KIND_ICONS, + useFilteredSortedSourceItems, + useSourceKindMap, +} from '@/components/sourceSelectUtils'; +import { useSources } from '@/source'; + +function SourceMultiSelectControlledComponent({ + allowedSourceKinds, + connectionId, + size, + placeholder, + ...props +}: { + allowedSourceKinds?: SourceKind[]; + connectionId?: string; + size?: string; + placeholder?: string; +} & UseControllerProps & + Omit) { + const { data } = useSources(); + const { + field: { value, onChange, onBlur, name, ref }, + fieldState, + } = useController(props); + + const sourceKindMap = useSourceKindMap(data); + + const items = useFilteredSortedSourceItems({ + sources: data, + allowedSourceKinds, + connectionId, + }); + + // Mantine passes `checked` to renderOption for MultiSelect items; show a + // check on selected entries so the dropdown reflects current selection + // without forcing the user to scan pills above. + const renderOption = useCallback( + ({ option, checked }: { option: ComboboxItem; checked?: boolean }) => { + const icon = SOURCE_KIND_ICONS[sourceKindMap.get(option.value) ?? '']; + return ( + + {icon} + {option.label} + {checked && ( + + )} + + ); + }, + [sourceKindMap], + ); + + return ( + + ); +} + +export const SourceMultiSelectControlled = memo( + SourceMultiSelectControlledComponent, +); diff --git a/packages/app/src/components/SourceSelect.tsx b/packages/app/src/components/SourceSelect.tsx index 1f7539fe90..ef015b9597 100644 --- a/packages/app/src/components/SourceSelect.tsx +++ b/packages/app/src/components/SourceSelect.tsx @@ -8,19 +8,16 @@ import { SelectProps, UnstyledButton, } from '@mantine/core'; -import { - IconChartLine, - IconConnection, - IconDeviceLaptop, - IconLogs, - IconPlus, - IconSettings, - IconStack, -} from '@tabler/icons-react'; +import { IconPlus, IconSettings, IconStack } from '@tabler/icons-react'; import SelectControlled, { SelectControlledSpecialValues, } from '@/components/SelectControlled'; +import { + SOURCE_KIND_ICONS, + useFilteredSortedSourceItems, + useSourceKindMap, +} from '@/components/sourceSelectUtils'; import { useSources } from '@/source'; import styles from '../../styles/SourceSelectControlled.module.scss'; @@ -57,13 +54,6 @@ export const SourceSelectRightSection = ({ }; }; -const SOURCE_KIND_ICONS: Record = { - [SourceKind.Log]: , - [SourceKind.Trace]: , - [SourceKind.Session]: , - [SourceKind.Metric]: , -}; - const OPTION_ICONS: Record = { [SelectControlledSpecialValues.CreateNewValue]: , [SelectControlledSpecialValues.EditValue]: , @@ -102,11 +92,7 @@ function SourceSelectControlledComponent({ ); - const sourceKindMap = useMemo(() => { - const map = new Map(); - data?.forEach(s => map.set(s.id, s.kind)); - return map; - }, [data]); + const sourceKindMap = useSourceKindMap(data); const renderOption = useCallback( ({ option }: { option: ComboboxItem }) => { @@ -126,21 +112,13 @@ function SourceSelectControlledComponent({ const hasActions = !!onCreate || !!onEdit; - const values = useMemo(() => { - const sourceItems = ( - data - ?.filter( - source => - (!allowedSourceKinds || allowedSourceKinds.includes(source.kind)) && - (!connectionId || source.connection === connectionId) && - !source.disabled, - ) - .map(d => ({ - value: d.id, - label: d.name, - })) ?? [] - ).sort((a, b) => a.label.localeCompare(b.label)); + const sourceItems = useFilteredSortedSourceItems({ + sources: data, + allowedSourceKinds, + connectionId, + }); + const values = useMemo(() => { if (!hasActions) { return sourceItems; } @@ -160,7 +138,7 @@ function SourceSelectControlledComponent({ } return [...sourceItems, { group: 'Actions', items: actionItems }]; - }, [data, onCreate, onEdit, allowedSourceKinds, connectionId, hasActions]); + }, [sourceItems, onCreate, onEdit, hasActions]); const rightSectionProps = SourceSelectRightSection({ sourceSchemaPreview }); diff --git a/packages/app/src/components/__tests__/sourceSelectUtils.test.tsx b/packages/app/src/components/__tests__/sourceSelectUtils.test.tsx new file mode 100644 index 0000000000..08272c3be1 --- /dev/null +++ b/packages/app/src/components/__tests__/sourceSelectUtils.test.tsx @@ -0,0 +1,123 @@ +import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types'; +import { renderHook } from '@testing-library/react'; + +import { + useFilteredSortedSourceItems, + useSourceKindMap, +} from '../sourceSelectUtils'; + +const makeSource = ( + id: string, + name: string, + kind: SourceKind, + overrides: Partial = {}, +): TSource => + ({ + id, + name, + kind, + connection: 'conn-a', + ...overrides, + }) as unknown as TSource; + +describe('useSourceKindMap', () => { + it('returns an empty map when sources is undefined', () => { + const { result } = renderHook(() => useSourceKindMap(undefined)); + expect(result.current.size).toBe(0); + }); + + it('builds an id -> kind lookup from the provided sources', () => { + const sources = [ + makeSource('a', 'Logs', SourceKind.Log), + makeSource('b', 'Traces', SourceKind.Trace), + makeSource('c', 'Metrics', SourceKind.Metric), + ]; + const { result } = renderHook(() => useSourceKindMap(sources)); + expect(result.current.get('a')).toBe(SourceKind.Log); + expect(result.current.get('b')).toBe(SourceKind.Trace); + expect(result.current.get('c')).toBe(SourceKind.Metric); + expect(result.current.get('missing')).toBeUndefined(); + }); + + it('returns a stable reference when the sources reference is unchanged', () => { + const sources = [makeSource('a', 'Logs', SourceKind.Log)]; + const { result, rerender } = renderHook( + ({ s }: { s: TSource[] }) => useSourceKindMap(s), + { initialProps: { s: sources } }, + ); + const first = result.current; + rerender({ s: sources }); + expect(result.current).toBe(first); + }); +}); + +describe('useFilteredSortedSourceItems', () => { + const sources: TSource[] = [ + makeSource('z', 'Zebra Logs', SourceKind.Log), + makeSource('a', 'Apple Traces', SourceKind.Trace, { connection: 'conn-b' }), + makeSource('m', 'Mango Metrics', SourceKind.Metric), + makeSource('d', 'Disabled', SourceKind.Log, { + disabled: true, + } as Partial), + ]; + + it('returns [] when sources is undefined', () => { + const { result } = renderHook(() => + useFilteredSortedSourceItems({ sources: undefined }), + ); + expect(result.current).toEqual([]); + }); + + it('omits disabled sources and sorts by label ascending', () => { + const { result } = renderHook(() => + useFilteredSortedSourceItems({ sources }), + ); + expect(result.current).toEqual([ + { value: 'a', label: 'Apple Traces' }, + { value: 'm', label: 'Mango Metrics' }, + { value: 'z', label: 'Zebra Logs' }, + ]); + }); + + it('filters by allowedSourceKinds', () => { + const { result } = renderHook(() => + useFilteredSortedSourceItems({ + sources, + allowedSourceKinds: [SourceKind.Trace, SourceKind.Metric], + }), + ); + expect(result.current.map(i => i.value)).toEqual(['a', 'm']); + }); + + it('filters by connectionId', () => { + const { result } = renderHook(() => + useFilteredSortedSourceItems({ sources, connectionId: 'conn-b' }), + ); + expect(result.current.map(i => i.value)).toEqual(['a']); + }); + + it('combines allowedSourceKinds + connectionId (AND semantics)', () => { + const { result } = renderHook(() => + useFilteredSortedSourceItems({ + sources, + allowedSourceKinds: [SourceKind.Log], + connectionId: 'conn-b', + }), + ); + expect(result.current).toEqual([]); + }); + + it('returns a stable reference when inputs are unchanged', () => { + const { result, rerender } = renderHook( + (props: { + sources: TSource[]; + allowedSourceKinds?: SourceKind[]; + connectionId?: string; + }) => useFilteredSortedSourceItems(props), + { initialProps: { sources } }, + ); + const first = result.current; + rerender({ sources }); + expect(result.current).toBe(first); + }); +}); diff --git a/packages/app/src/components/sourceSelectUtils.tsx b/packages/app/src/components/sourceSelectUtils.tsx new file mode 100644 index 0000000000..0333a4c4c0 --- /dev/null +++ b/packages/app/src/components/sourceSelectUtils.tsx @@ -0,0 +1,49 @@ +import { useMemo } from 'react'; +import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types'; +import { + IconChartLine, + IconConnection, + IconDeviceLaptop, + IconLogs, +} from '@tabler/icons-react'; + +export const SOURCE_KIND_ICONS: Record = { + [SourceKind.Log]: , + [SourceKind.Trace]: , + [SourceKind.Session]: , + [SourceKind.Metric]: , +}; + +export function useSourceKindMap(sources: TSource[] | undefined) { + return useMemo(() => { + const map = new Map(); + sources?.forEach(s => map.set(s.id, s.kind)); + return map; + }, [sources]); +} + +export function useFilteredSortedSourceItems({ + sources, + allowedSourceKinds, + connectionId, +}: { + sources: TSource[] | undefined; + allowedSourceKinds?: SourceKind[]; + connectionId?: string; +}) { + return useMemo( + () => + ( + sources + ?.filter( + source => + (!allowedSourceKinds || + allowedSourceKinds.includes(source.kind)) && + (!connectionId || source.connection === connectionId) && + !source.disabled, + ) + .map(s => ({ value: s.id, label: s.name })) ?? [] + ).sort((a, b) => a.label.localeCompare(b.label)), + [sources, allowedSourceKinds, connectionId], + ); +} diff --git a/packages/app/src/hooks/__tests__/usePresetDashboardFilters.test.tsx b/packages/app/src/hooks/__tests__/usePresetDashboardFilters.test.tsx index 377cb45f16..bc4b6afb6e 100644 --- a/packages/app/src/hooks/__tests__/usePresetDashboardFilters.test.tsx +++ b/packages/app/src/hooks/__tests__/usePresetDashboardFilters.test.tsx @@ -110,6 +110,7 @@ describe('usePresetDashboardFilters', () => { filterQueries: mockFilterQueries, setFilterQueries: jest.fn(), ignoredFilterExpressions: [], + getFilterQueriesForSource: jest.fn().mockReturnValue(mockFilterQueries), }); }); diff --git a/packages/app/src/hooks/useDashboardFilters.tsx b/packages/app/src/hooks/useDashboardFilters.tsx index 7aa439465e..093cf84254 100644 --- a/packages/app/src/hooks/useDashboardFilters.tsx +++ b/packages/app/src/hooks/useDashboardFilters.tsx @@ -44,6 +44,7 @@ const useDashboardFilters = (filters: DashboardFilter[]) => { valuesForExistingFilters, queriesForExistingFilters, ignoredExpressions, + filtersByExpression, } = useMemo(() => { const { filters: parsedFilters } = parseQuery(filterQueries ?? []); const valuesForExistingFilters: FilterState = {}; @@ -73,13 +74,55 @@ const useDashboardFilters = (filters: DashboardFilter[]) => { } } + // Multiple filter definitions may share the same expression but each + // declare a different `appliesToSourceIds` scope. + const filtersByExpression = new Map(); + for (const f of filters) { + const existing = filtersByExpression.get(f.expression); + if (existing) { + existing.push(f); + } else { + filtersByExpression.set(f.expression, [f]); + } + } + return { valuesForExistingFilters, queriesForExistingFilters: filtersToQuery(valuesForExistingFilters), ignoredExpressions: ignored, + filtersByExpression, }; }, [filterQueries, filters]); + // Return only the filter queries that should be applied to a tile whose + // source is `sourceId`. When multiple filter definitions share the same + // expression, their scopes are unioned: the filter value applies if ANY + // sibling is unscoped or includes `sourceId`. A filter with no + // `appliesToSourceIds` (or an empty array) is treated as "applies to all". + // If `sourceId` is undefined (e.g. a RawSQL tile with no resolvable + // source), scoped filters are skipped and only unscoped filters are + // returned. + const getFilterQueriesForSource = useCallback( + (sourceId: string | undefined): Filter[] => { + const scoped: FilterState = {}; + for (const [expression, state] of Object.entries( + valuesForExistingFilters, + )) { + const definitions = filtersByExpression.get(expression) ?? []; + const applies = definitions.some(def => { + const appliesTo = def.appliesToSourceIds; + if (!appliesTo || appliesTo.length === 0) return true; + return !!sourceId && appliesTo.includes(sourceId); + }); + if (applies) { + scoped[expression] = state; + } + } + return filtersToQuery(scoped); + }, + [valuesForExistingFilters, filtersByExpression], + ); + // Migrate legacy SQL filters in the URL to Lucene on load const hasMigratedRef = useRef(false); useEffect(() => { @@ -105,6 +148,13 @@ const useDashboardFilters = (filters: DashboardFilter[]) => { * be silently dropped. Callers can surface a warning. */ ignoredFilterExpressions: ignoredExpressions, + /** + * Returns the subset of filter queries that should apply to a tile whose + * source is `sourceId`. Filters with no `appliesToSourceIds` apply to all + * tiles. Filters with `appliesToSourceIds` defined apply only to tiles + * whose source ID is in the list. + */ + getFilterQueriesForSource, }; }; diff --git a/packages/app/tests/e2e/features/dashboard-template-import.spec.ts b/packages/app/tests/e2e/features/dashboard-template-import.spec.ts index 850df7583b..f8099abaa8 100644 --- a/packages/app/tests/e2e/features/dashboard-template-import.spec.ts +++ b/packages/app/tests/e2e/features/dashboard-template-import.spec.ts @@ -510,6 +510,95 @@ test.describe('Dashboard Template Import', { tag: ['@dashboard'] }, () => { }, ); + test( + 'should map filter appliesToSourceIds to selected source ids on import', + { tag: '@full-stack' }, + async ({ page }) => { + const ts = Date.now(); + const dashboardName = `E2E Import Filter Applies-To ${ts}`; + const tileName = `Logs Number ${ts}`; + // Use a name that matches the workspace logs source so the filter's + // applies-to entry auto-resolves on the import screen. + const template = { + version: '0.1.0', + name: dashboardName, + tiles: [ + { + id: 'tile-1', + x: 0, + y: 0, + w: 6, + h: 4, + config: { + name: tileName, + source: DEFAULT_LOGS_SOURCE_NAME, + displayType: 'number', + granularity: 'auto', + select: [{ aggFn: 'count', valueExpression: '' }], + where: '', + whereLanguage: 'sql', + }, + }, + ], + filters: [ + { + id: 'filter-1', + type: 'QUERY_EXPRESSION', + name: 'Service', + expression: 'ServiceName', + source: DEFAULT_LOGS_SOURCE_NAME, + whereLanguage: 'sql', + appliesToSourceIds: [DEFAULT_LOGS_SOURCE_NAME], + }, + ], + }; + + const templatePath = writeTempTemplate(template); + + await test.step('Upload the template and wait for mapping step', async () => { + await dashboardImportPage.gotoImport(); + await dashboardImportPage.uploadTemplateFile(templatePath); + await expect(dashboardImportPage.mappingStepHeading).toBeVisible(); + await expect(dashboardImportPage.dashboardNameInput).toHaveValue( + dashboardName, + ); + }); + + await test.step('Verify the applies-to row is rendered alongside the filter source row', async () => { + await expect( + dashboardImportPage.getMappingRow('Service (Filter)', 'Data Source'), + ).toBeVisible(); + await expect( + dashboardImportPage.getMappingRow( + 'Service (Filter)', + 'Applies to Sources', + ), + ).toBeVisible(); + }); + + await test.step('Finish import (auto-resolved mappings)', async () => { + await dashboardImportPage.finishImportButton.click(); + await expect( + dashboardImportPage.getImportSuccessNotification(), + ).toBeVisible(); + await page.waitForURL(/\/dashboards\/[a-f0-9]{24}/); + }); + + await test.step('Verify the saved filter resolves appliesToSourceIds to the matching workspace source id', async () => { + const logSources = await getSources(page, 'log'); + const logsSourceId = logSources.find( + (s: { name: string }) => s.name === DEFAULT_LOGS_SOURCE_NAME, + ).id; + + const dashboardId = dashboardPage.getCurrentDashboardId(); + const dashboard = await fetchDashboardById(page, dashboardId); + expect(dashboard.filters).toHaveLength(1); + expect(dashboard.filters[0].source).toBe(logsSourceId); + expect(dashboard.filters[0].appliesToSourceIds).toEqual([logsSourceId]); + }); + }, + ); + test( 'should drop an unmapped onClick from the imported tile', { tag: '@full-stack' }, diff --git a/packages/app/tests/e2e/features/dashboard.spec.ts b/packages/app/tests/e2e/features/dashboard.spec.ts index f6d923a039..50945f86b8 100644 --- a/packages/app/tests/e2e/features/dashboard.spec.ts +++ b/packages/app/tests/e2e/features/dashboard.spec.ts @@ -8,6 +8,7 @@ import { expect, test } from '../utils/base-test'; import { DEFAULT_LOGS_SOURCE_NAME, DEFAULT_METRICS_SOURCE_NAME, + DEFAULT_TRACES_SOURCE_NAME, } from '../utils/constants'; test.describe('Dashboard', { tag: ['@dashboard'] }, () => { @@ -499,6 +500,112 @@ test.describe('Dashboard', { tag: ['@dashboard'] }, () => { }); }); + test( + 'should scope a filter to a specific source via "Applies to sources"', + {}, + async () => { + test.setTimeout(45000); + + await test.step('Create new dashboard', async () => { + await expect(dashboardPage.createButton).toBeVisible(); + await dashboardPage.createNewDashboard(); + }); + + await test.step('Add a logs table tile', async () => { + await dashboardPage.addTile(); + await dashboardPage.chartEditor.createTable({ + chartName: 'Logs Table', + sourceName: DEFAULT_LOGS_SOURCE_NAME, + groupBy: 'ServiceName', + }); + }); + + await test.step('Add a traces table tile', async () => { + await dashboardPage.addTile(); + await dashboardPage.chartEditor.createTable({ + chartName: 'Traces Table', + sourceName: DEFAULT_TRACES_SOURCE_NAME, + groupBy: 'SpanName', + }); + }); + + await test.step('Add a Span filter scoped to the trace source', async () => { + await dashboardPage.openEditFiltersModal(); + await expect(dashboardPage.emptyFiltersList).toBeVisible(); + await dashboardPage.addFilterToDashboard( + 'SpanName', + DEFAULT_TRACES_SOURCE_NAME, + 'SpanName', + undefined, + [DEFAULT_TRACES_SOURCE_NAME], + ); + await expect( + dashboardPage.getFilterItemByName('SpanName'), // Not a valid column for the logs table + ).toBeVisible(); + await dashboardPage.closeFiltersModal(); + }); + + await test.step('Filter label tooltip shows the scoped count', async () => { + const label = dashboardPage.getFilterLabel('SpanName'); + await expect(label).toBeVisible(); + await label.hover(); + await expect( + dashboardPage.page.getByText('Applies to 1 source'), + ).toBeVisible(); + }); + + await test.step('Selecting a value filters only the traces tile', async () => { + await dashboardPage.clickFilterOption('SpanName', 'GET /api/logs'); + + // Traces tile: only the "GET /api/logs" span should remain. + const tracesAccountingCell = dashboardPage.page.getByTitle( + 'GET /api/logs', + { + exact: true, + }, + ); + await expect(tracesAccountingCell).toBeVisible(); + + // The logs tile must still render its rows — the filter scope + // excluded it, so the dropdown value should not have affected it. + // (Even if the traces source has no `SpanName` column, the tile + // must not be broken by an inapplicable WHERE.) + const logsTile = dashboardPage.getTile(0); + await expect(logsTile.locator('table tbody tr').first()).toBeVisible({ + timeout: 15000, + }); + }); + }, + ); + + test( + 'filter label tooltip shows "all sources" when scope is empty', + {}, + async () => { + await dashboardPage.createNewDashboard(); + await dashboardPage.addTile(); + await dashboardPage.chartEditor.createTable({ + chartName: 'Logs Table', + sourceName: DEFAULT_LOGS_SOURCE_NAME, + groupBy: 'ServiceName', + }); + + await dashboardPage.openEditFiltersModal(); + await dashboardPage.addFilterToDashboard( + 'Service', + DEFAULT_LOGS_SOURCE_NAME, + 'ServiceName', + ); + await dashboardPage.closeFiltersModal(); + + const label = dashboardPage.getFilterLabel('Service'); + await label.hover(); + await expect( + dashboardPage.page.getByText('Applies to all sources'), + ).toBeVisible(); + }, + ); + test('should save and restore query and filter values', {}, async () => { const testQuery = 'SeverityText:error'; let dashboardUrl: string; diff --git a/packages/app/tests/e2e/page-objects/DashboardImportPage.ts b/packages/app/tests/e2e/page-objects/DashboardImportPage.ts index ee67772d3e..8a084807b9 100644 --- a/packages/app/tests/e2e/page-objects/DashboardImportPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardImportPage.ts @@ -12,7 +12,8 @@ export type MappingType = | 'Data Source' | 'Data Connection' | 'On Click - Search Source' - | 'On Click - Dashboard'; + | 'On Click - Dashboard' + | 'Applies to Sources'; export class DashboardImportPage { readonly page: Page; @@ -123,11 +124,17 @@ export class DashboardImportPage { ? 'Select a connection' : mappingType === 'On Click - Dashboard' ? 'Select a dashboard' - : 'Select a source'; + : mappingType === 'Applies to Sources' + ? 'Select sources' + : 'Select a source'; await row.getByPlaceholder(placeholder).click(); await this.page .getByRole('option', { name: optionName, exact: true }) .click(); + if (mappingType === 'Applies to Sources') { + // Multiselect — close the dropdown so it doesn't intercept future clicks. + await this.page.keyboard.press('Escape'); + } } getImportSuccessNotification() { diff --git a/packages/app/tests/e2e/page-objects/DashboardPage.ts b/packages/app/tests/e2e/page-objects/DashboardPage.ts index 7f962520ac..8d4ebc5845 100644 --- a/packages/app/tests/e2e/page-objects/DashboardPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardPage.ts @@ -432,6 +432,7 @@ export class DashboardPage { sourceName: string, expression: string, metricType?: string, + appliesToSourceNames?: string[], ) { const filterNameInput = this.page.getByTestId('filter-name-input'); await filterNameInput.fill(name); @@ -451,6 +452,20 @@ export class DashboardPage { .click(); } + if (appliesToSourceNames && appliesToSourceNames.length > 0) { + const appliesToSelector = this.page.getByTestId( + 'applies-to-source-selector', + ); + for (const appliesName of appliesToSourceNames) { + await appliesToSelector.click(); + await this.page + .getByRole('option', { name: appliesName, exact: true }) + .click(); + } + // Close the dropdown so the save button is clickable. + await this.page.keyboard.press('Escape'); + } + const saveFilterButton = this.page.getByTestId('save-filter-button'); await saveFilterButton.click(); } @@ -460,10 +475,21 @@ export class DashboardPage { sourceName: string, expression: string, metricType?: string, + appliesToSourceNames?: string[], ) { await this.addFiltersButton.click(); - await this.fillFilterForm(name, sourceName, expression, metricType); + await this.fillFilterForm( + name, + sourceName, + expression, + metricType, + appliesToSourceNames, + ); + } + + getFilterLabel(name: string) { + return this.page.getByTestId(`dashboard-filter-help-${name}`); } async deleteFilterFromDashboard(name: string) { diff --git a/packages/common-utils/src/__tests__/utils.test.ts b/packages/common-utils/src/__tests__/utils.test.ts index ff8d0a96a2..058d8405eb 100644 --- a/packages/common-utils/src/__tests__/utils.test.ts +++ b/packages/common-utils/src/__tests__/utils.test.ts @@ -604,6 +604,116 @@ describe('utils', () => { }); }); + it('should remap filter.appliesToSourceIds from IDs to names', () => { + const sources: TSource[] = [ + { + id: 'source1', + name: 'Logs', + connection: 'connection1', + kind: SourceKind.Log, + from: { databaseName: 'db1', tableName: 'logs_table' }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: '', + }, + { + id: 'source2', + name: 'Traces', + connection: 'connection1', + kind: SourceKind.Log, + from: { databaseName: 'db1', tableName: 'traces_table' }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: '', + }, + ]; + + const dashboard: z.infer = { + id: 'dashboard1', + name: 'Mixed-Source Dashboard', + tags: [], + tiles: [], + filters: [ + { + // Multi-source scope — every ID resolves; both become names. + id: 'filter-multi', + type: 'QUERY_EXPRESSION', + name: 'Service', + expression: 'ServiceName', + source: 'source1', + appliesToSourceIds: ['source1', 'source2'], + }, + { + // Mixed: one resolvable + one stale ID. Stale ID is dropped + // silently so the surviving names land in the template. + id: 'filter-partial', + type: 'QUERY_EXPRESSION', + name: 'Region', + expression: 'Region', + source: 'source1', + appliesToSourceIds: ['source1', 'deleted-source-id'], + }, + { + // Every ID is stale → the array would be empty, which would + // import as "no tiles match". Field is omitted instead so the + // template imports as broadcast-to-all (safer default). + id: 'filter-all-unresolved', + type: 'QUERY_EXPRESSION', + name: 'Cluster', + expression: 'Cluster', + source: 'source1', + appliesToSourceIds: ['deleted-source-id'], + }, + { + // Unscoped filter (field omitted in the source doc) must stay + // unscoped — no array gets materialized in the template. + id: 'filter-broadcast', + type: 'QUERY_EXPRESSION', + name: 'Env', + expression: 'Env', + source: 'source1', + }, + ], + }; + + const template = convertToDashboardTemplate(dashboard, sources); + + expect(template.filters).toEqual([ + { + id: 'filter-multi', + type: 'QUERY_EXPRESSION', + name: 'Service', + expression: 'ServiceName', + source: 'Logs', + appliesToSourceIds: ['Logs', 'Traces'], + }, + { + id: 'filter-partial', + type: 'QUERY_EXPRESSION', + name: 'Region', + expression: 'Region', + source: 'Logs', + appliesToSourceIds: ['Logs'], + }, + { + id: 'filter-all-unresolved', + type: 'QUERY_EXPRESSION', + name: 'Cluster', + expression: 'Cluster', + source: 'Logs', + // appliesToSourceIds intentionally absent — empty result is + // collapsed to undefined so the import treats it as broadcast. + }, + { + id: 'filter-broadcast', + type: 'QUERY_EXPRESSION', + name: 'Env', + expression: 'Env', + source: 'Logs', + }, + ]); + expect(template.filters?.[2].appliesToSourceIds).toBeUndefined(); + expect(template.filters?.[3].appliesToSourceIds).toBeUndefined(); + }); + it('should convert a dashboard without filters to a dashboard template', () => { const dashboard: z.infer = { id: 'dashboard1', diff --git a/packages/common-utils/src/core/utils.ts b/packages/common-utils/src/core/utils.ts index f2185904b3..68f44db199 100644 --- a/packages/common-utils/src/core/utils.ts +++ b/packages/common-utils/src/core/utils.ts @@ -534,6 +534,14 @@ export function convertToDashboardTemplate( // Extract name from source or default to '' if not found filter.source = sources.find(source => source.id === input.source)?.name ?? ''; + if (input.appliesToSourceIds?.length) { + const remapped = input.appliesToSourceIds + .map(id => sources.find(source => source.id === id)?.name) + .filter((name): name is string => !!name && name.length > 0); + filter.appliesToSourceIds = remapped.length > 0 ? remapped : undefined; + } else { + filter.appliesToSourceIds = undefined; + } return filter; }; diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 5f202483e3..d684f85fe2 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -1058,6 +1058,9 @@ export const DashboardFilterSchema = z.object({ sourceMetricType: z.nativeEnum(MetricsDataType).optional(), where: z.string().optional(), whereLanguage: SearchConditionLanguageSchema, + // Sources this filter applies to. Undefined / missing means the filter + // applies to all tiles. + appliesToSourceIds: z.array(z.string().min(1)).optional(), }); export type DashboardFilter = z.infer;