From 20e69f3d07f829db9f207c3e5e90bbb48dfaacb9 Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Mon, 23 Mar 2026 12:28:39 -0400 Subject: [PATCH] feat: Add conditions to Dashboard filters; Support filter multi-select --- .changeset/lemon-gifts-suffer.md | 7 + packages/api/openapi.json | 15 ++ .../api/src/models/presetDashboardFilter.ts | 6 + .../external-api/__tests__/dashboards.test.ts | 36 ++- .../src/routers/external-api/v2/dashboards.ts | 10 + packages/app/src/DashboardFilters.tsx | 36 ++- packages/app/src/DashboardFiltersModal.tsx | 48 +++- .../useDashboardFilterValues.test.tsx | 92 ++++++-- .../__tests__/useDashboardFilters.test.tsx | 214 ++++++++++-------- .../src/hooks/useDashboardFilterValues.tsx | 109 ++++++--- .../app/src/hooks/useDashboardFilters.tsx | 6 +- .../app/tests/e2e/features/dashboard.spec.ts | 8 +- packages/common-utils/src/types.ts | 2 + 13 files changed, 401 insertions(+), 188 deletions(-) create mode 100644 .changeset/lemon-gifts-suffer.md diff --git a/.changeset/lemon-gifts-suffer.md b/.changeset/lemon-gifts-suffer.md new file mode 100644 index 0000000000..3fae936b39 --- /dev/null +++ b/.changeset/lemon-gifts-suffer.md @@ -0,0 +1,7 @@ +--- +"@hyperdx/common-utils": patch +"@hyperdx/api": patch +"@hyperdx/app": patch +--- + +feat: Add conditions to Dashboard filters; Support filter multi-select diff --git a/packages/api/openapi.json b/packages/api/openapi.json index ee6bdab60b..2788112dd8 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -1838,6 +1838,21 @@ ], "description": "Metric type when source is metrics", "example": "gauge" + }, + "where": { + "type": "string", + "description": "Optional WHERE condition to scope which rows this filter key reads values from", + "example": "ServiceName:api" + }, + "whereLanguage": { + "type": "string", + "enum": [ + "sql", + "lucene" + ], + "description": "Language of the where condition", + "default": "sql", + "example": "lucene" } } }, diff --git a/packages/api/src/models/presetDashboardFilter.ts b/packages/api/src/models/presetDashboardFilter.ts index 97fd712447..6e6860371e 100644 --- a/packages/api/src/models/presetDashboardFilter.ts +++ b/packages/api/src/models/presetDashboardFilter.ts @@ -42,6 +42,12 @@ const PresetDashboardFilterSchema = new Schema( }, type: { type: String, required: true }, expression: { type: String, required: true }, + where: { type: String, required: false }, + whereLanguage: { + type: String, + required: false, + enum: ['sql', 'lucene'], + }, }, { timestamps: true, 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 9b558c9f82..a5faa86585 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.test.ts @@ -848,6 +848,14 @@ describe('External API v2 Dashboards - old format', () => { sourceId: traceSource._id.toString(), sourceMetricType: undefined, }, + { + type: 'QUERY_EXPRESSION' as const, + name: 'Region (Filtered)', + expression: 'region', + sourceId: traceSource._id.toString(), + where: "environment = 'production'", + whereLanguage: 'sql' as const, + }, ], }; @@ -855,7 +863,7 @@ describe('External API v2 Dashboards - old format', () => { .send(dashboardPayload) .expect(200); - expect(response.body.data.filters).toHaveLength(2); + expect(response.body.data.filters).toHaveLength(3); response.body.data.filters.forEach( (f: { id: string; @@ -879,12 +887,18 @@ describe('External API v2 Dashboards - old format', () => { expect(response.body.data.filters[0].expression).toBe('environment'); 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[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'); const getResponse = await authRequest( 'get', `${BASE_URL}/${response.body.data.id}`, ).expect(200); - expect(getResponse.body.data.filters).toHaveLength(2); + expect(getResponse.body.data.filters).toHaveLength(3); expect(getResponse.body.data.filters).toEqual(response.body.data.filters); }); @@ -2519,6 +2533,14 @@ describe('External API v2 Dashboards - new format', () => { sourceId: traceSource._id.toString(), sourceMetricType: undefined, }, + { + type: 'QUERY_EXPRESSION' as const, + name: 'Region (Filtered)', + expression: 'region', + sourceId: traceSource._id.toString(), + where: "environment = 'production'", + whereLanguage: 'sql' as const, + }, ], }; @@ -2526,7 +2548,7 @@ describe('External API v2 Dashboards - new format', () => { .send(dashboardPayload) .expect(200); - expect(response.body.data.filters).toHaveLength(2); + expect(response.body.data.filters).toHaveLength(3); response.body.data.filters.forEach( (f: { id: string; @@ -2550,12 +2572,18 @@ describe('External API v2 Dashboards - new format', () => { expect(response.body.data.filters[0].expression).toBe('environment'); 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[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'); const getResponse = await authRequest( 'get', `${BASE_URL}/${response.body.data.id}`, ).expect(200); - expect(getResponse.body.data.filters).toHaveLength(2); + expect(getResponse.body.data.filters).toHaveLength(3); expect(getResponse.body.data.filters).toEqual(response.body.data.filters); }); diff --git a/packages/api/src/routers/external-api/v2/dashboards.ts b/packages/api/src/routers/external-api/v2/dashboards.ts index 5d6c34c616..6774d17cee 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -1156,6 +1156,16 @@ const updateDashboardBodySchema = buildDashboardBodySchema( * enum: [sum, gauge, histogram, summary, exponential histogram] * description: Metric type when source is metrics * example: "gauge" + * where: + * type: string + * description: Optional WHERE condition to scope which rows this filter key reads values from + * example: "ServiceName:api" + * whereLanguage: + * type: string + * enum: [sql, lucene] + * description: Language of the where condition + * default: "sql" + * example: "lucene" * * Filter: * allOf: diff --git a/packages/app/src/DashboardFilters.tsx b/packages/app/src/DashboardFilters.tsx index 203bd72c6d..331984c36f 100644 --- a/packages/app/src/DashboardFilters.tsx +++ b/packages/app/src/DashboardFilters.tsx @@ -1,5 +1,5 @@ import { DashboardFilter } from '@hyperdx/common-utils/dist/types'; -import { Group, Select } from '@mantine/core'; +import { Group, MultiSelect } from '@mantine/core'; import { IconRefresh } from '@tabler/icons-react'; import { useDashboardFilterValues } from './hooks/useDashboardFilterValues'; @@ -7,8 +7,8 @@ import { FilterState } from './searchFilters'; interface DashboardFilterSelectProps { filter: DashboardFilter; - onChange: (value: string | null) => void; - value?: string | null; + onChange: (values: string[]) => void; + value: string[]; values?: string[]; isLoading?: boolean; } @@ -26,18 +26,17 @@ const DashboardFilterSelect = ({ })); return ( -