mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
68ef3d6f97
commit
b4f0558776
13 changed files with 956 additions and 494 deletions
5
.changeset/metal-shrimps-relax.md
Normal file
5
.changeset/metal-shrimps-relax.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: localStorage for dashboards/saved searches in LOCAL mode
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
87
packages/app/src/__tests__/dashboard.test.ts
Normal file
87
packages/app/src/__tests__/dashboard.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
323
packages/app/src/__tests__/localStore.test.ts
Normal file
323
packages/app/src/__tests__/localStore.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: () => {
|
||||
|
|
|
|||
101
packages/app/src/localStore.ts
Normal file
101
packages/app/src/localStore.ts
Normal 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',
|
||||
);
|
||||
|
|
@ -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: () => {
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue