mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
fix: Fix dashboard error when using filter on non-String column (#1650)
Closes HDX-3266 # Summary This PR fixes an error in dashboards that occurred when using a Dashboard filter on a non-String column. ## Demo Such filters work now: <img width="1074" height="523" alt="Screenshot 2026-01-23 at 3 44 19 PM" src="https://github.com/user-attachments/assets/2e026590-f4cb-44a2-8677-37a80b65886b" /> Co-authored-by: Karl Power <85935352+karl-power@users.noreply.github.com>
This commit is contained in:
parent
866de771b4
commit
0dd585437d
8 changed files with 87 additions and 33 deletions
5
.changeset/sweet-dryers-sleep.md
Normal file
5
.changeset/sweet-dryers-sleep.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
fix: Fix dashboard error when using filter on non-String column
|
||||
|
|
@ -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 (
|
||||
<Group mt="sm">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Metadata>;
|
||||
|
|
@ -96,12 +96,13 @@ describe('useDashboardFilterKeyValues', () => {
|
|||
},
|
||||
];
|
||||
|
||||
const mockKeyValues: Record<string, string[] | undefined> = {
|
||||
const mockKeyValues: Record<string, string[] | number[] | undefined> = {
|
||||
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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -850,7 +850,7 @@ export class Metadata {
|
|||
source:
|
||||
| Omit<TSource, 'connection'> /* 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<any>());
|
||||
.then(res => res.json<Record<string, string[] | number[]>>());
|
||||
|
||||
// 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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue