Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/good-swans-hammer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hyperdx/common-utils": patch
---

Adding filters to dashboard exports. Implemented validation on dashboard imports to catch potential issues with generated JSON or manually tweaked JSON.
180 changes: 153 additions & 27 deletions packages/app/src/DBDashboardImportPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ import { Controller, useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { convertToDashboardDocument } from '@hyperdx/common-utils/dist/core/utils';
import {
type DashboardFilterQueryIssue,
type SavedFilterValueIssue,
type SavedQueryIssue,
validateDashboardFilterQueries,
validateSavedFilterValues,
validateSavedQuery,
} from '@hyperdx/common-utils/dist/filters';
import { isRawSqlSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
import {
type DashboardTemplate,
Expand Down Expand Up @@ -65,6 +73,48 @@ import { getDashboardTemplate } from './dashboardTemplates';
import { withAppNav } from './layout';
import { useSources } from './source';

/**
* A colored headline message with an optional collapsible "details" section,
* toggled by a chevron button. Shared by the import error and the various
* non-blocking import warnings so they stay visually consistent. The collapse
* state is local, so the details start collapsed each time it's rendered.
*/
function CollapsibleMessage({
color,
message,
details,
'data-testid': dataTestId,
}: {
color: string;
message: ReactNode;
details?: ReactNode;
'data-testid'?: string;
}) {
const [detailsOpen, { toggle: toggleDetails }] = useDisclosure(false);
return (
<div data-testid={dataTestId}>
<Text c={color}>{message}</Text>
{details != null && (
<>
<Button variant="transparent" onClick={toggleDetails} px={0}>
<Group c={color} gap={0} align="center">
<IconChevronRight
size="16px"
style={{
transition: 'transform 0.2s ease-in-out',
transform: detailsOpen ? 'rotate(90deg)' : 'rotate(0deg)',
}}
/>
{detailsOpen ? 'Hide Details' : 'Show Details'}
</Group>
</Button>
<Collapse expanded={detailsOpen}>{details}</Collapse>
</>
)}
</div>
);
}

function FileSelection({
onComplete,
}: {
Expand All @@ -79,14 +129,24 @@ function FileSelection({
message: string;
details?: ReactNode;
} | null>(null);
const [errorDetails, { toggle: toggleErrorDetails }] = useDisclosure(false);
const [filterWarnings, setFilterWarnings] = useState<SavedFilterValueIssue[]>(
[],
);
const [filterQueryWarnings, setFilterQueryWarnings] = useState<
DashboardFilterQueryIssue[]
>([]);
const [savedQueryWarning, setSavedQueryWarning] =
useState<SavedQueryIssue | null>(null);

const { control, handleSubmit } = useForm<FormValues>({
resolver: zodResolver(FormSchema),
});

const onSubmit = async ({ file }: FormValues) => {
setError(null);
setFilterWarnings([]);
setFilterQueryWarnings([]);
setSavedQueryWarning(null);
if (!file) return;

let data: unknown;
Expand Down Expand Up @@ -120,6 +180,32 @@ function FileSelection({
return;
}

// The schema only guarantees saved filter values are well-shaped, not that
// their condition strings parse. Surface a non-blocking warning so users
// who hand-edited or generated the file know a value won't actually filter,
// while still letting them proceed with the import.
setFilterWarnings(
validateSavedFilterValues(result.data.savedFilterValues ?? []),
);

// Import never runs the filters' values queries, so a filter whose `where`
// clause is malformed would otherwise only surface as a failed query after
// opening the dashboard. Statically validate the `where` clauses here so the
// problem is visible up front (non-blocking).
setFilterQueryWarnings(
validateDashboardFilterQueries(result.data.filters ?? []),
);
Comment thread
pulpdrew marked this conversation as resolved.
Resolved

// The dashboard's default saved query is also free-form text. A malformed
// one is lower impact (it surfaces in the dashboard search bar where it can
// be edited), but warn about it for consistency (non-blocking).
setSavedQueryWarning(
validateSavedQuery(
result.data.savedQuery,
result.data.savedQueryLanguage,
),
);

onComplete(result.data);
};

Expand Down Expand Up @@ -185,32 +271,72 @@ function FileSelection({
/>

{error && (
<div>
<Text c="red">{error.message}</Text>
{error.details != null && (
<>
<Button
variant="transparent"
onClick={toggleErrorDetails}
px={0}
>
<Group c="red" gap={0} align="center">
<IconChevronRight
size="16px"
style={{
transition: 'transform 0.2s ease-in-out',
transform: errorDetails
? 'rotate(90deg)'
: 'rotate(0deg)',
}}
/>
{errorDetails ? 'Hide Details' : 'Show Details'}
</Group>
</Button>
<Collapse expanded={errorDetails}>{error.details}</Collapse>
</>
)}
</div>
<CollapsibleMessage
color="red"
message={error.message}
details={error.details}
data-testid="import-error"
/>
)}

{filterWarnings.length > 0 && (
Comment thread
pulpdrew marked this conversation as resolved.
Resolved
<CollapsibleMessage
color="yellow"
data-testid="import-warning-saved-filter-values"
message={
filterWarnings.length === 1
? '1 saved filter value has an invalid condition and will not be applied.'
: `${filterWarnings.length} saved filter values have invalid conditions and will not be applied.`
}
details={
<Stack gap={0}>
{filterWarnings.map(warning => (
<Text
key={`${warning.index}:${warning.condition}`}
c="yellow"
>
Invalid {warning.language} condition: {warning.condition}
</Text>
))}
</Stack>
}
/>
)}

{filterQueryWarnings.length > 0 && (
<CollapsibleMessage
color="yellow"
data-testid="import-warning-filter-queries"
message={
filterQueryWarnings.length === 1
? "1 filter has an invalid query and won't load values."
: `${filterQueryWarnings.length} filters have invalid queries and won't load values.`
}
details={
<Stack gap={0}>
{filterQueryWarnings.map(warning => (
<Text key={warning.filterId} c="yellow">
{warning.filterName}: invalid {warning.language} query:{' '}
{warning.where}
</Text>
))}
</Stack>
}
/>
)}

{savedQueryWarning && (
<CollapsibleMessage
color="yellow"
data-testid="import-warning-saved-query"
message="The dashboard's saved query has an invalid condition and will not be applied."
details={
<Text c="yellow">
Invalid {savedQueryWarning.language} query:{' '}
{savedQueryWarning.query}
</Text>
}
/>
)}
</Stack>
</form>
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/DBDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2353,6 +2353,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
{hasTiles && (
<Menu.Item
leftSection={<IconDownload size={16} />}
data-testid="export-dashboard-menu-item"
onClick={() => {
if (!sources || !dashboard) {
notifications.show({
Expand Down
34 changes: 31 additions & 3 deletions packages/app/src/DashboardFilters.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { FilterState } from '@hyperdx/common-utils/dist/filters';
import { DashboardFilter } from '@hyperdx/common-utils/dist/types';
import { Group, Stack, Text, Tooltip } from '@mantine/core';
import { IconHelp, IconRefresh } from '@tabler/icons-react';
import { IconAlertTriangle, IconHelp, IconRefresh } from '@tabler/icons-react';

import { VirtualMultiSelect } from './components/VirtualMultiSelect/VirtualMultiSelect';
import { useDashboardFilterValues } from './hooks/useDashboardFilterValues';
Expand All @@ -12,6 +12,7 @@ interface DashboardFilterSelectProps {
value: string[];
values?: string[];
isLoading?: boolean;
isError?: boolean;
}

const getAppliesToTooltip = (filter: DashboardFilter) => {
Expand All @@ -26,6 +27,7 @@ const DashboardFilterSelect = ({
value,
values,
isLoading,
isError,
}: DashboardFilterSelectProps) => {
const sortedValues = values?.toSorted() || [];
const tooltipText = getAppliesToTooltip(filter);
Expand All @@ -43,12 +45,27 @@ const DashboardFilterSelect = ({
data-testid={`dashboard-filter-help-${filter.name}`}
/>
</Tooltip>
{isError && (
<Tooltip
label="Filter values query failed. The filter's query may be invalid."
withinPortal
>
<IconAlertTriangle
size={12}
color="var(--color-text-danger)"
data-testid={`dashboard-filter-error-${filter.name}`}
/>
</Tooltip>
)}
</Group>
<div style={{ width: 250 }}>
<VirtualMultiSelect
placeholder={value.length === 0 ? filter.name : undefined}
values={value}
data={sortedValues}
// Disable only while values are genuinely loading. A completed query
// that returned no rows (or failed) must stay interactive so the user
// can still clear/adjust the selection instead of being stuck.
disabled={isLoading}
onChange={onChange}
data-testid={`dashboard-filter-select-${filter.name}`}
Expand All @@ -71,7 +88,11 @@ const DashboardFilters = ({
filterValues,
onSetFilterValue,
}: DashboardFilterProps) => {
const { data: filterValuesById, isFetching } = useDashboardFilterValues({
const {
data: filterValuesById,
erroredFilterIds,
isFetching,
} = useDashboardFilterValues({
filters,
dateRange,
});
Expand All @@ -84,11 +105,18 @@ const DashboardFilters = ({
const selectedValues = included
? Array.from(included).map(v => v.toString())
: [];
// Fall back to the hook-level fetching state only until this filter's
// query has produced an entry; once it has (even with empty values),
// honor its own loading flag so a finished query never stays disabled.
const isLoadingValues = queriedFilterValues
? queriedFilterValues.isLoading
: isFetching;
return (
<DashboardFilterSelect
key={filter.id}
filter={filter}
isLoading={!queriedFilterValues}
isLoading={isLoadingValues}
isError={erroredFilterIds?.has(filter.id) ?? false}
onChange={values => onSetFilterValue(filter.expression, values)}
values={queriedFilterValues?.values}
value={selectedValues}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -881,8 +881,15 @@ describe('useDashboardFilterValues', () => {
isLoading: false,
});

// filter2 (service.name) should not be in the map because the query failed
expect(result.current.data?.has('filter2')).toBe(false);
// filter2 (service.name) is still present with empty values (so the UI can
// keep the control interactive) and is flagged as errored so callers can
// surface a warning.
expect(result.current.data?.get('filter2')).toEqual({
values: [],
isLoading: false,
});
expect(result.current.erroredFilterIds.has('filter1')).toBe(false);
expect(result.current.erroredFilterIds.has('filter2')).toBe(true);

// Overall error state should be true
expect(result.current.isError).toBe(true);
Expand All @@ -891,6 +898,44 @@ describe('useDashboardFilterValues', () => {
expect(mockMetadata.getKeyValues).toHaveBeenCalledTimes(2);
});

it('keeps a filter that returned no values present and interactive', async () => {
// Arrange
jest.spyOn(sourceModule, 'useSources').mockReturnValue({
data: mockSources,
isLoading: false,
} as any);

jest
.mocked(optimizeGetKeyValuesCalls)
.mockImplementationOnce(async ({ chartConfig, keys }) => [
{ chartConfig, keys },
]);

// Query succeeds but returns no rows for the requested key.
mockMetadata.getKeyValues.mockResolvedValueOnce([]);

// Act
const { result } = renderHook(
() =>
useDashboardFilterValues({
filters: [mockFilters[0]],
dateRange: mockDateRange,
}),
{ wrapper },
);

// Assert
await waitFor(() => expect(result.current.isFetching).toBe(false));

// Entry exists with empty values and is not loading → control stays usable.
expect(result.current.data?.get('filter1')).toEqual({
values: [],
isLoading: false,
});
expect(result.current.erroredFilterIds.has('filter1')).toBe(false);
expect(result.current.isError).toBe(false);
});

it('should keep previous data while fetching new data (placeholderData behavior)', async () => {
// Arrange
jest.spyOn(sourceModule, 'useSources').mockReturnValue({
Expand Down
Loading
Loading