diff --git a/.changeset/sweet-dryers-sleep.md b/.changeset/sweet-dryers-sleep.md new file mode 100644 index 00000000..4662223b --- /dev/null +++ b/.changeset/sweet-dryers-sleep.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +fix: Fix dashboard error when using filter on non-String column diff --git a/packages/app/src/DashboardFilters.tsx b/packages/app/src/DashboardFilters.tsx index bf188110..203bd72c 100644 --- a/packages/app/src/DashboardFilters.tsx +++ b/packages/app/src/DashboardFilters.tsx @@ -2,7 +2,7 @@ import { DashboardFilter } from '@hyperdx/common-utils/dist/types'; import { Group, Select } from '@mantine/core'; import { IconRefresh } from '@tabler/icons-react'; -import { useDashboardFilterKeyValues } from './hooks/useDashboardFilterValues'; +import { useDashboardFilterValues } from './hooks/useDashboardFilterValues'; import { FilterState } from './searchFilters'; interface DashboardFilterSelectProps { @@ -58,8 +58,10 @@ const DashboardFilters = ({ filterValues, onSetFilterValue, }: DashboardFilterProps) => { - const { data: filterValuesBySource, isFetching } = - useDashboardFilterKeyValues({ filters, dateRange }); + const { data: filterValuesBySource, isFetching } = useDashboardFilterValues({ + filters, + dateRange, + }); return ( diff --git a/packages/app/src/components/DBSearchPageFilters.tsx b/packages/app/src/components/DBSearchPageFilters.tsx index 853fdb3c..a8742607 100644 --- a/packages/app/src/components/DBSearchPageFilters.tsx +++ b/packages/app/src/components/DBSearchPageFilters.tsx @@ -1007,7 +1007,7 @@ const DBSearchPageFiltersComponent = ({ disableRowLimit: true, source, }); - const newValues = newKeyVals[0].value; + const newValues = newKeyVals[0].value?.map(val => val.toString()) ?? []; if (newValues.length > 0) { setExtraFacets(prev => ({ ...prev, diff --git a/packages/app/src/hooks/__tests__/useDashboardFilterValues.test.tsx b/packages/app/src/hooks/__tests__/useDashboardFilterValues.test.tsx index a815aab9..18e92c86 100644 --- a/packages/app/src/hooks/__tests__/useDashboardFilterValues.test.tsx +++ b/packages/app/src/hooks/__tests__/useDashboardFilterValues.test.tsx @@ -12,7 +12,7 @@ import { renderHook, waitFor } from '@testing-library/react'; import * as sourceModule from '@/source'; -import { useDashboardFilterKeyValues } from '../useDashboardFilterValues'; +import { useDashboardFilterValues } from '../useDashboardFilterValues'; import * as useMetadataModule from '../useMetadata'; // Mock modules @@ -26,7 +26,7 @@ jest.mock('@hyperdx/common-utils/dist/core/materializedViews', () => ({ ]), })); -describe('useDashboardFilterKeyValues', () => { +describe('useDashboardFilterValues', () => { let queryClient: QueryClient; let wrapper: React.ComponentType<{ children: any }>; let mockMetadata: jest.Mocked; @@ -96,12 +96,13 @@ describe('useDashboardFilterKeyValues', () => { }, ]; - const mockKeyValues: Record = { + const mockKeyValues: Record = { environment: ['production', 'staging', 'development'], 'service.name': ['frontend', 'backend', 'database'], MetricName: ['CPU_Usage', 'Memory_Usage'], status: ['200', '404', '500'], log_level: ['info', 'error'], + SeverityNumber: [1, 2], }; const mockDateRange: [Date, Date] = [ @@ -121,7 +122,7 @@ describe('useDashboardFilterKeyValues', () => { return Promise.resolve( keys.map(key => ({ key, - value: mockKeyValues[key] ?? [], + value: (mockKeyValues[key] as string[]) ?? [], })), ); }); @@ -150,6 +151,47 @@ describe('useDashboardFilterKeyValues', () => { jest.restoreAllMocks(); }); + it('should convert non-string key values to strings', async () => { + // Arrange + jest.spyOn(sourceModule, 'useSources').mockReturnValue({ + data: mockSources, + isLoading: false, + } as any); + + // Act + const { result } = renderHook( + () => + useDashboardFilterValues({ + filters: [ + { + id: 'filterSevNumber', + type: 'QUERY_EXPRESSION', + name: 'SeverityNumber', + expression: 'SeverityNumber', + source: 'logs-source', + }, + ], + dateRange: mockDateRange, + }), + { wrapper }, + ); + + // Assert + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.data).toEqual( + new Map([ + [ + 'SeverityNumber', + { + values: ['1', '2'], + isLoading: false, + }, + ], + ]), + ); + }); + it('should fetch key values for filters grouped by source', async () => { // Arrange jest.spyOn(sourceModule, 'useSources').mockReturnValue({ @@ -160,7 +202,7 @@ describe('useDashboardFilterKeyValues', () => { // Act const { result } = renderHook( () => - useDashboardFilterKeyValues({ + useDashboardFilterValues({ filters: mockFilters, dateRange: mockDateRange, }), @@ -285,7 +327,7 @@ describe('useDashboardFilterKeyValues', () => { // Act const { result } = renderHook( () => - useDashboardFilterKeyValues({ + useDashboardFilterValues({ filters: sameSourceFilters, dateRange: mockDateRange, }), @@ -313,7 +355,7 @@ describe('useDashboardFilterKeyValues', () => { // Act const { result } = renderHook( () => - useDashboardFilterKeyValues({ + useDashboardFilterValues({ filters: [], dateRange: mockDateRange, }), @@ -335,7 +377,7 @@ describe('useDashboardFilterKeyValues', () => { // Act renderHook( () => - useDashboardFilterKeyValues({ + useDashboardFilterValues({ filters: mockFilters, dateRange: mockDateRange, }), @@ -367,7 +409,7 @@ describe('useDashboardFilterKeyValues', () => { // Act const { result } = renderHook( () => - useDashboardFilterKeyValues({ + useDashboardFilterValues({ filters: filtersWithInvalidSource, dateRange: mockDateRange, }), @@ -395,7 +437,7 @@ describe('useDashboardFilterKeyValues', () => { // Act const { result } = renderHook( () => - useDashboardFilterKeyValues({ + useDashboardFilterValues({ filters: mockFilters, dateRange: mockDateRange, }), @@ -418,7 +460,7 @@ describe('useDashboardFilterKeyValues', () => { // Act const { result } = renderHook( () => - useDashboardFilterKeyValues({ + useDashboardFilterValues({ filters: [mockFilters[0]], // Only first filter (logs-source) dateRange: mockDateRange, }), @@ -469,7 +511,7 @@ describe('useDashboardFilterKeyValues', () => { // Act const { result, rerender } = renderHook( ({ filters, dateRange }) => - useDashboardFilterKeyValues({ + useDashboardFilterValues({ filters, dateRange, }), @@ -535,7 +577,7 @@ describe('useDashboardFilterKeyValues', () => { // Act const { result } = renderHook( () => - useDashboardFilterKeyValues({ + useDashboardFilterValues({ filters: multiFilters, dateRange: mockDateRange, }), @@ -623,7 +665,7 @@ describe('useDashboardFilterKeyValues', () => { // Act const { result } = renderHook( () => - useDashboardFilterKeyValues({ + useDashboardFilterValues({ filters: filtersForSameSource, dateRange: mockDateRange, }), @@ -705,7 +747,7 @@ describe('useDashboardFilterKeyValues', () => { // Act const { result } = renderHook( () => - useDashboardFilterKeyValues({ + useDashboardFilterValues({ filters: mockFilters.slice(0, 2), // Only first two filters dateRange: mockDateRange, }), @@ -766,7 +808,7 @@ describe('useDashboardFilterKeyValues', () => { // Act const { result } = renderHook( () => - useDashboardFilterKeyValues({ + useDashboardFilterValues({ filters: mockFilters.slice(0, 2), // Only first two filters dateRange: mockDateRange, }), @@ -830,7 +872,7 @@ describe('useDashboardFilterKeyValues', () => { // Act - Initial render const { result, rerender } = renderHook( ({ filters, dateRange }) => - useDashboardFilterKeyValues({ + useDashboardFilterValues({ filters, dateRange, }), diff --git a/packages/app/src/hooks/useDashboardFilterValues.tsx b/packages/app/src/hooks/useDashboardFilterValues.tsx index 4b8cc0be..3464f542 100644 --- a/packages/app/src/hooks/useDashboardFilterValues.tsx +++ b/packages/app/src/hooks/useDashboardFilterValues.tsx @@ -112,7 +112,7 @@ function useOptimizedKeyValuesCalls({ }; } -export function useDashboardFilterKeyValues({ +export function useDashboardFilterValues({ filters, dateRange, }: { @@ -185,7 +185,7 @@ export function useDashboardFilterKeyValues({ return data.map(({ key, value }) => [ key, { - values: value, + values: value.map(v => v.toString()), isLoading, }, ]); diff --git a/packages/common-utils/src/__tests__/metadata.int.test.ts b/packages/common-utils/src/__tests__/metadata.int.test.ts index 94c7892f..27d7db96 100644 --- a/packages/common-utils/src/__tests__/metadata.int.test.ts +++ b/packages/common-utils/src/__tests__/metadata.int.test.ts @@ -197,8 +197,9 @@ describe('Metadata Integration Tests', () => { expect(resultLimited[0].key).toBe('SeverityText'); expect(resultLimited[0].value).toHaveLength(2); expect( - resultLimited[0].value.every(v => - ['info', 'error', 'warning'].includes(v), + resultLimited[0].value.every( + v => + typeof v === 'string' && ['info', 'error', 'warning'].includes(v), ), ).toBeTruthy(); }); diff --git a/packages/common-utils/src/__tests__/metadata.test.ts b/packages/common-utils/src/__tests__/metadata.test.ts index 0a8e2e72..2c31d929 100644 --- a/packages/common-utils/src/__tests__/metadata.test.ts +++ b/packages/common-utils/src/__tests__/metadata.test.ts @@ -329,13 +329,13 @@ describe('Metadata', () => { ]); }); - it('should filter out falsy values from the response', async () => { + it('should filter out empty and nullish values from the response', async () => { (mockClickhouseClient.query as jest.Mock).mockResolvedValue({ json: () => Promise.resolve({ data: [ { - param0: ['value1', null, '', 'value2', undefined], + param0: ['value1', null, '', 'value2', undefined, 0, 10], }, ], }), @@ -348,7 +348,9 @@ describe('Metadata', () => { source, }); - expect(result).toEqual([{ key: 'column1', value: ['value1', 'value2'] }]); + expect(result).toEqual([ + { key: 'column1', value: ['value1', 'value2', 0, 10] }, + ]); }); it('should return an empty list when no keys are provided', async () => { diff --git a/packages/common-utils/src/core/metadata.ts b/packages/common-utils/src/core/metadata.ts index 6cb56014..56c4109f 100644 --- a/packages/common-utils/src/core/metadata.ts +++ b/packages/common-utils/src/core/metadata.ts @@ -850,7 +850,7 @@ export class Metadata { source: | Omit /* for overlap with ISource type */ | undefined; - }): Promise<{ key: string; value: string[] }[]> { + }): Promise<{ key: string; value: string[] | number[] }[]> { const cacheKeyConfig = { ...pick(chartConfig, [ 'connection', @@ -938,14 +938,16 @@ export class Metadata { : undefined, abort_signal: signal, }) - .then(res => res.json()); + .then(res => res.json>()); // TODO: Fix type issues mentioned in HDX-1548. value is not actually a // string[], sometimes it's { [key: string]: string; } return Object.entries(json?.data?.[0]).map(([key, value]) => ({ key: keys[parseInt(key.replace('param', ''))], - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- intentional, see HDX-1548 - value: (value as string[])?.filter(Boolean), // remove nulls + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + value: value?.filter(v => v != null && v !== '') as // remove nulls and empty strings + | string[] + | number[], })); }, ); @@ -965,7 +967,7 @@ export class Metadata { limit?: number; disableRowLimit?: boolean; signal?: AbortSignal; - }): Promise<{ key: string; value: string[] }[]> { + }): Promise<{ key: string; value: string[] | number[] }[]> { const cacheKeyConfig = { ...pick(chartConfig, [ 'connection',