+ 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;
+}