diff --git a/.changeset/good-swans-hammer.md b/.changeset/good-swans-hammer.md new file mode 100644 index 0000000000..a5ba3e0de5 --- /dev/null +++ b/.changeset/good-swans-hammer.md @@ -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. diff --git a/packages/app/src/DBDashboardImportPage.tsx b/packages/app/src/DBDashboardImportPage.tsx index 9ce75353a5..0b8cd9dc07 100644 --- a/packages/app/src/DBDashboardImportPage.tsx +++ b/packages/app/src/DBDashboardImportPage.tsx @@ -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, @@ -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 ( +
+ {message} + {details != null && ( + <> + + {details} + + )} +
+ ); +} + function FileSelection({ onComplete, }: { @@ -79,7 +129,14 @@ function FileSelection({ message: string; details?: ReactNode; } | null>(null); - const [errorDetails, { toggle: toggleErrorDetails }] = useDisclosure(false); + const [filterWarnings, setFilterWarnings] = useState( + [], + ); + const [filterQueryWarnings, setFilterQueryWarnings] = useState< + DashboardFilterQueryIssue[] + >([]); + const [savedQueryWarning, setSavedQueryWarning] = + useState(null); const { control, handleSubmit } = useForm({ resolver: zodResolver(FormSchema), @@ -87,6 +144,9 @@ function FileSelection({ const onSubmit = async ({ file }: FormValues) => { setError(null); + setFilterWarnings([]); + setFilterQueryWarnings([]); + setSavedQueryWarning(null); if (!file) return; let data: unknown; @@ -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 ?? []), + ); + + // 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); }; @@ -185,32 +271,72 @@ function FileSelection({ /> {error && ( -
- {error.message} - {error.details != null && ( - <> - - {error.details} - - )} -
+ + )} + + {filterWarnings.length > 0 && ( + + {filterWarnings.map(warning => ( + + Invalid {warning.language} condition: {warning.condition} + + ))} + + } + /> + )} + + {filterQueryWarnings.length > 0 && ( + + {filterQueryWarnings.map(warning => ( + + {warning.filterName}: invalid {warning.language} query:{' '} + {warning.where} + + ))} + + } + /> + )} + + {savedQueryWarning && ( + + Invalid {savedQueryWarning.language} query:{' '} + {savedQueryWarning.query} + + } + /> )} diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index ac648ab5f1..ec89bb5384 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -2353,6 +2353,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { {hasTiles && ( } + data-testid="export-dashboard-menu-item" onClick={() => { if (!sources || !dashboard) { notifications.show({ diff --git a/packages/app/src/DashboardFilters.tsx b/packages/app/src/DashboardFilters.tsx index 78a29dfdb6..f92ac131c8 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, 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'; @@ -12,6 +12,7 @@ interface DashboardFilterSelectProps { value: string[]; values?: string[]; isLoading?: boolean; + isError?: boolean; } const getAppliesToTooltip = (filter: DashboardFilter) => { @@ -26,6 +27,7 @@ const DashboardFilterSelect = ({ value, values, isLoading, + isError, }: DashboardFilterSelectProps) => { const sortedValues = values?.toSorted() || []; const tooltipText = getAppliesToTooltip(filter); @@ -43,12 +45,27 @@ const DashboardFilterSelect = ({ data-testid={`dashboard-filter-help-${filter.name}`} /> + {isError && ( + + + + )}
{ - const { data: filterValuesById, isFetching } = useDashboardFilterValues({ + const { + data: filterValuesById, + erroredFilterIds, + isFetching, + } = useDashboardFilterValues({ filters, dateRange, }); @@ -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 ( onSetFilterValue(filter.expression, values)} values={queriedFilterValues?.values} value={selectedValues} diff --git a/packages/app/src/hooks/__tests__/useDashboardFilterValues.test.tsx b/packages/app/src/hooks/__tests__/useDashboardFilterValues.test.tsx index 98837b38d8..6384fb7540 100644 --- a/packages/app/src/hooks/__tests__/useDashboardFilterValues.test.tsx +++ b/packages/app/src/hooks/__tests__/useDashboardFilterValues.test.tsx @@ -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); @@ -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({ diff --git a/packages/app/src/hooks/useDashboardFilterValues.tsx b/packages/app/src/hooks/useDashboardFilterValues.tsx index 8e36a05668..710eecb930 100644 --- a/packages/app/src/hooks/useDashboardFilterValues.tsx +++ b/packages/app/src/hooks/useDashboardFilterValues.tsx @@ -217,29 +217,42 @@ export function useDashboardFilterValues({ // Map results by filter ID instead of expression so that two filters with // the same expression but different sources/WHERE clauses get distinct values. - const flattenedData = useMemo( - () => - new Map( - results.flatMap(({ isLoading, data = [] }, resultIndex) => { - const call = calls[resultIndex]; - return data.flatMap(({ key: expression, value }) => { - const keyIndex = call.keys.indexOf(expression); - const filterIds = call.filterIds?.[keyIndex] ?? []; - const entry = { - values: value.map(v => v.toString()), - isLoading, - }; - return filterIds.map( - filterId => [filterId, entry] as [string, typeof entry], - ); - }); - }), - ), - [results, calls], - ); + // + // We iterate over the *requested* keys (not just the returned rows) so that a + // filter whose query returned no rows — or failed outright — still gets an + // entry (with empty `values`). Without this, such filters would be missing + // from the map entirely, and any consumer that derives "is still loading" + // from the entry's absence would leave the control stuck disabled forever. + // Failed filter IDs are tracked separately so callers can surface a warning + // while keeping the control interactive. + const { data: flattenedData, erroredFilterIds } = useMemo(() => { + const map = new Map(); + const errored = new Set(); + results.forEach((result, resultIndex) => { + const call = calls[resultIndex]; + if (!call) return; + const { isLoading, isError, data = [] } = result; + const valuesByExpression = new Map( + data.map(({ key, value }) => [key, value.map(v => v.toString())]), + ); + call.keys.forEach((expression, keyIndex) => { + const filterIds = call.filterIds?.[keyIndex] ?? []; + const values = valuesByExpression.get(expression) ?? []; + for (const filterId of filterIds) { + map.set(filterId, { values, isLoading }); + if (isError) { + errored.add(filterId); + } + } + }); + }); + return { data: map, erroredFilterIds: errored }; + }, [results, calls]); return { data: flattenedData, + /** Filter IDs whose key-values query failed. */ + erroredFilterIds, isLoading: isLoadingOptimizedCalls || results.every(r => r.isLoading), isFetching: isFetchingOptimizedCalls || results.some(r => r.isFetching), isError: results.some(r => r.isError), 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 87b27577c5..1595e50482 100644 --- a/packages/app/tests/e2e/features/dashboard-template-import.spec.ts +++ b/packages/app/tests/e2e/features/dashboard-template-import.spec.ts @@ -608,6 +608,184 @@ test.describe('Dashboard Template Import', { tag: ['@dashboard'] }, () => { }, ); + test( + 'should round-trip a dashboard through export and re-import', + { tag: '@full-stack' }, + async ({ page }) => { + const ts = Date.now(); + const dashboardName = `E2E Export Import ${ts}`; + const tileName = `Logs Count ${ts}`; + const filterName = `Service ${ts}`; + + await test.step('Create a dashboard with a tile and a filter', async () => { + await dashboardPage.goto(); + await dashboardPage.createNewDashboard(); + await dashboardPage.editDashboardName(dashboardName); + await dashboardPage.addTileWithSource( + tileName, + DEFAULT_LOGS_SOURCE_NAME, + ); + + await dashboardPage.openEditFiltersModal(); + await dashboardPage.addFilterToDashboard( + filterName, + DEFAULT_LOGS_SOURCE_NAME, + 'ServiceName', + ); + await expect( + dashboardPage.getFilterItemByName(filterName), + ).toBeVisible(); + await dashboardPage.closeFiltersModal(); + // Export reads the in-memory dashboard state (not the persisted doc), + // so the tile + filter we just added are already present — no need to + // wait for the fire-and-forget PATCH to land. + }); + + let exported: Record = {}; + await test.step('Export the dashboard and verify the downloaded template', async () => { + exported = await dashboardPage.exportDashboard(); + expect(exported.name).toBe(dashboardName); + expect(exported.tiles).toHaveLength(1); + expect(exported.tiles[0].config.source).toBe(DEFAULT_LOGS_SOURCE_NAME); + expect(exported.filters).toHaveLength(1); + expect(exported.filters[0]).toMatchObject({ + name: filterName, + expression: 'ServiceName', + source: DEFAULT_LOGS_SOURCE_NAME, + }); + }); + + await test.step('Re-import the exported template and finish (auto-resolved mappings)', async () => { + await dashboardImportPage.gotoImport(); + await dashboardImportPage.uploadDashboardFile(JSON.stringify(exported)); + await expect(dashboardImportPage.mappingStepHeading).toBeVisible(); + await expect(dashboardImportPage.dashboardNameInput).toHaveValue( + dashboardName, + ); + await dashboardImportPage.finishImportButton.click(); + await expect( + dashboardImportPage.getImportSuccessNotification(), + ).toBeVisible(); + await page.waitForURL(/\/dashboards\/[a-f0-9]{24}/); + }); + + await test.step('Verify the re-imported dashboard matches the original', async () => { + const logSources = await getSources(page, 'log'); + const logsSourceId = logSources.find( + (s: { name: string }) => s.name === DEFAULT_LOGS_SOURCE_NAME, + ).id; + + const importedId = dashboardPage.getCurrentDashboardId(); + const imported = await fetchDashboardById(page, importedId); + expect(imported.name).toBe(dashboardName); + expect(imported.tiles).toHaveLength(1); + expect(imported.tiles[0].config.source).toBe(logsSourceId); + expect(imported.filters).toHaveLength(1); + expect(imported.filters[0]).toMatchObject({ + name: filterName, + expression: 'ServiceName', + source: logsSourceId, + }); + }); + }, + ); + + test( + 'should warn about invalid saved filter values and saved query without blocking import', + { tag: '@full-stack' }, + async ({ page }) => { + const ts = Date.now(); + const dashboardName = `E2E Import Invalid Saved Values ${ts}`; + const tileName = `Logs Count ${ts}`; + const invalidFilterCondition = 'ServiceName = = ='; + const invalidSavedQuery = 'ServiceName:((("broken'; + + // Tile uses the workspace logs source so its mapping auto-resolves and + // the import can be finished without manual mapping. The saved filter + // value and saved query carry deliberately malformed conditions. + const dashboardFile = { + 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', + }, + }, + ], + savedFilterValues: [{ type: 'sql', condition: invalidFilterCondition }], + savedQuery: invalidSavedQuery, + savedQueryLanguage: 'lucene', + }; + + await test.step('Upload the dashboard file and reach the mapping step', async () => { + await dashboardImportPage.gotoImport(); + await dashboardImportPage.uploadDashboardFile( + JSON.stringify(dashboardFile), + ); + // Warnings are non-blocking: the mapping step is still reached. + await expect(dashboardImportPage.mappingStepHeading).toBeVisible(); + await expect(dashboardImportPage.dashboardNameInput).toHaveValue( + dashboardName, + ); + }); + + await test.step('Verify the invalid saved filter value warning is shown and lists the bad condition', async () => { + const warning = dashboardImportPage.getSavedFilterValuesWarning(); + await expect(warning).toBeVisible(); + await expect( + warning.getByText( + '1 saved filter value has an invalid condition and will not be applied.', + ), + ).toBeVisible(); + await dashboardImportPage.showDetails(warning); + await expect( + warning.getByText(`Invalid sql condition: ${invalidFilterCondition}`), + ).toBeVisible(); + }); + + await test.step('Verify the invalid saved query warning is shown and lists the bad query', async () => { + const warning = dashboardImportPage.getSavedQueryWarning(); + await expect(warning).toBeVisible(); + await expect( + warning.getByText( + "The dashboard's saved query has an invalid condition and will not be applied.", + ), + ).toBeVisible(); + await dashboardImportPage.showDetails(warning); + await expect( + warning.getByText(`Invalid lucene query: ${invalidSavedQuery}`), + ).toBeVisible(); + }); + + await test.step('Finish the import despite the warnings and verify success', async () => { + await dashboardImportPage.finishImportButton.click(); + await expect( + dashboardImportPage.getImportSuccessNotification(), + ).toBeVisible(); + await page.waitForURL(/\/dashboards\/[a-f0-9]{24}/); + }); + + await test.step('Verify the imported dashboard retained its name and tile', async () => { + const dashboardId = dashboardPage.getCurrentDashboardId(); + const dashboard = await fetchDashboardById(page, dashboardId); + expect(dashboard.name).toBe(dashboardName); + expect(dashboard.tiles).toHaveLength(1); + }); + }, + ); + test( 'should drop an unmapped onClick from the imported tile', { tag: '@full-stack' }, diff --git a/packages/app/tests/e2e/page-objects/DashboardImportPage.ts b/packages/app/tests/e2e/page-objects/DashboardImportPage.ts index 8a084807b9..8536d5e330 100644 --- a/packages/app/tests/e2e/page-objects/DashboardImportPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardImportPage.ts @@ -140,4 +140,24 @@ export class DashboardImportPage { getImportSuccessNotification() { return this.page.getByText('Import Successful!'); } + + /** The non-blocking warning shown when imported saved filter values have invalid conditions. */ + getSavedFilterValuesWarning() { + return this.page.getByTestId('import-warning-saved-filter-values'); + } + + /** The non-blocking warning shown when imported dashboard filters have invalid value queries. */ + getFilterQueriesWarning() { + return this.page.getByTestId('import-warning-filter-queries'); + } + + /** The non-blocking warning shown when the imported dashboard's saved query is invalid. */ + getSavedQueryWarning() { + return this.page.getByTestId('import-warning-saved-query'); + } + + /** Expand the "Show Details" section of one of the import warning/error blocks. */ + async showDetails(warning: Locator) { + await warning.getByRole('button', { name: 'Show Details' }).click(); + } } diff --git a/packages/app/tests/e2e/page-objects/DashboardPage.ts b/packages/app/tests/e2e/page-objects/DashboardPage.ts index 93c14f1e6a..1d2e9cb6c4 100644 --- a/packages/app/tests/e2e/page-objects/DashboardPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardPage.ts @@ -86,6 +86,7 @@ export class DashboardPage { private readonly dashboardMenuButton: Locator; private readonly saveDefaultQueryAndFiltersMenuItem: Locator; private readonly removeDefaultQueryAndFiltersMenuItem: Locator; + private readonly exportDashboardMenuItem: Locator; constructor(page: Page) { this.page = page; @@ -137,6 +138,9 @@ export class DashboardPage { this.removeDefaultQueryAndFiltersMenuItem = page.getByTestId( 'remove-default-query-filters-menu-item', ); + this.exportDashboardMenuItem = page.getByTestId( + 'export-dashboard-menu-item', + ); } /** @@ -536,6 +540,22 @@ export class DashboardPage { await this.saveTile(); } + /** + * Add a tile bound to an explicit source. Unlike addTileWithConfig (which + * relies on the editor's default source), this selects a known source so the + * exported tile carries a deterministic source name that auto-maps on import. + */ + async addTileWithSource(chartName: string, sourceName: string) { + await this.addTile(); + await expect(this.chartEditor.nameInput).toBeVisible(); + await this.chartEditor.waitForDataToLoad(); + await this.chartEditor.setChartName(chartName); + await this.chartEditor.selectSource(sourceName); + await this.chartEditor.runQuery(false); + await this.chartEditor.save(); + await expect(this.getTiles()).toHaveCount(1, { timeout: 10000 }); + } + /** * Get all dashboard tiles */ @@ -616,6 +636,25 @@ export class DashboardPage { await this.removeDefaultQueryAndFiltersMenuItem.click(); } + /** + * Export the current dashboard via the dashboard menu and return the parsed + * JSON that was downloaded. The export triggers a client-side anchor download + * (see `downloadObjectAsJson` in DBDashboardPage), so we capture the + * Playwright download event and read its stream. + */ + async exportDashboard(): Promise> { + await this.dashboardMenuButton.click(); + const downloadPromise = this.page.waitForEvent('download'); + await this.exportDashboardMenuItem.click(); + const download = await downloadPromise; + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)); + } + return JSON.parse(Buffer.concat(chunks).toString('utf-8')); + } + /** * Toggle live mode */ diff --git a/packages/common-utils/src/__tests__/filters.test.ts b/packages/common-utils/src/__tests__/filters.test.ts index 20253b4c8b..408750b7f5 100644 --- a/packages/common-utils/src/__tests__/filters.test.ts +++ b/packages/common-utils/src/__tests__/filters.test.ts @@ -1,4 +1,10 @@ -import { filtersToQuery } from '@/filters'; +import { + filtersToQuery, + validateDashboardFilterQueries, + validateSavedFilterValues, + validateSavedQuery, +} from '@/filters'; +import type { DashboardFilter, Filter } from '@/types'; describe('filters', () => { describe('filtersToQuery', () => { @@ -163,4 +169,256 @@ describe('filters', () => { ]); }); }); + + describe('validateSavedFilterValues', () => { + it('returns no issues for an empty array', () => { + expect(validateSavedFilterValues([])).toEqual([]); + }); + + it('accepts a valid single-value lucene condition', () => { + const filters: Filter[] = [ + { type: 'lucene', condition: 'ServiceName:"hdx-oss-dev-api"' }, + ]; + expect(validateSavedFilterValues(filters)).toEqual([]); + }); + + it('accepts a valid multi-value (OR) lucene condition', () => { + const filters: Filter[] = [ + { + type: 'lucene', + condition: + '(ServiceName:"hdx-oss-dev-api" OR ServiceName:"hdx-oss-dev-app")', + }, + ]; + expect(validateSavedFilterValues(filters)).toEqual([]); + }); + + it('accepts lucene conditions over map / bracket-notation keys', () => { + const filters: Filter[] = [ + { + type: 'lucene', + condition: 'ResourceAttributes.k8s\\.pod\\.name:"checkout-0"', + }, + ]; + expect(validateSavedFilterValues(filters)).toEqual([]); + }); + + it('accepts a valid sql condition', () => { + const filters: Filter[] = [ + { type: 'sql', condition: "ServiceName = 'hdx-oss-dev-api'" }, + ]; + expect(validateSavedFilterValues(filters)).toEqual([]); + }); + + it('accepts a valid sql condition over a map access column', () => { + const filters: Filter[] = [ + { + type: 'sql', + condition: "ResourceAttributes['service.name'] = 'checkout'", + }, + ]; + expect(validateSavedFilterValues(filters)).toEqual([]); + }); + + it('flags a malformed lucene condition', () => { + const filters: Filter[] = [ + { type: 'lucene', condition: 'ServiceName:((("broken' }, + ]; + expect(validateSavedFilterValues(filters)).toEqual([ + { + index: 0, + language: 'lucene', + condition: 'ServiceName:((("broken', + }, + ]); + }); + + it('flags a malformed sql condition', () => { + const filters: Filter[] = [ + { type: 'sql', condition: 'ServiceName = = ' }, + ]; + expect(validateSavedFilterValues(filters)).toEqual([ + { index: 0, language: 'sql', condition: 'ServiceName = = ' }, + ]); + }); + + it('treats empty / whitespace-only conditions as valid (no-ops)', () => { + const filters: Filter[] = [ + { type: 'lucene', condition: '' }, + { type: 'sql', condition: ' ' }, + ]; + expect(validateSavedFilterValues(filters)).toEqual([]); + }); + + it('treats structurally-valid sql_ast filters as valid', () => { + const filters: Filter[] = [ + { type: 'sql_ast', operator: '=', left: 'ServiceName', right: 'api' }, + ]; + expect(validateSavedFilterValues(filters)).toEqual([]); + }); + + it('reports the correct index for each invalid value in a mixed list', () => { + const filters: Filter[] = [ + { type: 'lucene', condition: 'ServiceName:"good"' }, + { type: 'lucene', condition: 'Bad:((("' }, + { type: 'sql', condition: "Level = 'error'" }, + { type: 'sql', condition: 'broken = = =' }, + ]; + const issues = validateSavedFilterValues(filters); + expect(issues).toEqual([ + { index: 1, language: 'lucene', condition: 'Bad:((("' }, + { index: 3, language: 'sql', condition: 'broken = = =' }, + ]); + }); + }); + + describe('validateSavedQuery', () => { + it('returns null for an empty / nullish query', () => { + expect(validateSavedQuery('', 'lucene')).toBeNull(); + expect(validateSavedQuery(' ', 'sql')).toBeNull(); + expect(validateSavedQuery(null, 'lucene')).toBeNull(); + expect(validateSavedQuery(undefined, 'sql')).toBeNull(); + }); + + it('accepts a valid lucene query', () => { + expect(validateSavedQuery('ServiceName:"api"', 'lucene')).toBeNull(); + }); + + it('accepts a valid sql query', () => { + expect(validateSavedQuery("ServiceName = 'api'", 'sql')).toBeNull(); + }); + + it('defaults a missing language to lucene', () => { + expect(validateSavedQuery('ServiceName:"api"', null)).toBeNull(); + expect(validateSavedQuery('ServiceName:"api"', undefined)).toBeNull(); + expect(validateSavedQuery('Bad:((("', undefined)).toEqual({ + language: 'lucene', + query: 'Bad:((("', + }); + }); + + it('treats promql as valid (not statically validated)', () => { + expect(validateSavedQuery('rate(foo[5m]', 'promql')).toBeNull(); + }); + + it('flags a malformed lucene query', () => { + expect(validateSavedQuery('ServiceName:((("broken', 'lucene')).toEqual({ + language: 'lucene', + query: 'ServiceName:((("broken', + }); + }); + + it('flags a malformed sql query', () => { + expect(validateSavedQuery('ServiceName = = ', 'sql')).toEqual({ + language: 'sql', + query: 'ServiceName = = ', + }); + }); + }); + + describe('validateDashboardFilterQueries', () => { + const filter = (overrides: Partial): DashboardFilter => ({ + id: 'f1', + type: 'QUERY_EXPRESSION', + name: 'ServiceName', + expression: 'ServiceName', + source: 'logs', + ...overrides, + }); + + it('returns no issues for an empty array', () => { + expect(validateDashboardFilterQueries([])).toEqual([]); + }); + + it('treats a filter with no where clause as valid', () => { + expect( + validateDashboardFilterQueries([filter({ whereLanguage: 'lucene' })]), + ).toEqual([]); + }); + + it('treats a whitespace-only where clause as valid', () => { + expect( + validateDashboardFilterQueries([ + filter({ where: ' ', whereLanguage: 'lucene' }), + ]), + ).toEqual([]); + }); + + it('accepts a valid lucene where clause', () => { + expect( + validateDashboardFilterQueries([ + filter({ where: 'ServiceName:*', whereLanguage: 'lucene' }), + ]), + ).toEqual([]); + }); + + it('accepts a valid sql where clause', () => { + expect( + validateDashboardFilterQueries([ + filter({ where: "ServiceName != ''", whereLanguage: 'sql' }), + ]), + ).toEqual([]); + }); + + it('flags a malformed lucene where clause', () => { + expect( + validateDashboardFilterQueries([ + filter({ + id: 'svc', + name: 'Service', + where: 'ServiceName:((("', + whereLanguage: 'lucene', + }), + ]), + ).toEqual([ + { + filterId: 'svc', + filterName: 'Service', + language: 'lucene', + where: 'ServiceName:((("', + }, + ]); + }); + + it('flags a malformed sql where clause', () => { + expect( + validateDashboardFilterQueries([ + filter({ + id: 'svc', + name: 'Service', + where: 'ServiceName = =', + whereLanguage: 'sql', + }), + ]), + ).toEqual([ + { + filterId: 'svc', + filterName: 'Service', + language: 'sql', + where: 'ServiceName = =', + }, + ]); + }); + + it('only reports the invalid filters in a mixed list', () => { + const issues = validateDashboardFilterQueries([ + filter({ id: 'a', where: 'ServiceName:*', whereLanguage: 'lucene' }), + filter({ + id: 'b', + name: 'Bad', + where: 'Bad:((("', + whereLanguage: 'lucene', + }), + filter({ id: 'c', where: "Level = 'error'", whereLanguage: 'sql' }), + ]); + expect(issues).toEqual([ + { + filterId: 'b', + filterName: 'Bad', + language: 'lucene', + where: 'Bad:((("', + }, + ]); + }); + }); }); diff --git a/packages/common-utils/src/__tests__/utils.test.ts b/packages/common-utils/src/__tests__/utils.test.ts index 077a9ae2f9..d70aa40882 100644 --- a/packages/common-utils/src/__tests__/utils.test.ts +++ b/packages/common-utils/src/__tests__/utils.test.ts @@ -13,6 +13,7 @@ import { import { aliasMapToWithClauses, + convertToDashboardDocument, convertToDashboardTemplate, extractSettingsClauseFromEnd, findJsonExpressions, @@ -605,6 +606,227 @@ describe('utils', () => { }); }); + it('should include saved default query and filter values in the template', () => { + const dashboard: z.infer = { + id: 'dashboard1', + name: 'Dashboard With Defaults', + tags: [], + tiles: [ + { + id: 'tile1', + config: { + name: 'Log Tile', + source: 'source1', + select: '', + where: '', + }, + x: 0, + y: 0, + w: 6, + h: 6, + }, + ], + savedQuery: 'level:error', + savedQueryLanguage: 'lucene', + savedFilterValues: [ + { + type: 'lucene', + condition: 'ServiceName:"accounting"', + }, + ], + }; + + const sources: TSource[] = [ + { + id: 'source1', + name: 'Logs', + connection: 'connection1', + kind: SourceKind.Log, + from: { + databaseName: 'db1', + tableName: 'logs_table', + }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: '', + }, + ]; + + const template = convertToDashboardTemplate(dashboard, sources); + + expect(template.savedQuery).toBe('level:error'); + expect(template.savedQueryLanguage).toBe('lucene'); + expect(template.savedFilterValues).toEqual([ + { type: 'lucene', condition: 'ServiceName:"accounting"' }, + ]); + + const document = convertToDashboardDocument(template); + expect(document.savedQuery).toBe('level:error'); + expect(document.savedQueryLanguage).toBe('lucene'); + expect(document.savedFilterValues).toEqual([ + { type: 'lucene', condition: 'ServiceName:"accounting"' }, + ]); + }); + + describe('savedFilterValues export/import round trip', () => { + const baseSources: TSource[] = [ + { + id: 'source1', + name: 'Logs', + connection: 'connection1', + kind: SourceKind.Log, + from: { databaseName: 'db1', tableName: 'logs_table' }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: '', + }, + ]; + + const makeDashboard = ( + savedFilterValues?: z.infer< + typeof DashboardSchema + >['savedFilterValues'], + ): z.infer => ({ + id: 'dashboard1', + name: 'Service Filter Dashboard', + tags: [], + tiles: [ + { + id: 'tile1', + config: { + name: 'Log Tile', + source: 'source1', + select: '', + where: '', + }, + x: 0, + y: 0, + w: 6, + h: 6, + }, + ], + filters: [ + { + id: 'filter1', + type: 'QUERY_EXPRESSION', + name: 'ServiceName Filter', + expression: 'ServiceName', + source: 'source1', + }, + ], + ...(savedFilterValues !== undefined ? { savedFilterValues } : {}), + }); + + it('omits savedFilterValues when no services are selected (undefined)', () => { + const dashboard = makeDashboard(undefined); + + const template = convertToDashboardTemplate(dashboard, baseSources); + expect(template.savedFilterValues).toBeUndefined(); + expect('savedFilterValues' in template).toBe(false); + + const document = convertToDashboardDocument(template); + expect(document.savedFilterValues).toBeUndefined(); + expect('savedFilterValues' in document).toBe(false); + }); + + it('omits savedFilterValues when the selection is an empty array', () => { + const dashboard = makeDashboard([]); + + const template = convertToDashboardTemplate(dashboard, baseSources); + expect(template.savedFilterValues).toBeUndefined(); + expect('savedFilterValues' in template).toBe(false); + + const document = convertToDashboardDocument(template); + expect(document.savedFilterValues).toBeUndefined(); + expect('savedFilterValues' in document).toBe(false); + }); + + it('carries a single selected service through the round trip', () => { + const savedFilterValues = [ + { + type: 'lucene' as const, + condition: 'ServiceName:"hdx-oss-dev-api"', + }, + ]; + const dashboard = makeDashboard(savedFilterValues); + + const template = convertToDashboardTemplate(dashboard, baseSources); + expect(template.savedFilterValues).toEqual(savedFilterValues); + + const document = convertToDashboardDocument(template); + expect(document.savedFilterValues).toEqual(savedFilterValues); + }); + + it('carries multiple selected services through the round trip', () => { + const savedFilterValues = [ + { + type: 'lucene' as const, + condition: + '(ServiceName:"hdx-oss-dev-api" OR ServiceName:"hdx-oss-dev-app")', + }, + ]; + const dashboard = makeDashboard(savedFilterValues); + + const template = convertToDashboardTemplate(dashboard, baseSources); + expect(template.savedFilterValues).toEqual(savedFilterValues); + + const document = convertToDashboardDocument(template); + expect(document.savedFilterValues).toEqual(savedFilterValues); + }); + + it('carries selected values across multiple filter expressions', () => { + const savedFilterValues = [ + { + type: 'lucene' as const, + condition: + '(ServiceName:"hdx-oss-dev-api" OR ServiceName:"hdx-oss-dev-app")', + }, + { type: 'lucene' as const, condition: 'SeverityText:"error"' }, + ]; + const dashboard = makeDashboard(savedFilterValues); + + const template = convertToDashboardTemplate(dashboard, baseSources); + expect(template.savedFilterValues).toEqual(savedFilterValues); + + const document = convertToDashboardDocument(template); + expect(document.savedFilterValues).toEqual(savedFilterValues); + }); + + it('deep-clones savedFilterValues so the template does not alias the input', () => { + const savedFilterValues = [ + { + type: 'lucene' as const, + condition: 'ServiceName:"hdx-oss-dev-api"', + }, + ]; + const dashboard = makeDashboard(savedFilterValues); + + const template = convertToDashboardTemplate(dashboard, baseSources); + expect(template.savedFilterValues).not.toBe( + dashboard.savedFilterValues, + ); + expect(template.savedFilterValues?.[0]).not.toBe(savedFilterValues[0]); + + // Mutating the source after export must not leak into the template. + savedFilterValues[0].condition = 'ServiceName:"mutated"'; + const exported = template.savedFilterValues?.[0]; + expect(exported).toEqual({ + type: 'lucene', + condition: 'ServiceName:"hdx-oss-dev-api"', + }); + }); + + it('produces a template that validates against DashboardTemplateSchema', () => { + const dashboard = makeDashboard([ + { + type: 'lucene' as const, + condition: 'ServiceName:"hdx-oss-dev-api"', + }, + ]); + + const template = convertToDashboardTemplate(dashboard, baseSources); + expect(() => DashboardTemplateSchema.parse(template)).not.toThrow(); + }); + }); + it('should remap filter.appliesToSourceIds from IDs to names', () => { const sources: TSource[] = [ { diff --git a/packages/common-utils/src/core/utils.ts b/packages/common-utils/src/core/utils.ts index 5f95e6cb0b..2095771aca 100644 --- a/packages/common-utils/src/core/utils.ts +++ b/packages/common-utils/src/core/utils.ts @@ -672,6 +672,15 @@ export function convertToDashboardTemplate( output.containers = structuredClone(input.containers); } + if (input.savedQuery != null) { + output.savedQuery = input.savedQuery; + output.savedQueryLanguage = input.savedQueryLanguage ?? null; + } + + if (input.savedFilterValues && input.savedFilterValues.length > 0) { + output.savedFilterValues = structuredClone(input.savedFilterValues); + } + return output; } @@ -711,6 +720,15 @@ export function convertToDashboardDocument( output.containers = structuredClone(input.containers); } + if (input.savedQuery != null) { + output.savedQuery = input.savedQuery; + output.savedQueryLanguage = input.savedQueryLanguage ?? null; + } + + if (input.savedFilterValues && input.savedFilterValues.length > 0) { + output.savedFilterValues = structuredClone(input.savedFilterValues); + } + return output; } diff --git a/packages/common-utils/src/filters.ts b/packages/common-utils/src/filters.ts index 9a7ac150b1..4bb8fe0d1e 100644 --- a/packages/common-utils/src/filters.ts +++ b/packages/common-utils/src/filters.ts @@ -1,4 +1,8 @@ -import { Filter } from '@/types'; +import * as SQLParser from 'node-sql-parser'; + +import { replaceJsonExpressions } from '@/core/utils'; +import { parse } from '@/queryParser'; +import { DashboardFilter, Filter } from '@/types'; export type FilterState = { [key: string]: { @@ -52,3 +56,161 @@ export const filtersToQuery = ( return conditions; }); }; + +export type SavedFilterValueIssue = { + /** Index of the offending value within the input array */ + index: number; + /** Query language the condition claims to be written in */ + language: 'lucene' | 'sql'; + /** The raw condition string that failed to parse */ + condition: string; +}; + +const isParseableLucene = (condition: string): boolean => { + try { + parse(condition); + return true; + } catch { + return false; + } +}; + +// node-sql-parser can't handle ClickHouse map / array access (e.g. +// `LogAttributes['k']` or `arr[1]`), so swap those out for harmless literals +// before parsing — we only care whether the predicate is structurally valid. +const MAP_OR_ARRAY_ACCESS_REGEX = /\b[a-zA-Z0-9_]+\[([0-9]+|'[^']*')\]/g; + +const isParseableSql = (condition: string): boolean => { + try { + const { sqlWithReplacements } = replaceJsonExpressions(condition); + const sanitized = sqlWithReplacements.replace( + MAP_OR_ARRAY_ACCESS_REGEX, + "''", + ); + new SQLParser.Parser().astify(`SELECT 1 FROM t WHERE ${sanitized}`, { + database: 'Postgresql', + }); + return true; + } catch { + return false; + } +}; + +/** + * Whether a condition string parses as the given query language. Empty / + * whitespace-only conditions are considered valid (they're no-ops, not errors). + * `promql` is not statically validated here and is treated as valid. + */ +export function isValidFilterCondition( + condition: string, + language: 'lucene' | 'sql' | 'promql', +): boolean { + if (!condition.trim()) return true; + if (language === 'lucene') return isParseableLucene(condition); + if (language === 'sql') return isParseableSql(condition); + return true; +} + +/** + * Validate the condition strings carried in a dashboard's saved filter values. + * + * Schema validation only guarantees each value has a `{ type, condition }` + * shape — the condition text itself is a free-form string and may be broken in + * a hand-edited or machine-generated export. This returns one issue per value + * whose condition fails to parse as the language it claims to be, so callers + * can warn the user (e.g. on import) without hard-blocking the operation. + * + * Empty / whitespace-only conditions are treated as valid (they're no-ops at + * query time, not errors), as are structurally-validated `sql_ast` filters. + */ +export function validateSavedFilterValues( + filters: Filter[], +): SavedFilterValueIssue[] { + const issues: SavedFilterValueIssue[] = []; + filters.forEach((filter, index) => { + if (filter.type !== 'lucene' && filter.type !== 'sql') return; + const condition = filter.condition; + if (!condition.trim()) return; + if (!isValidFilterCondition(condition, filter.type)) { + issues.push({ index, language: filter.type, condition }); + } + }); + return issues; +} + +export type SavedQueryIssue = { + /** Query language the saved query claims to be written in */ + language: 'lucene' | 'sql'; + /** The raw saved query string that failed to parse */ + query: string; +}; + +/** + * Validate a dashboard's default saved query (the `where` clause applied to the + * whole dashboard). Like the other import-time validators this only checks that + * the query *parses* as its declared language. Returns a single issue or `null`. + * + * Empty / whitespace-only queries are treated as valid (no-ops), and a query in + * a non-statically-validated language (`promql`) is treated as valid. A missing + * language defaults to `lucene`, mirroring how the dashboard page resolves it. + * + * A malformed saved query is comparatively low impact at import time — it's + * surfaced in the dashboard's search bar where the user can see and edit it — + * but validating it keeps the import warnings consistent and avoids silently + * carrying over a broken default query. + */ +export function validateSavedQuery( + savedQuery: string | null | undefined, + language: 'lucene' | 'sql' | 'promql' | null | undefined, +): SavedQueryIssue | null { + if (!savedQuery?.trim()) return null; + const lang = language ?? 'lucene'; + if (lang !== 'lucene' && lang !== 'sql') return null; + if (isValidFilterCondition(savedQuery, lang)) return null; + return { language: lang, query: savedQuery }; +} + +export type DashboardFilterQueryIssue = { + /** ID of the offending dashboard filter */ + filterId: string; + /** Display name of the offending dashboard filter */ + filterName: string; + /** Query language of the filter's `where` clause */ + language: 'lucene' | 'sql'; + /** The raw `where` clause that failed to parse */ + where: string; +}; + +/** + * Validate the `where` clause of each dashboard filter *definition* (the query + * that scopes which values populate the filter's dropdown). + * + * Useful at import time, where no values query is actually run: a filter whose + * `where` clause is malformed would otherwise only surface as a failed query + * after opening the dashboard. Returns one issue per filter whose `where` + * fails to parse as its declared language. + * + * Note: this only checks that the `where` clause *parses*. It cannot catch a + * `where`/`expression` that references a non-existent column — that only fails + * when the query runs against ClickHouse. + */ +export function validateDashboardFilterQueries( + filters: DashboardFilter[], +): DashboardFilterQueryIssue[] { + const issues: DashboardFilterQueryIssue[] = []; + for (const filter of filters) { + const where = filter.where ?? ''; + if (!where.trim()) continue; + const language = filter.whereLanguage ?? 'sql'; + if (language !== 'lucene' && language !== 'sql') continue; + if (!isValidFilterCondition(where, language)) { + issues.push({ + filterId: filter.id, + filterName: filter.name, + language, + where, + }); + } + } + return issues; +}