feat: localStorage for dashboards/saved searches in LOCAL mode (#1822)

Fixes: HDX-3506

What
Introduces support for storing dashboards/saved searches in local storage for LOCAL ONLY (UI only) mode. 

Why
This adds additional features for the public demo + helps the user experience when using hyperdx inside of clickhouse.
This commit is contained in:
Tom Alexander 2026-03-05 12:26:51 -05:00 committed by GitHub
parent 68ef3d6f97
commit b4f0558776
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 956 additions and 494 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
feat: localStorage for dashboards/saved searches in LOCAL mode

View file

@ -1197,7 +1197,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
);
}}
/>
{IS_LOCAL_MODE === false && isLocalDashboard && (
{isLocalDashboard && (
<Paper my="lg" p="md" data-testid="temporary-dashboard-banner">
<Flex justify="space-between" align="center">
<Text size="sm">
@ -1397,18 +1397,16 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
<IconRefresh size={18} />
</ActionIcon>
</Tooltip>
{!IS_LOCAL_MODE && (
<Tooltip withArrow label="Edit Filters" fz="xs" color="gray">
<ActionIcon
variant="secondary"
onClick={() => setShowFiltersModal(true)}
data-testid="edit-filters-button"
size="input-sm"
>
<IconFilterEdit size={18} />
</ActionIcon>
</Tooltip>
)}
<Tooltip withArrow label="Edit Filters" fz="xs" color="gray">
<ActionIcon
variant="secondary"
onClick={() => setShowFiltersModal(true)}
data-testid="edit-filters-button"
size="input-sm"
>
<IconFilterEdit size={18} />
</ActionIcon>
</Tooltip>
<Button
data-testid="search-submit-button"
variant="primary"

View file

@ -1635,77 +1635,75 @@ function DBSearchPage() {
size="xs"
/>
</Box>
{!IS_LOCAL_MODE && (
<>
{!savedSearchId ? (
<Button
data-testid="save-search-button"
variant="secondary"
size="xs"
onClick={onSaveSearch}
style={{ flexShrink: 0 }}
<>
{!savedSearchId ? (
<Button
data-testid="save-search-button"
variant="secondary"
size="xs"
onClick={onSaveSearch}
style={{ flexShrink: 0 }}
>
Save
</Button>
) : (
<Button
data-testid="update-search-button"
variant="secondary"
size="xs"
onClick={() => {
setSaveSearchModalState('update');
}}
style={{ flexShrink: 0 }}
>
Update
</Button>
)}
{!IS_LOCAL_MODE && (
<Button
data-testid="alerts-button"
variant="secondary"
size="xs"
onClick={openAlertModal}
style={{ flexShrink: 0 }}
>
Alerts
</Button>
)}
{!!savedSearch && (
<>
<Tags
allowCreate
values={savedSearch.tags || []}
onChange={handleUpdateTags}
>
Save
</Button>
) : (
<Button
data-testid="update-search-button"
variant="secondary"
size="xs"
onClick={() => {
<Button
data-testid="tags-button"
variant="secondary"
px="xs"
size="xs"
style={{ flexShrink: 0 }}
>
<IconTags size={14} className="me-1" />
{savedSearch.tags?.length || 0}
</Button>
</Tags>
<SearchPageActionBar
onClickDeleteSavedSearch={() => {
deleteSavedSearch.mutate(savedSearch?.id ?? '', {
onSuccess: () => {
router.push('/search');
},
});
}}
onClickRenameSavedSearch={() => {
setSaveSearchModalState('update');
}}
style={{ flexShrink: 0 }}
>
Update
</Button>
)}
{!IS_LOCAL_MODE && (
<Button
data-testid="alerts-button"
variant="secondary"
size="xs"
onClick={openAlertModal}
style={{ flexShrink: 0 }}
>
Alerts
</Button>
)}
{!!savedSearch && (
<>
<Tags
allowCreate
values={savedSearch.tags || []}
onChange={handleUpdateTags}
>
<Button
data-testid="tags-button"
variant="secondary"
px="xs"
size="xs"
style={{ flexShrink: 0 }}
>
<IconTags size={14} className="me-1" />
{savedSearch.tags?.length || 0}
</Button>
</Tags>
<SearchPageActionBar
onClickDeleteSavedSearch={() => {
deleteSavedSearch.mutate(savedSearch?.id ?? '', {
onSuccess: () => {
router.push('/search');
},
});
}}
onClickRenameSavedSearch={() => {
setSaveSearchModalState('update');
}}
/>
</>
)}
</>
)}
/>
</>
)}
</>
</Flex>
<SourceEditModal
opened={modelFormExpanded}

View file

@ -0,0 +1,87 @@
jest.mock('../api', () => ({ hdxServer: jest.fn() }));
jest.mock('../config', () => ({ IS_LOCAL_MODE: true }));
jest.mock('@mantine/notifications', () => ({
notifications: { show: jest.fn() },
}));
jest.mock('nuqs', () => ({
parseAsJson: jest.fn(),
useQueryState: jest.fn(),
}));
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(),
useMutation: jest.fn(),
useQueryClient: jest.fn(),
}));
jest.mock('@/utils', () => ({ hashCode: jest.fn(() => 0) }));
import { fetchLocalDashboards, getLocalDashboardTags } from '../dashboard';
const STORAGE_KEY = 'hdx-local-dashboards';
beforeEach(() => {
localStorage.clear();
});
describe('fetchLocalDashboards', () => {
it('returns empty array when no dashboards exist', () => {
expect(fetchLocalDashboards()).toEqual([]);
});
it('returns all stored dashboards', () => {
const dashboards = [
{ id: 'a', name: 'Dashboard A', tiles: [], tags: [] },
{ id: 'b', name: 'Dashboard B', tiles: [], tags: [] },
];
localStorage.setItem(STORAGE_KEY, JSON.stringify(dashboards));
expect(fetchLocalDashboards()).toHaveLength(2);
});
});
describe('getLocalDashboardTags', () => {
it('returns empty array when no dashboards exist', () => {
expect(getLocalDashboardTags()).toEqual([]);
});
it('returns empty array when dashboards have no tags', () => {
const dashboards = [
{ id: 'a', name: 'A', tiles: [], tags: [] },
{ id: 'b', name: 'B', tiles: [], tags: [] },
];
localStorage.setItem(STORAGE_KEY, JSON.stringify(dashboards));
expect(getLocalDashboardTags()).toEqual([]);
});
it('collects tags from all dashboards', () => {
const dashboards = [
{ id: 'a', name: 'A', tiles: [], tags: ['production'] },
{ id: 'b', name: 'B', tiles: [], tags: ['staging'] },
];
localStorage.setItem(STORAGE_KEY, JSON.stringify(dashboards));
expect(getLocalDashboardTags()).toEqual(
expect.arrayContaining(['production', 'staging']),
);
expect(getLocalDashboardTags()).toHaveLength(2);
});
it('deduplicates tags that appear on multiple dashboards', () => {
const dashboards = [
{ id: 'a', name: 'A', tiles: [], tags: ['production', 'infra'] },
{ id: 'b', name: 'B', tiles: [], tags: ['production', 'billing'] },
];
localStorage.setItem(STORAGE_KEY, JSON.stringify(dashboards));
const tags = getLocalDashboardTags();
expect(tags).toHaveLength(3);
expect(tags).toEqual(
expect.arrayContaining(['production', 'infra', 'billing']),
);
});
it('handles dashboards with undefined tags', () => {
const dashboards = [
{ id: 'a', name: 'A', tiles: [] },
{ id: 'b', name: 'B', tiles: [], tags: ['ops'] },
];
localStorage.setItem(STORAGE_KEY, JSON.stringify(dashboards));
expect(getLocalDashboardTags()).toEqual(['ops']);
});
});

View file

@ -0,0 +1,323 @@
import { SavedSearch, TSource } from '@hyperdx/common-utils/dist/types';
// Mock config so we can control HDX_LOCAL_DEFAULT_SOURCES in tests
jest.mock('../config', () => ({
HDX_LOCAL_DEFAULT_SOURCES: null,
}));
// Mock parseJSON so tests aren't coupled to its implementation.
// Returns null for empty/falsy input, matching the real parseJSON behaviour.
jest.mock('../utils', () => ({
parseJSON: jest.fn((s: string) => {
if (!s) return null;
return JSON.parse(s);
}),
}));
import {
createEntityStore,
localSavedSearches,
localSources,
} from '../localStore';
type Item = { id: string; name: string };
const TEST_KEY = 'test-entity-store';
function makeStore() {
return createEntityStore<Item>(TEST_KEY);
}
beforeEach(() => {
localStorage.clear();
jest.clearAllMocks();
});
// ---------------------------------------------------------------------------
// createEntityStore — core CRUD
// ---------------------------------------------------------------------------
describe('createEntityStore', () => {
describe('getAll', () => {
it('returns empty array when store is empty', () => {
const store = makeStore();
expect(store.getAll()).toEqual([]);
});
it('returns stored items', () => {
const store = makeStore();
store.create({ name: 'alpha' });
store.create({ name: 'beta' });
expect(store.getAll()).toHaveLength(2);
expect(store.getAll().map(i => i.name)).toEqual(['alpha', 'beta']);
});
it('calls getDefaultItems when the key is absent', () => {
const defaults: Item[] = [{ id: 'default-1', name: 'default' }];
const getDefaultItems = jest.fn(() => defaults);
const store = createEntityStore<Item>(TEST_KEY, getDefaultItems);
const result = store.getAll();
expect(getDefaultItems).toHaveBeenCalledTimes(1);
expect(result).toEqual(defaults);
});
it('does not call getDefaultItems once data exists in storage', () => {
const getDefaultItems = jest.fn(() => [
{ id: 'default-1', name: 'default' },
]);
const store = createEntityStore<Item>(TEST_KEY, getDefaultItems);
// First write: key absent, so getDefaultItems is used to seed transact
store.create({ name: 'persisted' });
getDefaultItems.mockClear();
// Subsequent reads/writes should not consult defaults
store.getAll();
expect(getDefaultItems).not.toHaveBeenCalled();
});
});
describe('create', () => {
it('persists a new item and returns it with a generated id', () => {
const store = makeStore();
const created = store.create({ name: 'new item' });
expect(created.name).toBe('new item');
expect(created.id).toBeDefined();
expect(store.getAll()).toHaveLength(1);
expect(store.getAll()[0]).toEqual(created);
});
it('generates hex ids matching /[0-9a-f]+/', () => {
const store = makeStore();
const { id } = store.create({ name: 'x' });
expect(id).toMatch(/^[0-9a-f]+$/);
});
it('each create appends without replacing existing items', () => {
const store = makeStore();
store.create({ name: 'first' });
store.create({ name: 'second' });
store.create({ name: 'third' });
expect(store.getAll()).toHaveLength(3);
});
});
describe('update', () => {
it('updates the matching item and returns the updated value', () => {
const store = makeStore();
const { id } = store.create({ name: 'original' });
const updated = store.update(id, { name: 'updated' });
expect(updated).toEqual({ id, name: 'updated' });
expect(store.getAll()[0]).toEqual({ id, name: 'updated' });
});
it('preserves other items when updating', () => {
const store = makeStore();
const a = store.create({ name: 'a' });
const b = store.create({ name: 'b' });
store.update(a.id, { name: 'a-updated' });
const all = store.getAll();
expect(all).toHaveLength(2);
expect(all.find(i => i.id === b.id)?.name).toBe('b');
});
it('preserves the original id even if updates include an id field', () => {
const store = makeStore();
const { id } = store.create({ name: 'x' });
const updated = store.update(id, {
name: 'y',
id: 'injected-id',
} as Partial<Omit<Item, 'id'>>);
expect(updated.id).toBe(id);
});
it('throws when id is not found', () => {
const store = makeStore();
expect(() => store.update('nonexistent', { name: 'x' })).toThrow(
/not found/,
);
});
});
describe('delete', () => {
it('removes the item with the given id', () => {
const store = makeStore();
const { id } = store.create({ name: 'to-delete' });
store.create({ name: 'keep' });
store.delete(id);
const all = store.getAll();
expect(all).toHaveLength(1);
expect(all[0].name).toBe('keep');
});
it('is a no-op for a non-existent id', () => {
const store = makeStore();
store.create({ name: 'a' });
expect(() => store.delete('nonexistent')).not.toThrow();
expect(store.getAll()).toHaveLength(1);
});
});
describe('set', () => {
it('replaces the entire collection', () => {
const store = makeStore();
store.create({ name: 'old' });
const replacement: Item[] = [
{ id: 'x1', name: 'new-a' },
{ id: 'x2', name: 'new-b' },
];
store.set(replacement);
expect(store.getAll()).toEqual(replacement);
});
it('set with empty array clears the store', () => {
const store = makeStore();
store.create({ name: 'a' });
store.set([]);
expect(store.getAll()).toEqual([]);
});
});
describe('isolation', () => {
it('two stores with different keys do not share data', () => {
const storeA = createEntityStore<Item>('key-a');
const storeB = createEntityStore<Item>('key-b');
storeA.create({ name: 'a-item' });
expect(storeB.getAll()).toEqual([]);
});
});
describe('mutations against env-var defaults (key absent from localStorage)', () => {
const defaults: Item[] = [{ id: 'default-id', name: 'default-item' }];
function makeStoreWithDefaults() {
return createEntityStore<Item>(TEST_KEY, () => defaults);
}
it('update finds an item that exists only in defaults', () => {
const store = makeStoreWithDefaults();
// Nothing in localStorage yet — getAll() returns defaults
expect(store.getAll()).toEqual(defaults);
const updated = store.update('default-id', { name: 'updated-name' });
expect(updated).toEqual({ id: 'default-id', name: 'updated-name' });
// After the write the value is now in localStorage
expect(store.getAll()).toEqual([
{ id: 'default-id', name: 'updated-name' },
]);
});
it('delete removes an item that exists only in defaults', () => {
const store = makeStoreWithDefaults();
store.delete('default-id');
expect(store.getAll()).toEqual([]);
});
it('create preserves defaults when adding a new item', () => {
const store = makeStoreWithDefaults();
const created = store.create({ name: 'brand-new' });
const all = store.getAll();
expect(all).toHaveLength(2);
expect(all.find(i => i.id === 'default-id')).toBeDefined();
expect(all.find(i => i.id === created.id)).toBeDefined();
});
});
});
// ---------------------------------------------------------------------------
// localSources — env-var default fallback
// ---------------------------------------------------------------------------
describe('localSources', () => {
const mockedUtils = jest.requireMock('../utils') as { parseJSON: jest.Mock };
const mockedConfig = jest.requireMock('../config') as {
HDX_LOCAL_DEFAULT_SOURCES: string | null;
};
it('returns empty array when storage is empty and no env-var default', () => {
mockedConfig.HDX_LOCAL_DEFAULT_SOURCES = null;
expect(localSources.getAll()).toEqual([]);
});
it('returns env-var defaults when storage is empty', () => {
const defaults = [{ id: 'src-1', name: 'Demo Logs' }];
mockedConfig.HDX_LOCAL_DEFAULT_SOURCES = JSON.stringify(defaults);
mockedUtils.parseJSON.mockReturnValueOnce(defaults);
expect(localSources.getAll()).toEqual(defaults);
});
it('persists defaults + new item on first write and stops consulting env-var after', () => {
const envDefaults = [{ id: 'env-src', name: 'Env Source' }];
mockedConfig.HDX_LOCAL_DEFAULT_SOURCES = JSON.stringify(envDefaults);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const stored = localSources.create({ name: 'My Source' } as Omit<
TSource,
'id'
>);
mockedUtils.parseJSON.mockClear();
const all = localSources.getAll();
expect(all).toHaveLength(2);
expect(all.find(s => s.id === 'env-src')).toBeDefined();
expect(all.find(s => s.id === stored.id)).toBeDefined();
// Subsequent reads do not consult env-var defaults
expect(mockedUtils.parseJSON).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// localSavedSearches — basic sanity (delegates to createEntityStore)
// ---------------------------------------------------------------------------
describe('localSavedSearches', () => {
it('starts empty', () => {
expect(localSavedSearches.getAll()).toEqual([]);
});
it('creates, updates, and deletes a saved search', () => {
const created = localSavedSearches.create({
name: 'My Search',
select: 'Timestamp, Body',
where: '',
whereLanguage: 'lucene',
source: 'src-1',
tags: [],
filters: [],
orderBy: '',
} as Omit<SavedSearch, 'id'>);
expect(created.id).toMatch(/^[0-9a-f]+$/);
expect(localSavedSearches.getAll()).toHaveLength(1);
localSavedSearches.update(created.id, { name: 'Renamed' });
expect(localSavedSearches.getAll()[0].name).toBe('Renamed');
localSavedSearches.delete(created.id);
expect(localSavedSearches.getAll()).toHaveLength(0);
});
});

View file

@ -11,7 +11,11 @@ import type { UseQueryOptions } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import { IS_LOCAL_MODE } from './config';
import { Dashboard } from './dashboard';
import {
Dashboard,
fetchLocalDashboards,
getLocalDashboardTags,
} from './dashboard';
import type { AlertsPageItem } from './types';
type ServicesResponse = {
@ -117,12 +121,9 @@ const api = {
useDashboards(options?: UseQueryOptions<Dashboard[] | null, Error>) {
return useQuery({
queryKey: [`dashboards`],
queryFn: () => {
if (IS_LOCAL_MODE) {
return null;
}
return hdxServer(`dashboards`, { method: 'GET' }).json<Dashboard[]>();
},
queryFn: IS_LOCAL_MODE
? async () => fetchLocalDashboards()
: () => hdxServer(`dashboards`, { method: 'GET' }).json<Dashboard[]>(),
...options,
});
},
@ -359,7 +360,9 @@ const api = {
useTags() {
return useQuery({
queryKey: [`team/tags`],
queryFn: () => hdxServer(`team/tags`).json<{ data: string[] }>(),
queryFn: IS_LOCAL_MODE
? async () => ({ data: getLocalDashboardTags() })
: () => hdxServer(`team/tags`).json<{ data: string[] }>(),
});
},
useSaveWebhook() {

View file

@ -117,23 +117,6 @@ const NAV_LINKS: NavLinkConfig[] = [
function NewDashboardButton() {
const createDashboard = useCreateDashboard();
if (IS_LOCAL_MODE) {
return (
<Button
component={Link}
href="/dashboards"
data-testid="create-dashboard-button"
variant="transparent"
color="var(--color-text)"
py="0px"
px="sm"
fw={400}
>
<span className="pe-2">+</span> Create Dashboard
</Button>
);
}
return (
<Button
data-testid="create-dashboard-button"
@ -721,11 +704,7 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
icon={<IconTable size={16} />}
href="/search"
isExpanded={isSearchExpanded}
onToggle={
!IS_LOCAL_MODE
? () => setIsSearchExpanded(!isSearchExpanded)
: undefined
}
onToggle={() => setIsSearchExpanded(!isSearchExpanded)}
/>
{!isCollapsed && (
@ -734,42 +713,40 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
{isLogViewsLoading ? (
<Loader variant="dots" mx="md" my="xs" size="sm" />
) : (
!IS_LOCAL_MODE && (
<>
<SearchInput
placeholder="Saved Searches"
value={searchesListQ}
onChange={setSearchesListQ}
onEnterDown={() => {
(
savedSearchesResultsRef?.current
?.firstChild as HTMLAnchorElement
)?.focus?.();
}}
/>
<>
<SearchInput
placeholder="Saved Searches"
value={searchesListQ}
onChange={setSearchesListQ}
onEnterDown={() => {
(
savedSearchesResultsRef?.current
?.firstChild as HTMLAnchorElement
)?.focus?.();
}}
/>
{logViews.length === 0 && (
<div className={styles.emptyMessage}>
No saved searches
</div>
)}
<div ref={savedSearchesResultsRef}>
<AppNavLinkGroups
name="saved-searches"
groups={groupedFilteredSearchesList}
renderLink={renderLogViewLink}
forceExpandGroups={!!searchesListQ}
onDragEnd={handleLogViewDragEnd}
/>
{logViews.length === 0 && (
<div className={styles.emptyMessage}>
No saved searches
</div>
)}
<div ref={savedSearchesResultsRef}>
<AppNavLinkGroups
name="saved-searches"
groups={groupedFilteredSearchesList}
renderLink={renderLogViewLink}
forceExpandGroups={!!searchesListQ}
onDragEnd={handleLogViewDragEnd}
/>
</div>
{searchesListQ && filteredSearchesList.length === 0 ? (
<div className={styles.emptyMessage}>
No results matching <i>{searchesListQ}</i>
</div>
) : null}
</>
)
{searchesListQ && filteredSearchesList.length === 0 ? (
<div className={styles.emptyMessage}>
No results matching <i>{searchesListQ}</i>
</div>
) : null}
</>
)}
</div>
</Collapse>
@ -801,45 +778,43 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
<div className={styles.subMenu}>
<NewDashboardButton />
{isDashboardsLoading ? (
{isDashboardsLoading && dashboardsData == null ? (
<Loader variant="dots" mx="md" my="xs" size="sm" />
) : (
!IS_LOCAL_MODE && (
<>
<SearchInput
placeholder="Saved Dashboards"
value={dashboardsListQ}
onChange={setDashboardsListQ}
onEnterDown={() => {
(
dashboardsResultsRef?.current
?.firstChild as HTMLAnchorElement
)?.focus?.();
}}
/>
<>
<SearchInput
placeholder="Saved Dashboards"
value={dashboardsListQ}
onChange={setDashboardsListQ}
onEnterDown={() => {
(
dashboardsResultsRef?.current
?.firstChild as HTMLAnchorElement
)?.focus?.();
}}
/>
<AppNavLinkGroups
name="dashboards"
groups={groupedFilteredDashboardsList}
renderLink={renderDashboardLink}
forceExpandGroups={!!dashboardsListQ}
onDragEnd={handleDashboardDragEnd}
/>
<AppNavLinkGroups
name="dashboards"
groups={groupedFilteredDashboardsList}
renderLink={renderDashboardLink}
forceExpandGroups={!!dashboardsListQ}
onDragEnd={handleDashboardDragEnd}
/>
{dashboards.length === 0 && (
<div className={styles.emptyMessage}>
No saved dashboards
</div>
)}
{dashboards.length === 0 && (
<div className={styles.emptyMessage}>
No saved dashboards
</div>
)}
{dashboardsListQ &&
filteredDashboardsList.length === 0 ? (
<div className={styles.emptyMessage}>
No results matching <i>{dashboardsListQ}</i>
</div>
) : null}
</>
)
{dashboardsListQ &&
filteredDashboardsList.length === 0 ? (
<div className={styles.emptyMessage}>
No results matching <i>{dashboardsListQ}</i>
</div>
) : null}
</>
)}
<AppNavGroupLabel

View file

@ -13,6 +13,7 @@ import { hashCode } from '@/utils';
import { hdxServer } from './api';
import { IS_LOCAL_MODE } from './config';
import { createEntityStore } from './localStore';
// TODO: Move to types
export type Tile = {
@ -35,6 +36,15 @@ export type Dashboard = {
savedFilterValues?: Filter[];
};
const localDashboards = createEntityStore<Dashboard>('hdx-local-dashboards');
async function fetchDashboards(): Promise<Dashboard[]> {
if (IS_LOCAL_MODE) {
return localDashboards.getAll();
}
return hdxServer('dashboards').json<Dashboard[]>();
}
export function useUpdateDashboard() {
const queryClient = useQueryClient();
@ -42,6 +52,11 @@ export function useUpdateDashboard() {
mutationFn: async (
dashboard: Partial<Dashboard> & { id: Dashboard['id'] },
) => {
if (IS_LOCAL_MODE) {
const { id, ...updates } = dashboard;
localDashboards.update(id, updates);
return;
}
await hdxServer(`dashboards/${dashboard.id}`, {
method: 'PATCH',
json: dashboard,
@ -58,6 +73,9 @@ export function useCreateDashboard() {
return useMutation({
mutationFn: async (dashboard: Omit<Dashboard, 'id'>) => {
if (IS_LOCAL_MODE) {
return localDashboards.create(dashboard);
}
return hdxServer('dashboards', {
method: 'POST',
json: dashboard,
@ -72,12 +90,7 @@ export function useCreateDashboard() {
export function useDashboards() {
return useQuery({
queryKey: ['dashboards'],
queryFn: async () => {
if (IS_LOCAL_MODE) {
return [];
}
return hdxServer('dashboards').json<Dashboard[]>();
},
queryFn: fetchDashboards,
});
}
@ -109,9 +122,7 @@ export function useDashboard({
const { data: remoteDashboard, isFetching: isFetchingRemoteDashboard } =
useQuery({
queryKey: ['dashboards'],
queryFn: () => {
return hdxServer('dashboards').json<Dashboard[]>();
},
queryFn: fetchDashboards,
select: data => {
return data.find(d => d.id === dashboardId);
},
@ -176,11 +187,27 @@ export function useDashboard({
};
}
export function fetchLocalDashboards(): Dashboard[] {
return localDashboards.getAll();
}
export function getLocalDashboardTags(): string[] {
const tagSet = new Set<string>();
localDashboards
.getAll()
.forEach(d => (d.tags ?? []).forEach(t => tagSet.add(t)));
return Array.from(tagSet);
}
export function useDeleteDashboard() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => {
if (IS_LOCAL_MODE) {
localDashboards.delete(id);
return Promise.resolve();
}
return hdxServer(`dashboards/${id}`, { method: 'DELETE' }).json<void>();
},
onSuccess: () => {

View file

@ -0,0 +1,101 @@
import store from 'store2';
import { hashCode } from '@hyperdx/common-utils/dist/core/utils';
import { SavedSearch, TSource } from '@hyperdx/common-utils/dist/types';
import { HDX_LOCAL_DEFAULT_SOURCES } from './config';
import { parseJSON } from './utils';
type EntityWithId = { id: string };
/**
* Generic localStorage CRUD store for local-mode entities.
* Uses store2 for atomic transact operations and JSON serialization.
*/
export function createEntityStore<T extends EntityWithId>(
key: string,
getDefaultItems?: () => T[],
) {
function getAll(): T[] {
if (getDefaultItems != null && !store.has(key)) {
return getDefaultItems();
}
return store.get(key, []);
}
return {
getAll,
create(item: Omit<T, 'id'>): T {
const newItem = {
...item,
id: Math.abs(hashCode(Math.random().toString())).toString(16),
} as T;
// Seed transact from defaults when the key is absent so that
// env-var-seeded items are not silently dropped on the first write.
const alt = !store.has(key) ? (getDefaultItems?.() ?? []) : [];
store.transact(key, (prev: T[]) => [...(prev ?? []), newItem], alt);
return newItem;
},
update(id: string, updates: Partial<Omit<T, 'id'>>): T {
let updated: T | undefined;
// Same rationale: seed transact from defaults when the key is absent so
// that updates against env-var-provided items don't throw 'not found'.
const alt = !store.has(key) ? (getDefaultItems?.() ?? []) : [];
store.transact(
key,
(prev: T[]) =>
(prev ?? []).map(item => {
if (item.id === id) {
updated = { ...item, ...updates, id };
return updated;
}
return item;
}),
alt,
);
if (updated == null) {
throw new Error(
`Local store: entity with id "${id}" not found in "${key}"`,
);
}
return updated;
},
delete(id: string): void {
const alt = !store.has(key) ? (getDefaultItems?.() ?? []) : [];
store.transact(
key,
(prev: T[]) => (prev ?? []).filter(item => item.id !== id),
alt,
);
},
/** Replace the entire collection atomically (used for single-item stores like connections). */
set(items: T[]): void {
store.set(key, items);
},
};
}
/**
* Sources store with env-var default fallback.
* Keeps the existing "hdx-local-source" key for backward compatibility.
*/
export const localSources = createEntityStore<TSource>(
'hdx-local-source',
() => {
try {
const defaults = parseJSON(HDX_LOCAL_DEFAULT_SOURCES ?? '');
if (defaults != null) return defaults;
} catch (e) {
console.error('Error loading default sources', e);
}
return [];
},
);
/** Saved searches store (alerts remain cloud-only; no alert fields persisted locally). */
export const localSavedSearches = createEntityStore<SavedSearch>(
'hdx-local-saved-searches',
);

View file

@ -1,4 +1,3 @@
import { z } from 'zod';
import { SavedSearch } from '@hyperdx/common-utils/dist/types';
import {
useMutation,
@ -9,20 +8,21 @@ import {
import { hdxServer } from './api';
import { IS_LOCAL_MODE } from './config';
import { localSavedSearches } from './localStore';
import { SavedSearchWithEnhancedAlerts } from './types';
async function fetchSavedSearches(): Promise<SavedSearchWithEnhancedAlerts[]> {
if (IS_LOCAL_MODE) {
// Locally stored saved searches never have alert data (alerts are cloud-only)
return localSavedSearches.getAll() as SavedSearchWithEnhancedAlerts[];
}
return hdxServer('saved-search').json<SavedSearchWithEnhancedAlerts[]>();
}
export function useSavedSearches() {
return useQuery({
queryKey: ['saved-search'],
queryFn: async () => {
if (IS_LOCAL_MODE) {
return [];
} else {
return hdxServer('saved-search').json<
SavedSearchWithEnhancedAlerts[]
>();
}
},
queryFn: fetchSavedSearches,
});
}
@ -35,12 +35,7 @@ export function useSavedSearch(
) {
return useQuery({
queryKey: ['saved-search'],
queryFn: () => {
if (IS_LOCAL_MODE) {
return [];
}
return hdxServer('saved-search').json<SavedSearchWithEnhancedAlerts[]>();
},
queryFn: fetchSavedSearches,
select: data => data.find(s => s.id === id),
...options,
});
@ -51,6 +46,11 @@ export function useCreateSavedSearch() {
return useMutation({
mutationFn: (data: Omit<SavedSearch, 'id'>) => {
if (IS_LOCAL_MODE) {
return Promise.resolve(
localSavedSearches.create(data) as SavedSearchWithEnhancedAlerts,
);
}
return hdxServer('saved-search', {
method: 'POST',
json: data,
@ -67,6 +67,15 @@ export function useUpdateSavedSearch() {
return useMutation({
mutationFn: (data: Partial<SavedSearch> & { id: SavedSearch['id'] }) => {
if (IS_LOCAL_MODE) {
const { id, ...updates } = data;
return Promise.resolve(
localSavedSearches.update(
id,
updates,
) as SavedSearchWithEnhancedAlerts,
);
}
return hdxServer(`saved-search/${data.id}`, {
method: 'PATCH',
json: data,
@ -83,6 +92,10 @@ export function useDeleteSavedSearch() {
return useMutation({
mutationFn: (id: string) => {
if (IS_LOCAL_MODE) {
localSavedSearches.delete(id);
return Promise.resolve();
}
return hdxServer(`saved-search/${id}`, { method: 'DELETE' }).json<void>();
},
onSuccess: () => {

View file

@ -2,7 +2,6 @@
// SourceForm.tsx and remove type assertions for TSource and TSourceUnion
import pick from 'lodash/pick';
import objectHash from 'object-hash';
import store from 'store2';
import {
ColumnMeta,
extractColumnReferencesFromKey,
@ -10,10 +9,7 @@ import {
JSDataType,
} from '@hyperdx/common-utils/dist/clickhouse';
import { Metadata } from '@hyperdx/common-utils/dist/core/metadata';
import {
hashCode,
splitAndTrimWithBracket,
} from '@hyperdx/common-utils/dist/core/utils';
import { splitAndTrimWithBracket } from '@hyperdx/common-utils/dist/core/utils';
import {
MetricsDataType,
SourceKind,
@ -23,11 +19,8 @@ import {
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { hdxServer } from '@/api';
import { HDX_LOCAL_DEFAULT_SOURCES } from '@/config';
import { IS_LOCAL_MODE } from '@/config';
import { parseJSON } from '@/utils';
import { getLocalConnections } from './connection';
import { localSources } from '@/localStore';
// Columns for the sessions table as of OTEL Collector v0.129.1
export const SESSION_TABLE_EXPRESSIONS = {
@ -42,30 +35,6 @@ export const JSON_SESSION_TABLE_EXPRESSIONS = {
timestampValueExpression: 'Timestamp',
} as const;
const LOCAL_STORE_SOUCES_KEY = 'hdx-local-source';
function setLocalSources(fn: (prev: TSource[]) => TSource[]) {
store.transact(LOCAL_STORE_SOUCES_KEY, fn, []);
}
function getLocalSources(): TSource[] {
const connections = getLocalConnections();
if (connections.length > 0 && store.has(LOCAL_STORE_SOUCES_KEY)) {
return store.get(LOCAL_STORE_SOUCES_KEY, []) ?? [];
}
// pull sources from env var
try {
const defaultSources = parseJSON(HDX_LOCAL_DEFAULT_SOURCES ?? '');
if (defaultSources != null) {
return defaultSources;
}
} catch (e) {
console.error('Error fetching default sources', e);
}
// fallback to empty array
return [];
}
// If a user specifies a timestampValueExpression with multiple columns,
// this will return the first one. We'll want to refine this over time
export function getFirstTimestampValueExpression(valueExpression: string) {
@ -109,9 +78,8 @@ export function useSources() {
queryKey: ['sources'],
queryFn: async () => {
if (IS_LOCAL_MODE) {
return getLocalSources();
return localSources.getAll();
}
const rawSources = await hdxServer('sources').json<TSourceUnion[]>();
return rawSources.map(addDefaultsToSource);
},
@ -122,12 +90,11 @@ export function useSource({ id }: { id?: string | null }) {
return useQuery({
queryKey: ['sources'],
queryFn: async () => {
if (!IS_LOCAL_MODE) {
const rawSources = await hdxServer('sources').json<TSourceUnion[]>();
return rawSources.map(addDefaultsToSource);
} else {
return getLocalSources();
if (IS_LOCAL_MODE) {
return localSources.getAll();
}
const rawSources = await hdxServer('sources').json<TSourceUnion[]>();
return rawSources.map(addDefaultsToSource);
},
select: (data: TSource[]): TSource => {
return data.filter((s: any) => s.id === id)[0];
@ -142,20 +109,13 @@ export function useUpdateSource() {
return useMutation({
mutationFn: async ({ source }: { source: TSource }) => {
if (IS_LOCAL_MODE) {
setLocalSources(prev => {
return prev.map(s => {
if (s.id === source.id) {
return source;
}
return s;
});
});
} else {
return await hdxServer(`sources/${source.id}`, {
method: 'PUT',
json: source,
});
localSources.update(source.id, source);
return;
}
return hdxServer(`sources/${source.id}`, {
method: 'PUT',
json: source,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sources'] });
@ -169,27 +129,19 @@ export function useCreateSource() {
const mut = useMutation({
mutationFn: async ({ source }: { source: Omit<TSource, 'id'> }) => {
if (IS_LOCAL_MODE) {
const localSources = getLocalSources();
const existingSource = localSources.find(
stored =>
objectHash(pick(stored, ['kind', 'name', 'connection'])) ===
objectHash(pick(source, ['kind', 'name', 'connection'])),
);
if (existingSource) {
// replace the existing source with the new one
return {
...source,
id: existingSource.id,
};
const existing = localSources
.getAll()
.find(
stored =>
objectHash(pick(stored, ['kind', 'name', 'connection'])) ===
objectHash(pick(source, ['kind', 'name', 'connection'])),
);
if (existing) {
// Replace the existing source in-place rather than duplicating
localSources.update(existing.id, source);
return { ...source, id: existing.id } as TSource;
}
const newSource = {
...source,
id: `l${hashCode(Math.random().toString())}`,
};
setLocalSources(prev => {
return [...prev, newSource];
});
return newSource;
return localSources.create(source);
}
return hdxServer(`sources`, {
@ -211,9 +163,7 @@ export function useDeleteSource() {
return useMutation({
mutationFn: async ({ id }: { id: string }) => {
if (IS_LOCAL_MODE) {
setLocalSources(prev => {
return prev.filter(s => s.id !== id);
});
localSources.delete(id);
return;
}
return hdxServer(`sources/${id}`, { method: 'DELETE' });

View file

@ -36,87 +36,83 @@ test.describe('Dashboard', { tag: ['@dashboard'] }, () => {
},
);
test(
'should persist dashboard across page reloads',
{ tag: '@full-stack' },
async () => {
const uniqueDashboardName = `Test Dashboard ${Date.now()}`;
test('should persist dashboard across page reloads', {}, async () => {
const uniqueDashboardName = `Test Dashboard ${Date.now()}`;
await test.step('Create and name a new dashboard', async () => {
// Create dashboard using page object
await expect(dashboardPage.createButton).toBeVisible();
await dashboardPage.createNewDashboard();
await test.step('Create and name a new dashboard', async () => {
// Create dashboard using page object
await expect(dashboardPage.createButton).toBeVisible();
await dashboardPage.createNewDashboard();
// Edit dashboard name using page object method
await dashboardPage.editDashboardName(uniqueDashboardName);
});
// Edit dashboard name using page object method
await dashboardPage.editDashboardName(uniqueDashboardName);
});
await test.step('Add a tile to the dashboard', async () => {
// Open add tile modal
await expect(dashboardPage.addNewTileButton).toBeVisible();
await dashboardPage.addTile();
await test.step('Add a tile to the dashboard', async () => {
// Open add tile modal
await expect(dashboardPage.addNewTileButton).toBeVisible();
await dashboardPage.addTile();
// Create chart using chart editor component
await expect(dashboardPage.chartEditor.nameInput).toBeVisible();
await dashboardPage.chartEditor.createBasicChart(
'Persistence Test Chart',
);
// Create chart using chart editor component
await expect(dashboardPage.chartEditor.nameInput).toBeVisible();
await dashboardPage.chartEditor.createBasicChart(
'Persistence Test Chart',
);
// Wait for tile to appear first (wrapper element)
const dashboardTiles = dashboardPage.getTiles();
await expect(dashboardTiles).toHaveCount(1, { timeout: 10000 });
// Wait for tile to appear first (wrapper element)
const dashboardTiles = dashboardPage.getTiles();
await expect(dashboardTiles).toHaveCount(1, { timeout: 10000 });
// Then verify chart rendered inside (recharts can take time to initialize)
const chartContainers = dashboardPage.getChartContainers();
await expect(chartContainers).toHaveCount(1, { timeout: 10000 });
});
// Then verify chart rendered inside (recharts can take time to initialize)
const chartContainers = dashboardPage.getChartContainers();
await expect(chartContainers).toHaveCount(1, { timeout: 10000 });
});
let dashboardUrl: string;
await test.step('Save dashboard URL', async () => {
dashboardUrl = dashboardPage.page.url();
console.log(`Dashboard URL: ${dashboardUrl}`);
});
let dashboardUrl: string;
await test.step('Save dashboard URL', async () => {
dashboardUrl = dashboardPage.page.url();
console.log(`Dashboard URL: ${dashboardUrl}`);
});
await test.step('Navigate away from dashboard', async () => {
await dashboardPage.page.goto('/search');
await expect(dashboardPage.page).toHaveURL(/.*\/search/);
});
await test.step('Navigate away from dashboard', async () => {
await dashboardPage.page.goto('/search');
await expect(dashboardPage.page).toHaveURL(/.*\/search/);
});
await test.step('Return to dashboard and verify persistence', async () => {
await dashboardPage.page.goto(dashboardUrl);
await test.step('Return to dashboard and verify persistence', async () => {
await dashboardPage.page.goto(dashboardUrl);
// Wait for dashboard to load by checking for tiles first
const dashboardTiles = dashboardPage.getTiles();
await expect(dashboardTiles).toHaveCount(1);
// Wait for dashboard to load by checking for tiles first
const dashboardTiles = dashboardPage.getTiles();
await expect(dashboardTiles).toHaveCount(1);
// Verify dashboard name persisted (displayed as h3 title)
const dashboardNameHeading =
dashboardPage.getDashboardHeading(uniqueDashboardName);
await expect(dashboardNameHeading).toBeVisible({ timeout: 5000 });
// Verify dashboard name persisted (displayed as h3 title)
const dashboardNameHeading =
dashboardPage.getDashboardHeading(uniqueDashboardName);
await expect(dashboardNameHeading).toBeVisible({ timeout: 5000 });
// Verify chart still shows
const chartContainers = dashboardPage.getChartContainers();
await expect(chartContainers.first()).toBeVisible();
});
// Verify chart still shows
const chartContainers = dashboardPage.getChartContainers();
await expect(chartContainers.first()).toBeVisible();
});
await test.step('Verify dashboard appears in dashboards list', async () => {
await dashboardPage.goto();
await test.step('Verify dashboard appears in dashboards list', async () => {
await dashboardPage.goto();
// Look for our dashboard in the list
const dashboardLink = dashboardPage.page.locator(
`text="${uniqueDashboardName}"`,
);
await expect(dashboardLink).toBeVisible({ timeout: 10000 });
// Look for our dashboard in the list
const dashboardLink = dashboardPage.page.locator(
`text="${uniqueDashboardName}"`,
);
await expect(dashboardLink).toBeVisible({ timeout: 10000 });
// Click on it and verify it loads
await dashboardPage.goToDashboardByName(uniqueDashboardName);
// Click on it and verify it loads
await dashboardPage.goToDashboardByName(uniqueDashboardName);
// Verify we're on the right dashboard
const dashboardTiles = dashboardPage.getTiles();
await expect(dashboardTiles).toHaveCount(1);
});
},
);
// Verify we're on the right dashboard
const dashboardTiles = dashboardPage.getTiles();
await expect(dashboardTiles).toHaveCount(1);
});
});
test('Comprehensive dashboard workflow - create, add tiles, configure, and test', async () => {
test.setTimeout(60000);
await test.step('Create new dashboard', async () => {
@ -409,176 +405,162 @@ test.describe('Dashboard', { tag: ['@dashboard'] }, () => {
await expect(dashboardPage.unsavedChangesConfirmModal).toBeHidden();
});
test(
'should create and populate filters',
{ tag: '@full-stack' },
async () => {
test.setTimeout(30000);
test('should create and populate filters', {}, async () => {
test.setTimeout(30000);
await test.step('Create new dashboard', async () => {
await expect(dashboardPage.createButton).toBeVisible();
await dashboardPage.createNewDashboard();
await test.step('Create new dashboard', async () => {
await expect(dashboardPage.createButton).toBeVisible();
await dashboardPage.createNewDashboard();
});
await test.step('Create a table tile to filter', async () => {
await dashboardPage.addTile();
await dashboardPage.chartEditor.createTable({
chartName: 'Test Table',
sourceName: DEFAULT_LOGS_SOURCE_NAME,
groupBy: 'ServiceName',
});
await test.step('Create a table tile to filter', async () => {
await dashboardPage.addTile();
await dashboardPage.chartEditor.createTable({
chartName: 'Test Table',
sourceName: DEFAULT_LOGS_SOURCE_NAME,
groupBy: 'ServiceName',
});
const accountCell = dashboardPage.page.getByTitle('accounting', {
exact: true,
});
const adCell = dashboardPage.page.getByTitle('ad', { exact: true });
await expect(accountCell).toBeVisible();
await expect(adCell).toBeVisible();
const accountCell = dashboardPage.page.getByTitle('accounting', {
exact: true,
});
const adCell = dashboardPage.page.getByTitle('ad', { exact: true });
await expect(accountCell).toBeVisible();
await expect(adCell).toBeVisible();
});
await test.step('Add ServiceName filter to dashboard', async () => {
await dashboardPage.openEditFiltersModal();
await expect(dashboardPage.emptyFiltersList).toBeVisible();
await test.step('Add ServiceName filter to dashboard', async () => {
await dashboardPage.openEditFiltersModal();
await expect(dashboardPage.emptyFiltersList).toBeVisible();
await dashboardPage.addFilterToDashboard(
'Service',
DEFAULT_LOGS_SOURCE_NAME,
'ServiceName',
);
await dashboardPage.addFilterToDashboard(
'Service',
DEFAULT_LOGS_SOURCE_NAME,
'ServiceName',
);
await expect(
dashboardPage.getFilterItemByName('Service'),
).toBeVisible();
await expect(dashboardPage.getFilterItemByName('Service')).toBeVisible();
await dashboardPage.closeFiltersModal();
await dashboardPage.closeFiltersModal();
});
await test.step('Add MetricName filter to dashboard', async () => {
await dashboardPage.openEditFiltersModal();
await expect(dashboardPage.filtersList).toBeVisible();
await dashboardPage.addFilterToDashboard(
'Metric',
DEFAULT_METRICS_SOURCE_NAME,
'MetricName',
'gauge',
);
await expect(dashboardPage.getFilterItemByName('Metric')).toBeVisible();
await dashboardPage.closeFiltersModal();
});
await test.step('Verify tiles are filtered', async () => {
// Select 'accounting' in Service filter
await dashboardPage.clickFilterOption('Service', 'accounting');
const accountCell = dashboardPage.page.getByTitle('accounting', {
exact: true,
});
await expect(accountCell).toBeVisible();
await test.step('Add MetricName filter to dashboard', async () => {
await dashboardPage.openEditFiltersModal();
await expect(dashboardPage.filtersList).toBeVisible();
// 'ad' ServiceName row should be filtered out
const adCell = dashboardPage.page.getByTitle('ad', { exact: true });
await expect(adCell).toHaveCount(0);
});
await dashboardPage.addFilterToDashboard(
'Metric',
DEFAULT_METRICS_SOURCE_NAME,
'MetricName',
'gauge',
);
await test.step('Verify metric filter is populated', async () => {
await dashboardPage.clickFilterOption(
'Metric',
'container.cpu.utilization',
);
});
await expect(dashboardPage.getFilterItemByName('Metric')).toBeVisible();
await test.step('Delete a filter and verify it is removed', async () => {
await dashboardPage.openEditFiltersModal();
await dashboardPage.deleteFilterFromDashboard('Metric');
await dashboardPage.closeFiltersModal();
});
// Service filter should still be visible
await expect(dashboardPage.getFilterItemByName('Service')).toBeVisible();
await test.step('Verify tiles are filtered', async () => {
// Select 'accounting' in Service filter
await dashboardPage.clickFilterOption('Service', 'accounting');
// Metric filter should be gone
await expect(dashboardPage.getFilterItemByName('Metric')).toHaveCount(0);
});
});
const accountCell = dashboardPage.page.getByTitle('accounting', {
exact: true,
});
await expect(accountCell).toBeVisible();
test('should save and restore query and filter values', {}, async () => {
const testQuery = 'level:error';
let dashboardUrl: string;
// 'ad' ServiceName row should be filtered out
const adCell = dashboardPage.page.getByTitle('ad', { exact: true });
await expect(adCell).toHaveCount(0);
});
await test.step('Create dashboard with chart', async () => {
await dashboardPage.createNewDashboard();
await test.step('Verify metric filter is populated', async () => {
await dashboardPage.clickFilterOption(
'Metric',
'container.cpu.utilization',
);
});
// Add a tile so dashboard is saveable
await dashboardPage.addTile();
await dashboardPage.chartEditor.createBasicChart('Test Chart');
await test.step('Delete a filter and verify it is removed', async () => {
await dashboardPage.openEditFiltersModal();
await dashboardPage.deleteFilterFromDashboard('Metric');
const chartContainers = dashboardPage.getChartContainers();
await expect(chartContainers).toHaveCount(1, { timeout: 10000 });
// Service filter should still be visible
await expect(
dashboardPage.getFilterItemByName('Service'),
).toBeVisible();
// Save dashboard URL for later
dashboardUrl = dashboardPage.page.url();
});
// Metric filter should be gone
await expect(dashboardPage.getFilterItemByName('Metric')).toHaveCount(
0,
);
});
},
);
await test.step('Enter query in search bar', async () => {
const searchInput = dashboardPage.searchInput;
await expect(searchInput).toBeVisible();
await searchInput.fill(testQuery);
});
test(
'should save and restore query and filter values',
{ tag: '@full-stack' },
async () => {
const testQuery = 'level:error';
let dashboardUrl: string;
await test.step('Click save query button', async () => {
await dashboardPage.saveQueryAndFiltersAsDefault();
await test.step('Create dashboard with chart', async () => {
await dashboardPage.createNewDashboard();
// Wait for success notification
const notification = dashboardPage.page.locator(
'text=/Filter query and dropdown values/i',
);
await expect(notification).toBeVisible({ timeout: 5000 });
});
// Add a tile so dashboard is saveable
await dashboardPage.addTile();
await dashboardPage.chartEditor.createBasicChart('Test Chart');
await test.step('Navigate away from dashboard', async () => {
await dashboardPage.page.goto('/search');
await expect(dashboardPage.page).toHaveURL(/.*\/search/);
});
const chartContainers = dashboardPage.getChartContainers();
await expect(chartContainers).toHaveCount(1, { timeout: 10000 });
await test.step('Return to dashboard and verify query restored', async () => {
await dashboardPage.page.goto(dashboardUrl);
// Save dashboard URL for later
dashboardUrl = dashboardPage.page.url();
});
// Wait for dashboard controls to load
await expect(
dashboardPage.page.getByTestId('dashboard-page'),
).toBeVisible({ timeout: 10000 });
await expect(dashboardPage.searchInput).toBeVisible({ timeout: 10000 });
await test.step('Enter query in search bar', async () => {
const searchInput = dashboardPage.searchInput;
await expect(searchInput).toBeVisible();
await searchInput.fill(testQuery);
});
// Verify saved query is restored in search input
const searchInput = dashboardPage.searchInput;
await expect(searchInput).toHaveValue(testQuery);
});
await test.step('Click save query button', async () => {
await dashboardPage.saveQueryAndFiltersAsDefault();
await test.step('Clear URL params and verify query persists', async () => {
// Extract dashboard ID and navigate without query params
const dashboardId = dashboardUrl.split('/').pop()?.split('?')[0];
await dashboardPage.page.goto(`/dashboards/${dashboardId}`);
// Wait for success notification
const notification = dashboardPage.page.locator(
'text=/Filter query and dropdown values/i',
);
await expect(notification).toBeVisible({ timeout: 5000 });
});
await test.step('Navigate away from dashboard', async () => {
await dashboardPage.page.goto('/search');
await expect(dashboardPage.page).toHaveURL(/.*\/search/);
});
await test.step('Return to dashboard and verify query restored', async () => {
await dashboardPage.page.goto(dashboardUrl);
// Wait for dashboard controls to load
await expect(
dashboardPage.page.getByTestId('dashboard-page'),
).toBeVisible({ timeout: 10000 });
await expect(dashboardPage.searchInput).toBeVisible({ timeout: 10000 });
// Verify saved query is restored in search input
const searchInput = dashboardPage.searchInput;
await expect(searchInput).toHaveValue(testQuery);
});
await test.step('Clear URL params and verify query persists', async () => {
// Extract dashboard ID and navigate without query params
const dashboardId = dashboardUrl.split('/').pop()?.split('?')[0];
await dashboardPage.page.goto(`/dashboards/${dashboardId}`);
// Verify saved query still loads
const searchInput = dashboardPage.searchInput;
await expect(searchInput).toHaveValue(testQuery);
});
},
);
// Verify saved query still loads
const searchInput = dashboardPage.searchInput;
await expect(searchInput).toHaveValue(testQuery);
});
});
test(
'should handle URL query params overriding saved query',
{ tag: '@full-stack' },
{},
async () => {
const savedQuery = 'level:error';
const urlQuery = 'status:active';
@ -635,7 +617,7 @@ test.describe('Dashboard', { tag: ['@dashboard'] }, () => {
test(
'should clear saved query when WHERE input is cleared and saved',
{ tag: '@full-stack' },
{},
async () => {
const testQuery = 'level:warn';
let dashboardUrl: string;

View file

@ -7,7 +7,7 @@ import {
import { SearchPage } from '../../page-objects/SearchPage';
import { expect, test } from '../../utils/base-test';
test.describe('Saved Search Functionality', { tag: '@full-stack' }, () => {
test.describe('Saved Search Functionality', () => {
let searchPage: SearchPage;
test.beforeEach(async ({ page }) => {
@ -21,7 +21,7 @@ test.describe('Saved Search Functionality', { tag: '@full-stack' }, () => {
test(
'should preserve custom SELECT when navigating between saved searches',
{ tag: '@full-stack' },
{},
async ({ page }) => {
/**
* This test verifies the fix for issue where SELECT statement would not
@ -74,7 +74,7 @@ test.describe('Saved Search Functionality', { tag: '@full-stack' }, () => {
test(
'should restore saved search SELECT after switching sources',
{ tag: '@full-stack' },
{},
async ({ page }) => {
/**
* This test verifies that SELECT properly updates when switching between
@ -131,7 +131,7 @@ test.describe('Saved Search Functionality', { tag: '@full-stack' }, () => {
test(
'should use default SELECT when switching sources within a saved search',
{ tag: '@full-stack' },
{},
async ({ page }) => {
await test.step('Create and navigate to saved search', async () => {
const customSelect =
@ -179,7 +179,7 @@ test.describe('Saved Search Functionality', { tag: '@full-stack' }, () => {
test(
'should load saved search when navigating from another page',
{ tag: '@full-stack' },
{},
async ({ page }) => {
/**
* This test verifies the fix for the issue where saved searches would not
@ -257,7 +257,7 @@ test.describe('Saved Search Functionality', { tag: '@full-stack' }, () => {
test(
'should preserve custom SELECT when loading saved search from another page',
{ tag: '@full-stack' },
{},
async ({ page }) => {
/**
* This test specifically verifies that custom SELECT statements are preserved
@ -305,7 +305,7 @@ test.describe('Saved Search Functionality', { tag: '@full-stack' }, () => {
test(
'should handle navigation via browser back button',
{ tag: '@full-stack' },
{},
async ({ page }) => {
/**
* This test verifies that using browser back/forward navigation
@ -345,7 +345,7 @@ test.describe('Saved Search Functionality', { tag: '@full-stack' }, () => {
test(
'should update ORDER BY when switching sources multiple times',
{ tag: '@full-stack' },
{},
async ({ page }) => {
/**
* This test verifies the fix for the issue where ORDER BY does not update
@ -415,7 +415,7 @@ test.describe('Saved Search Functionality', { tag: '@full-stack' }, () => {
test(
'should save and restore filters with saved searches',
{ tag: '@full-stack' },
{},
async ({ page }) => {
/**
* This test verifies that filters applied in the sidebar are saved
@ -497,7 +497,7 @@ test.describe('Saved Search Functionality', { tag: '@full-stack' }, () => {
test(
'should update filters when updating a saved search',
{ tag: '@full-stack' },
{},
async ({ page }) => {
/**
* Verifies that updating a saved search with additional filters