mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Add saved searches listing page (#2012)
## Summary This PR moves saved searches from the sidebar to a new Saved Search listing page, for consistency with the new dashboards listing page. ### Screenshots or video https://github.com/user-attachments/assets/11afec45-2a50-4f52-aad7-9a441ac115f5 ### How to test locally or on Vercel This can be tested in the preview environment (but the alert indicator will only work if you run it locally, not in LOCAL_MODE). ### References - Linear Issue: Closes HDX-3833 Closes HDX-2066 Closes HDX-2633 - Related PRs:
This commit is contained in:
parent
05a1b76588
commit
e5c7fdf924
16 changed files with 920 additions and 554 deletions
6
.changeset/pink-coats-leave.md
Normal file
6
.changeset/pink-coats-leave.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Add saved searches listing page
|
||||
2
packages/app/pages/search/list.tsx
Normal file
2
packages/app/pages/search/list.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import SavedSearchesListPage from '@/components/SavedSearches/SavedSearchesListPage';
|
||||
export default SavedSearchesListPage;
|
||||
|
|
@ -113,6 +113,7 @@ import { useConnections } from './connection';
|
|||
import { useDashboard } from './dashboard';
|
||||
import DashboardFilters from './DashboardFilters';
|
||||
import DashboardFiltersModal from './DashboardFiltersModal';
|
||||
import { EditablePageName } from './EditablePageName';
|
||||
import { GranularityPickerControlled } from './GranularityPicker';
|
||||
import HDXMarkdownChart from './HDXMarkdownChart';
|
||||
import { withAppNav } from './layout';
|
||||
|
|
@ -755,68 +756,6 @@ const updateLayout = (newLayout: RGL.Layout[]) => {
|
|||
};
|
||||
};
|
||||
|
||||
function DashboardName({
|
||||
name,
|
||||
onSave,
|
||||
}: {
|
||||
name: string;
|
||||
onSave: (name: string) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editedName, setEditedName] = useState(name);
|
||||
|
||||
const { hovered, ref } = useHover();
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
pe="md"
|
||||
onDoubleClick={() => setEditing(true)}
|
||||
className="cursor-pointer"
|
||||
title="Double click to edit"
|
||||
>
|
||||
{editing ? (
|
||||
<form
|
||||
className="d-flex align-items-center"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
onSave(editedName);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={editedName}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setEditedName(e.target.value)
|
||||
}
|
||||
placeholder="Dashboard Name"
|
||||
/>
|
||||
<Button ms="sm" variant="primary" type="submit">
|
||||
Save Name
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="d-flex align-items-center" style={{ minWidth: 100 }}>
|
||||
<Title fw={400} order={3}>
|
||||
{name}
|
||||
</Title>
|
||||
{hovered && (
|
||||
<Button
|
||||
ms="xs"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<IconPencil size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Download an object to users computer as JSON using specified name
|
||||
function downloadObjectAsJson(object: object, fileName = 'output') {
|
||||
const dataStr =
|
||||
|
|
@ -1610,7 +1549,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
|
|||
</Breadcrumbs>
|
||||
)}
|
||||
<Flex mt="xs" mb="md" justify="space-between" align="center">
|
||||
<DashboardName
|
||||
<EditablePageName
|
||||
key={`${dashboardHash}`}
|
||||
name={dashboard?.name ?? ''}
|
||||
onSave={editedName => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import router from 'next/router';
|
||||
import {
|
||||
parseAsBoolean,
|
||||
|
|
@ -42,7 +43,9 @@ import {
|
|||
} from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Box,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
|
|
@ -135,6 +138,7 @@ import {
|
|||
import api from './api';
|
||||
import { LOCAL_STORE_CONNECTIONS_KEY } from './connection';
|
||||
import { DBSearchPageAlertModal } from './DBSearchPageAlertModal';
|
||||
import { EditablePageName } from './EditablePageName';
|
||||
import { SearchConfig } from './types';
|
||||
|
||||
import searchPageStyles from '../styles/SearchPage.module.scss';
|
||||
|
|
@ -1571,6 +1575,61 @@ function DBSearchPage() {
|
|||
/>
|
||||
)}
|
||||
<OnboardingModal />
|
||||
{savedSearch && (
|
||||
<Group justify="space-between" align="flex-end" mt="lg" mx="xs">
|
||||
<Stack gap={0}>
|
||||
<Breadcrumbs fz="sm" mb="xs">
|
||||
<Anchor component={Link} href="/search/list" fz="sm" c="dimmed">
|
||||
Saved Searches
|
||||
</Anchor>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{savedSearch.name}
|
||||
</Text>
|
||||
</Breadcrumbs>
|
||||
<EditablePageName
|
||||
key={savedSearch.id}
|
||||
name={savedSearch?.name ?? 'Untitled Search'}
|
||||
onSave={editedName => {
|
||||
updateSavedSearch.mutate({
|
||||
id: savedSearch.id,
|
||||
name: editedName,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Group gap="xs">
|
||||
<Tags
|
||||
allowCreate
|
||||
values={savedSearch.tags || []}
|
||||
onChange={handleUpdateTags}
|
||||
>
|
||||
<Button
|
||||
data-testid="tags-button"
|
||||
variant="secondary"
|
||||
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/list');
|
||||
},
|
||||
});
|
||||
}}
|
||||
onClickSaveAsNew={() => {
|
||||
setSaveSearchModalState('create');
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
<form
|
||||
data-testid="search-form"
|
||||
onSubmit={onFormSubmit}
|
||||
|
|
@ -1649,39 +1708,6 @@ function DBSearchPage() {
|
|||
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
|
||||
|
|
|
|||
83
packages/app/src/EditablePageName.tsx
Normal file
83
packages/app/src/EditablePageName.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { useState } from 'react';
|
||||
import { Box, Button, Input, Title } from '@mantine/core';
|
||||
import { useHover } from '@mantine/hooks';
|
||||
import { IconPencil } from '@tabler/icons-react';
|
||||
|
||||
export function EditablePageName({
|
||||
name,
|
||||
onSave,
|
||||
}: {
|
||||
name: string;
|
||||
onSave: (name: string) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editedName, setEditedName] = useState(name);
|
||||
|
||||
const { hovered, ref } = useHover();
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditedName(name);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
pe="md"
|
||||
onDoubleClick={() => setEditing(true)}
|
||||
className="cursor-pointer"
|
||||
title="Double click to edit"
|
||||
>
|
||||
{editing ? (
|
||||
<form
|
||||
className="d-flex align-items-center"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
if (!editedName.trim()) return;
|
||||
onSave(editedName);
|
||||
setEditing(false);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') {
|
||||
cancelEditing();
|
||||
}
|
||||
}}
|
||||
onBlur={e => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
cancelEditing();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={editedName}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setEditedName(e.target.value)
|
||||
}
|
||||
placeholder="Name"
|
||||
autoFocus
|
||||
/>
|
||||
<Button ms="sm" variant="primary" type="submit">
|
||||
Save Name
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="d-flex align-items-center" style={{ minWidth: 100 }}>
|
||||
<Title fw={400} order={3}>
|
||||
{name}
|
||||
</Title>
|
||||
{hovered && (
|
||||
<Button
|
||||
ms="xs"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<IconPencil size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -245,6 +245,7 @@ export const AppNavLink = ({
|
|||
isExpanded,
|
||||
onToggle,
|
||||
isBeta,
|
||||
isActive,
|
||||
}: {
|
||||
className?: string;
|
||||
label: React.ReactNode;
|
||||
|
|
@ -253,6 +254,7 @@ export const AppNavLink = ({
|
|||
isExpanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
isBeta?: boolean;
|
||||
isActive?: boolean;
|
||||
}) => {
|
||||
const { pathname, isCollapsed } = React.useContext(AppNavContext);
|
||||
|
||||
|
|
@ -268,7 +270,8 @@ export const AppNavLink = ({
|
|||
|
||||
// Check if current path matches this nav item
|
||||
// Use exact match or startsWith to avoid partial matches (e.g., /search matching /search-settings)
|
||||
const isActive = pathname === href || pathname?.startsWith(href + '/');
|
||||
const defaultIsActive = pathname === href || pathname?.startsWith(href + '/');
|
||||
const isActiveResolved = isActive ?? defaultIsActive;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
|
@ -276,7 +279,7 @@ export const AppNavLink = ({
|
|||
href={href}
|
||||
className={cx(
|
||||
styles.navItem,
|
||||
{ [styles.navItemActive]: isActive },
|
||||
{ [styles.navItemActive]: isActiveResolved },
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,13 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Router, { useRouter } from 'next/router';
|
||||
import cx from 'classnames';
|
||||
import Fuse from 'fuse.js';
|
||||
import {
|
||||
NumberParam,
|
||||
StringParam,
|
||||
useQueryParam,
|
||||
useQueryParams,
|
||||
withDefault,
|
||||
} from 'use-query-params';
|
||||
import HyperDX from '@hyperdx/browser';
|
||||
import { AlertState } from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Badge,
|
||||
CloseButton,
|
||||
Collapse,
|
||||
Group,
|
||||
Input,
|
||||
Loader,
|
||||
ScrollArea,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
|
|
@ -28,14 +15,10 @@ import { useDisclosure, useLocalStorage } from '@mantine/hooks';
|
|||
import {
|
||||
IconArrowBarToLeft,
|
||||
IconBell,
|
||||
IconBellFilled,
|
||||
IconChartDots,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconCommand,
|
||||
IconDeviceFloppy,
|
||||
IconDeviceLaptop,
|
||||
IconLayoutGrid,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconSitemap,
|
||||
IconTable,
|
||||
|
|
@ -45,9 +28,7 @@ import api from '@/api';
|
|||
import { IS_LOCAL_MODE } from '@/config';
|
||||
import InstallInstructionModal from '@/InstallInstructionsModal';
|
||||
import OnboardingChecklist from '@/OnboardingChecklist';
|
||||
import { useSavedSearches, useUpdateSavedSearch } from '@/savedSearch';
|
||||
import { useLogomark, useWordmark } from '@/theme/ThemeProvider';
|
||||
import type { SavedSearch } from '@/types';
|
||||
import { UserPreferencesModal } from '@/UserPreferencesModal';
|
||||
import { useUserPreferences } from '@/useUserPreferences';
|
||||
import { useWindowSize } from '@/utils';
|
||||
|
|
@ -68,8 +49,6 @@ import styles from './AppNav.module.scss';
|
|||
const APP_VERSION =
|
||||
process.env.NEXT_PUBLIC_APP_VERSION ?? packageJson.version ?? 'dev';
|
||||
|
||||
const UNTAGGED_SEARCHES_GROUP_NAME = 'Saved Searches';
|
||||
|
||||
// Navigation link configuration
|
||||
type NavLinkConfig = {
|
||||
id: string;
|
||||
|
|
@ -109,234 +88,6 @@ const NAV_LINKS: NavLinkConfig[] = [
|
|||
},
|
||||
];
|
||||
|
||||
function SearchInput({
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
onEnterDown,
|
||||
}: {
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onChange: (arg0: string) => void;
|
||||
onEnterDown?: () => void;
|
||||
}) {
|
||||
const kbdShortcut = useMemo(() => {
|
||||
return (
|
||||
<div className={styles.shortcutHint}>
|
||||
{window.navigator.platform?.toUpperCase().includes('MAC') ? (
|
||||
<IconCommand size={8} />
|
||||
) : (
|
||||
<span className={styles.shortcutHintCtrl}>Ctrl</span>
|
||||
)}
|
||||
K
|
||||
</div>
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
onEnterDown?.();
|
||||
}
|
||||
},
|
||||
[onEnterDown],
|
||||
);
|
||||
|
||||
return (
|
||||
<Input
|
||||
data-testid="nav-search-input"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(e.currentTarget.value)
|
||||
}
|
||||
leftSection={<IconSearch size={16} className="ps-1" />}
|
||||
onKeyDown={handleKeyDown}
|
||||
rightSection={
|
||||
value ? (
|
||||
<CloseButton
|
||||
data-testid="nav-search-clear"
|
||||
tabIndex={-1}
|
||||
size="xs"
|
||||
radius="xl"
|
||||
onClick={() => onChange('')}
|
||||
/>
|
||||
) : (
|
||||
kbdShortcut
|
||||
)
|
||||
}
|
||||
mt={8}
|
||||
mb="sm"
|
||||
size="xs"
|
||||
variant="filled"
|
||||
radius="xl"
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface AppNavLinkItem {
|
||||
id: string;
|
||||
name: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
type AppNavLinkGroup<T extends AppNavLinkItem> = {
|
||||
name: string;
|
||||
items: T[];
|
||||
};
|
||||
|
||||
const AppNavGroupLabel = ({
|
||||
name,
|
||||
collapsed,
|
||||
onClick,
|
||||
}: {
|
||||
name: string;
|
||||
collapsed: boolean;
|
||||
onClick: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.groupLabel} onClick={onClick}>
|
||||
{collapsed ? (
|
||||
<IconChevronRight size={14} />
|
||||
) : (
|
||||
<IconChevronDown size={14} />
|
||||
)}
|
||||
<div>{name}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AppNavLinkGroups = <T extends AppNavLinkItem>({
|
||||
name,
|
||||
groups,
|
||||
renderLink,
|
||||
onDragEnd,
|
||||
forceExpandGroups = false,
|
||||
}: {
|
||||
name: string;
|
||||
groups: AppNavLinkGroup<T>[];
|
||||
renderLink: (item: T) => React.ReactNode;
|
||||
onDragEnd?: (target: HTMLElement | null, newGroup: string | null) => void;
|
||||
forceExpandGroups?: boolean;
|
||||
}) => {
|
||||
const [collapsedGroups, setCollapsedGroups] = useLocalStorage<
|
||||
Record<string, boolean>
|
||||
>({
|
||||
key: `collapsedGroups-${name}`,
|
||||
defaultValue: {},
|
||||
});
|
||||
|
||||
const handleToggleGroup = useCallback(
|
||||
(groupName: string) => {
|
||||
setCollapsedGroups({
|
||||
...collapsedGroups,
|
||||
[groupName]: !collapsedGroups[groupName],
|
||||
});
|
||||
},
|
||||
[collapsedGroups, setCollapsedGroups],
|
||||
);
|
||||
|
||||
const [draggingOver, setDraggingOver] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{groups.map(group => (
|
||||
<div
|
||||
key={group.name}
|
||||
className={cx(draggingOver === group.name && styles.groupDragOver)}
|
||||
onDragOver={e => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDraggingOver(group.name);
|
||||
}}
|
||||
onDragEnd={e => {
|
||||
e.preventDefault();
|
||||
onDragEnd?.(e.target as HTMLElement, draggingOver);
|
||||
setDraggingOver(null);
|
||||
}}
|
||||
>
|
||||
<AppNavGroupLabel
|
||||
onClick={() => handleToggleGroup(group.name)}
|
||||
name={group.name}
|
||||
collapsed={collapsedGroups[group.name]}
|
||||
/>
|
||||
<Collapse in={!collapsedGroups[group.name] || forceExpandGroups}>
|
||||
{group.items.map(item => renderLink(item))}
|
||||
</Collapse>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function useSearchableList<T extends AppNavLinkItem>({
|
||||
items,
|
||||
untaggedGroupName = 'Other',
|
||||
}: {
|
||||
items: T[];
|
||||
untaggedGroupName?: string;
|
||||
}) {
|
||||
const fuse = useMemo(
|
||||
() =>
|
||||
new Fuse(items, {
|
||||
keys: ['name'],
|
||||
threshold: 0.2,
|
||||
ignoreLocation: true,
|
||||
}),
|
||||
[items],
|
||||
);
|
||||
|
||||
const [q, setQ] = useState('');
|
||||
|
||||
const filteredList = useMemo(() => {
|
||||
if (q === '') {
|
||||
return items;
|
||||
}
|
||||
return fuse.search(q).map(result => result.item);
|
||||
}, [fuse, items, q]);
|
||||
|
||||
const groupedFilteredList = useMemo<AppNavLinkGroup<T>[]>(() => {
|
||||
// group by tags
|
||||
const groupedItems: Record<string, T[]> = {};
|
||||
const untaggedItems: T[] = [];
|
||||
filteredList.forEach(item => {
|
||||
if (item.tags?.length) {
|
||||
item.tags.forEach(tag => {
|
||||
groupedItems[tag] = groupedItems[tag] ?? [];
|
||||
groupedItems[tag].push(item);
|
||||
});
|
||||
} else {
|
||||
untaggedItems.push(item);
|
||||
}
|
||||
});
|
||||
if (untaggedItems.length) {
|
||||
groupedItems[untaggedGroupName] = untaggedItems;
|
||||
}
|
||||
return Object.entries(groupedItems)
|
||||
.map(([name, items]) => ({
|
||||
name,
|
||||
items,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.name === untaggedGroupName) {
|
||||
return 1;
|
||||
}
|
||||
if (b.name === untaggedGroupName) {
|
||||
return -1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [filteredList, untaggedGroupName]);
|
||||
|
||||
return {
|
||||
filteredList,
|
||||
groupedFilteredList,
|
||||
q,
|
||||
setQ,
|
||||
};
|
||||
}
|
||||
|
||||
export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
||||
const wordmark = useWordmark();
|
||||
const logomark = useLogomark({ size: 22 });
|
||||
|
|
@ -358,33 +109,11 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: logViewsData,
|
||||
isLoading: isLogViewsLoading,
|
||||
refetch: refetchLogViews,
|
||||
} = useSavedSearches();
|
||||
const logViews = useMemo(() => logViewsData ?? [], [logViewsData]);
|
||||
|
||||
const updateLogView = useUpdateSavedSearch();
|
||||
|
||||
const router = useRouter();
|
||||
const { pathname, query } = router;
|
||||
|
||||
const [timeRangeQuery] = useQueryParams({
|
||||
from: withDefault(NumberParam, -1),
|
||||
to: withDefault(NumberParam, -1),
|
||||
});
|
||||
const [inputTimeQuery] = useQueryParam('tq', withDefault(StringParam, ''), {
|
||||
updateType: 'pushIn',
|
||||
enableBatching: true,
|
||||
});
|
||||
|
||||
const { data: meData } = api.useMe();
|
||||
|
||||
const [isSearchExpanded, setIsSearchExpanded] = useLocalStorage<boolean>({
|
||||
key: 'isSearchExpanded',
|
||||
defaultValue: true,
|
||||
});
|
||||
const { width } = useWindowSize();
|
||||
|
||||
const [isPreferCollapsed, setIsPreferCollapsed] = useLocalStorage<boolean>({
|
||||
|
|
@ -415,95 +144,6 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
}
|
||||
}, [meData]);
|
||||
|
||||
const {
|
||||
q: searchesListQ,
|
||||
setQ: setSearchesListQ,
|
||||
filteredList: filteredSearchesList,
|
||||
groupedFilteredList: groupedFilteredSearchesList,
|
||||
} = useSearchableList({
|
||||
items: logViews,
|
||||
untaggedGroupName: UNTAGGED_SEARCHES_GROUP_NAME,
|
||||
});
|
||||
|
||||
const savedSearchesResultsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const renderLogViewLink = useCallback(
|
||||
(savedSearch: SavedSearch) => (
|
||||
<Link
|
||||
href={`/search/${savedSearch.id}?${new URLSearchParams(
|
||||
timeRangeQuery.from != -1 && timeRangeQuery.to != -1
|
||||
? {
|
||||
from: timeRangeQuery.from.toString(),
|
||||
to: timeRangeQuery.to.toString(),
|
||||
tq: inputTimeQuery,
|
||||
}
|
||||
: {},
|
||||
).toString()}`}
|
||||
key={savedSearch.id}
|
||||
tabIndex={0}
|
||||
className={cx(
|
||||
styles.subMenuItem,
|
||||
savedSearch.id === query.savedSearchId && styles.subMenuItemActive,
|
||||
)}
|
||||
title={savedSearch.name}
|
||||
draggable
|
||||
data-savedsearchid={savedSearch.id}
|
||||
>
|
||||
<Group gap={2}>
|
||||
<div className="d-inline-block text-truncate">{savedSearch.name}</div>
|
||||
{Array.isArray(savedSearch.alerts) &&
|
||||
savedSearch.alerts.length > 0 ? (
|
||||
savedSearch.alerts.some(a => a.state === AlertState.ALERT) ? (
|
||||
<IconBellFilled
|
||||
size={14}
|
||||
className="float-end text-danger ms-1"
|
||||
aria-label="Has Alerts and is in ALERT state"
|
||||
/>
|
||||
) : (
|
||||
<IconBell
|
||||
size={14}
|
||||
className="float-end ms-1"
|
||||
aria-label="Has Alerts and is in OK state"
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</Group>
|
||||
</Link>
|
||||
),
|
||||
[
|
||||
inputTimeQuery,
|
||||
query.savedSearchId,
|
||||
timeRangeQuery.from,
|
||||
timeRangeQuery.to,
|
||||
],
|
||||
);
|
||||
|
||||
const handleLogViewDragEnd = useCallback(
|
||||
(target: HTMLElement | null, name: string | null) => {
|
||||
if (!target?.dataset.savedsearchid || name == null) {
|
||||
return;
|
||||
}
|
||||
const logView = logViews.find(
|
||||
lv => lv.id === target.dataset.savedsearchid,
|
||||
);
|
||||
if (logView?.tags?.includes(name)) {
|
||||
return;
|
||||
}
|
||||
updateLogView.mutate(
|
||||
{
|
||||
id: target.dataset.savedsearchid,
|
||||
tags: name === UNTAGGED_SEARCHES_GROUP_NAME ? [] : [name],
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetchLogViews();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[logViews, refetchLogViews, updateLogView],
|
||||
);
|
||||
|
||||
const [
|
||||
UserPreferencesOpen,
|
||||
{ close: closeUserPreferences, open: openUserPreferences },
|
||||
|
|
@ -593,54 +233,16 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
label="Search"
|
||||
icon={<IconTable size={16} />}
|
||||
href="/search"
|
||||
isExpanded={isSearchExpanded}
|
||||
onToggle={() => setIsSearchExpanded(!isSearchExpanded)}
|
||||
isActive={pathname === '/search'}
|
||||
/>
|
||||
|
||||
{!isCollapsed && (
|
||||
<Collapse in={isSearchExpanded}>
|
||||
<div className={styles.subMenu}>
|
||||
{isLogViewsLoading ? (
|
||||
<Loader variant="dots" mx="md" my="xs" size="sm" />
|
||||
) : (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{searchesListQ && filteredSearchesList.length === 0 ? (
|
||||
<div className={styles.emptyMessage}>
|
||||
No results matching <i>{searchesListQ}</i>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Collapse>
|
||||
)}
|
||||
{/* Saved Searches */}
|
||||
<AppNavLink
|
||||
label="Saved Searches"
|
||||
href="/search/list"
|
||||
icon={<IconDeviceFloppy size={16} />}
|
||||
isActive={pathname?.startsWith('/search/')}
|
||||
/>
|
||||
{/* Simple nav links from config */}
|
||||
{NAV_LINKS.filter(link => !link.cloudOnly || !IS_LOCAL_MODE).map(
|
||||
link => (
|
||||
|
|
@ -662,11 +264,15 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
/>
|
||||
{!isCollapsed && (
|
||||
<Text size="xs" px="lg" py="xs" fw="lighter" fs="italic">
|
||||
Saved dashboards have moved! Try the{' '}
|
||||
Saved searches and dashboards have moved! Try the{' '}
|
||||
<Anchor component={Link} href="/search/list">
|
||||
Saved Searches
|
||||
</Anchor>{' '}
|
||||
or{' '}
|
||||
<Anchor component={Link} href="/dashboards/list">
|
||||
Dashboards page
|
||||
</Anchor>
|
||||
.
|
||||
Dashboards
|
||||
</Anchor>{' '}
|
||||
page.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ import {
|
|||
IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { ListingCard } from '@/components/ListingCard';
|
||||
import { ListingRow } from '@/components/ListingListRow';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { IS_K8S_DASHBOARD_ENABLED } from '@/config';
|
||||
import {
|
||||
|
|
@ -41,9 +43,6 @@ import { useConfirm } from '@/useConfirm';
|
|||
|
||||
import { withAppNav } from '../../layout';
|
||||
|
||||
import { DashboardCard } from './DashboardCard';
|
||||
import { DashboardListRow } from './DashboardListRow';
|
||||
|
||||
const PRESET_DASHBOARDS = [
|
||||
{
|
||||
name: 'Services',
|
||||
|
|
@ -158,7 +157,7 @@ export default function DashboardsListPage() {
|
|||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} mb="xl">
|
||||
{PRESET_DASHBOARDS.map(p => (
|
||||
<DashboardCard key={p.href} {...p} />
|
||||
<ListingCard key={p.href} {...p} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
|
|
@ -298,15 +297,17 @@ export default function DashboardsListPage() {
|
|||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Tags</Table.Th>
|
||||
<Table.Th>Tiles</Table.Th>
|
||||
<Table.Th w={50} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filteredDashboards.map(d => (
|
||||
<DashboardListRow
|
||||
<ListingRow
|
||||
key={d.id}
|
||||
dashboard={d}
|
||||
id={d.id}
|
||||
name={d.name}
|
||||
href={`/dashboards/${d.id}`}
|
||||
tags={d.tags}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -315,7 +316,7 @@ export default function DashboardsListPage() {
|
|||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
|
||||
{filteredDashboards.map(d => (
|
||||
<DashboardCard
|
||||
<ListingCard
|
||||
key={d.id}
|
||||
name={d.name}
|
||||
href={`/dashboards/${d.id}`}
|
||||
|
|
|
|||
|
|
@ -2,18 +2,20 @@ import Link from 'next/link';
|
|||
import { ActionIcon, Badge, Card, Group, Menu, Text } from '@mantine/core';
|
||||
import { IconDots, IconTrash } from '@tabler/icons-react';
|
||||
|
||||
export function DashboardCard({
|
||||
export function ListingCard({
|
||||
name,
|
||||
href,
|
||||
description,
|
||||
tags,
|
||||
onDelete,
|
||||
statusIcon,
|
||||
}: {
|
||||
name: string;
|
||||
href: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
onDelete?: () => void;
|
||||
statusIcon?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
|
|
@ -25,14 +27,17 @@ export function DashboardCard({
|
|||
style={{ cursor: 'pointer', textDecoration: 'none' }}
|
||||
>
|
||||
<Group justify="space-between" mb="xs" wrap="nowrap">
|
||||
<Text
|
||||
fw={500}
|
||||
lineClamp={1}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text
|
||||
fw={500}
|
||||
lineClamp={1}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
{statusIcon}
|
||||
</Group>
|
||||
{onDelete && (
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
|
|
@ -2,17 +2,21 @@ import Router from 'next/router';
|
|||
import { ActionIcon, Badge, Group, Menu, Table, Text } from '@mantine/core';
|
||||
import { IconDots, IconTrash } from '@tabler/icons-react';
|
||||
|
||||
import type { Dashboard } from '../../dashboard';
|
||||
|
||||
export function DashboardListRow({
|
||||
dashboard,
|
||||
export function ListingRow({
|
||||
id,
|
||||
name,
|
||||
href,
|
||||
tags,
|
||||
onDelete,
|
||||
statusIcon,
|
||||
}: {
|
||||
dashboard: Dashboard;
|
||||
id: string;
|
||||
name: string;
|
||||
href: string;
|
||||
tags?: string[];
|
||||
onDelete: (id: string) => void;
|
||||
statusIcon?: React.ReactNode;
|
||||
}) {
|
||||
const href = `/dashboards/${dashboard.id}`;
|
||||
|
||||
return (
|
||||
<Table.Tr
|
||||
style={{ cursor: 'pointer' }}
|
||||
|
|
@ -30,24 +34,22 @@ export function DashboardListRow({
|
|||
}}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text fw={500} size="sm">
|
||||
{dashboard.name}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Text fw={500} size="sm">
|
||||
{name}
|
||||
</Text>
|
||||
{statusIcon}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
{dashboard.tags.map(tag => (
|
||||
{tags?.map(tag => (
|
||||
<Badge key={tag} variant="light" size="xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">
|
||||
{dashboard.tiles.length}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
|
|
@ -65,7 +67,7 @@ export function DashboardListRow({
|
|||
leftSection={<IconTrash size={14} />}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onDelete(dashboard.id);
|
||||
onDelete(id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
import { useCallback, useMemo, useState } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Router from 'next/router';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { AlertState } from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
Group,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
IconBell,
|
||||
IconBellFilled,
|
||||
IconLayoutGrid,
|
||||
IconList,
|
||||
IconSearch,
|
||||
IconTable,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { ListingCard } from '@/components/ListingCard';
|
||||
import { ListingRow } from '@/components/ListingListRow';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useDeleteSavedSearch, useSavedSearches } from '@/savedSearch';
|
||||
import { useBrandDisplayName } from '@/theme/ThemeProvider';
|
||||
import type { SavedSearchWithEnhancedAlerts } from '@/types';
|
||||
import { useConfirm } from '@/useConfirm';
|
||||
|
||||
import { withAppNav } from '../../layout';
|
||||
|
||||
function AlertStatusIcon({
|
||||
alerts,
|
||||
}: {
|
||||
alerts?: SavedSearchWithEnhancedAlerts['alerts'];
|
||||
}) {
|
||||
if (!Array.isArray(alerts) || alerts.length === 0) return null;
|
||||
const alertingCount = alerts.filter(a => a.state === AlertState.ALERT).length;
|
||||
return (
|
||||
<Tooltip
|
||||
label={
|
||||
alertingCount > 0
|
||||
? `${alertingCount} alert${alertingCount > 1 ? 's' : ''} triggered`
|
||||
: 'Alerts configured'
|
||||
}
|
||||
>
|
||||
{alertingCount > 0 ? (
|
||||
<IconBellFilled size={14} color="var(--mantine-color-red-filled)" />
|
||||
) : (
|
||||
<IconBell size={14} />
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SavedSearchesListPage() {
|
||||
const brandName = useBrandDisplayName();
|
||||
const { data: savedSearches, isLoading, isError } = useSavedSearches();
|
||||
const confirm = useConfirm();
|
||||
const deleteSavedSearch = useDeleteSavedSearch();
|
||||
const [search, setSearch] = useState('');
|
||||
const [tagFilter, setTagFilter] = useQueryState('tag');
|
||||
const [viewMode, setViewMode] = useLocalStorage<'grid' | 'list'>({
|
||||
key: 'savedSearchesViewMode',
|
||||
defaultValue: 'grid',
|
||||
});
|
||||
|
||||
const allTags = useMemo(() => {
|
||||
if (!savedSearches) return [];
|
||||
const tags = new Set<string>();
|
||||
savedSearches.forEach(s => s.tags.forEach(t => tags.add(t)));
|
||||
return Array.from(tags).sort();
|
||||
}, [savedSearches]);
|
||||
|
||||
const filteredSavedSearches = useMemo(() => {
|
||||
if (!savedSearches) return [];
|
||||
let result = savedSearches;
|
||||
if (tagFilter) {
|
||||
result = result.filter(s => s.tags.includes(tagFilter));
|
||||
}
|
||||
const trimmedSearch = search.trim();
|
||||
if (trimmedSearch) {
|
||||
const q = trimmedSearch.toLowerCase();
|
||||
result = result.filter(
|
||||
s =>
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.tags.some(t => t.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
return result.slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [savedSearches, search, tagFilter]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: string) => {
|
||||
const confirmed = await confirm(
|
||||
'Are you sure you want to delete this saved search? This action cannot be undone.',
|
||||
'Delete',
|
||||
{ variant: 'danger' },
|
||||
);
|
||||
if (!confirmed) return;
|
||||
deleteSavedSearch.mutate(id, {
|
||||
onSuccess: () => {
|
||||
notifications.show({
|
||||
message: 'Saved search deleted',
|
||||
color: 'green',
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: 'Failed to delete saved search',
|
||||
color: 'red',
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[confirm, deleteSavedSearch],
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="saved-searches-list-page">
|
||||
<Head>
|
||||
<title>Saved Searches - {brandName}</title>
|
||||
</Head>
|
||||
<PageHeader>Saved Searches</PageHeader>
|
||||
<Container maw={1200} py="lg" px="lg">
|
||||
<Flex justify="space-between" align="center" mb="lg" gap="sm">
|
||||
<Group gap="xs" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
placeholder="Search by name"
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.currentTarget.value)}
|
||||
style={{ flex: 1, maxWidth: 400 }}
|
||||
miw={100}
|
||||
/>
|
||||
{allTags.length > 0 && (
|
||||
<Select
|
||||
placeholder="Filter by tag"
|
||||
data={allTags}
|
||||
value={tagFilter}
|
||||
onChange={v => setTagFilter(v)}
|
||||
clearable
|
||||
searchable
|
||||
style={{ maxWidth: 200 }}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="xs" align="center">
|
||||
<ActionIcon.Group>
|
||||
<ActionIcon
|
||||
variant={viewMode === 'grid' ? 'primary' : 'secondary'}
|
||||
size="input-sm"
|
||||
onClick={() => setViewMode('grid')}
|
||||
aria-label="Grid view"
|
||||
>
|
||||
<IconLayoutGrid size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant={viewMode === 'list' ? 'primary' : 'secondary'}
|
||||
size="input-sm"
|
||||
onClick={() => setViewMode('list')}
|
||||
aria-label="List view"
|
||||
>
|
||||
<IconList size={16} />
|
||||
</ActionIcon>
|
||||
</ActionIcon.Group>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftSection={<IconTable size={16} />}
|
||||
onClick={() => Router.push('/search')}
|
||||
data-testid="new-search-button"
|
||||
>
|
||||
New Search
|
||||
</Button>
|
||||
</Group>
|
||||
</Flex>
|
||||
|
||||
{isLoading ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
Loading saved searches...
|
||||
</Text>
|
||||
) : isError ? (
|
||||
<Text size="sm" c="red" ta="center" py="xl">
|
||||
Failed to load saved searches. Please try refreshing the page.
|
||||
</Text>
|
||||
) : filteredSavedSearches.length === 0 ? (
|
||||
<Stack align="center" gap="sm" py="xl">
|
||||
<IconTable size={40} opacity={0.3} />
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{search || tagFilter
|
||||
? 'No matching saved searches.'
|
||||
: 'No saved searches yet.'}
|
||||
</Text>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftSection={<IconTable size={16} />}
|
||||
onClick={() => Router.push('/search')}
|
||||
data-testid="empty-new-search-button"
|
||||
>
|
||||
New Search
|
||||
</Button>
|
||||
</Stack>
|
||||
) : viewMode === 'list' ? (
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Tags</Table.Th>
|
||||
<Table.Th w={50} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filteredSavedSearches.map(s => (
|
||||
<ListingRow
|
||||
key={s.id}
|
||||
id={s.id}
|
||||
name={s.name}
|
||||
href={`/search/${s.id}`}
|
||||
tags={s.tags}
|
||||
onDelete={handleDelete}
|
||||
statusIcon={<AlertStatusIcon alerts={s.alerts} />}
|
||||
/>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
|
||||
{filteredSavedSearches.map(s => (
|
||||
<ListingCard
|
||||
key={s.id}
|
||||
name={s.name}
|
||||
href={`/search/${s.id}`}
|
||||
tags={s.tags}
|
||||
onDelete={() => handleDelete(s.id)}
|
||||
statusIcon={<AlertStatusIcon alerts={s.alerts} />}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SavedSearchesListPage.getLayout = withAppNav;
|
||||
|
|
@ -1,17 +1,18 @@
|
|||
import { ActionIcon, Menu } from '@mantine/core';
|
||||
import { IconDotsVertical, IconForms, IconTrash } from '@tabler/icons-react';
|
||||
import { IconCopy, IconDotsVertical, IconTrash } from '@tabler/icons-react';
|
||||
|
||||
export default function SearchPageActionBar({
|
||||
onClickDeleteSavedSearch,
|
||||
onClickRenameSavedSearch,
|
||||
onClickSaveAsNew,
|
||||
}: {
|
||||
onClickDeleteSavedSearch: () => void;
|
||||
onClickRenameSavedSearch: () => void;
|
||||
onClickSaveAsNew: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Menu width={250}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
data-testid="search-page-action-bar"
|
||||
variant="secondary"
|
||||
style={{ flexShrink: 0 }}
|
||||
size="input-xs"
|
||||
|
|
@ -21,18 +22,19 @@ export default function SearchPageActionBar({
|
|||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconCopy size={16} />}
|
||||
onClick={onClickSaveAsNew}
|
||||
>
|
||||
Save as New Search
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconTrash size={16} />}
|
||||
color="red"
|
||||
onClick={onClickDeleteSavedSearch}
|
||||
>
|
||||
Delete Saved Search
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconForms size={16} />}
|
||||
onClick={onClickRenameSavedSearch}
|
||||
>
|
||||
Rename Saved Search
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,9 +4,43 @@ import {
|
|||
DEFAULT_TRACES_SOURCE_NAME,
|
||||
} from 'tests/e2e/utils/constants';
|
||||
|
||||
import { SavedSearchesListPage } from '../../page-objects/SavedSearchesListPage';
|
||||
import { SearchPage } from '../../page-objects/SearchPage';
|
||||
import { getApiUrl, getSources } from '../../utils/api-helpers';
|
||||
import { expect, test } from '../../utils/base-test';
|
||||
|
||||
/**
|
||||
* Helper to create a saved search via the API.
|
||||
* Fetches the log source ID from the API to use as the source reference.
|
||||
* Returns the created saved search object (including its id).
|
||||
*/
|
||||
async function createSavedSearchViaApi(
|
||||
page: import('@playwright/test').Page,
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
const API_URL = getApiUrl();
|
||||
const logSources = await getSources(page, 'log');
|
||||
const sourceId = logSources[0]._id;
|
||||
const defaults = {
|
||||
name: `E2E Saved Search ${Date.now()}`,
|
||||
select: 'Timestamp, Body',
|
||||
where: '',
|
||||
whereLanguage: 'lucene',
|
||||
source: sourceId,
|
||||
tags: [] as string[],
|
||||
};
|
||||
const body = { ...defaults, ...overrides };
|
||||
const response = await page.request.post(`${API_URL}/saved-search`, {
|
||||
data: body,
|
||||
});
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`Failed to create saved search: ${response.status()} ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
test.describe('Saved Search Functionality', () => {
|
||||
let searchPage: SearchPage;
|
||||
|
||||
|
|
@ -618,4 +652,298 @@ test.describe('Saved Search Functionality', () => {
|
|||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'should save as new search from an existing saved search',
|
||||
{},
|
||||
async ({ page }) => {
|
||||
/**
|
||||
* This test verifies that "Save as New Search" creates a new saved search
|
||||
* with the current configuration, navigating to a new URL while preserving
|
||||
* the original saved search.
|
||||
*
|
||||
* Test flow:
|
||||
* 1. Create a saved search with a custom WHERE and SELECT
|
||||
* 2. Click "Save as New Search" from the action bar menu
|
||||
* 3. Give the new search a different name and save
|
||||
* 4. Verify navigation to a new saved search URL
|
||||
* 5. Verify the new search preserved the original's configuration
|
||||
* 6. Verify the original saved search still exists
|
||||
*/
|
||||
|
||||
let originalUrl: string;
|
||||
const originalName = 'Original Search';
|
||||
const newName = 'Cloned Search';
|
||||
const customSelect =
|
||||
'Timestamp, Body, upper(ServiceName) as service_upper';
|
||||
|
||||
await test.step('Create a saved search with custom SELECT and WHERE', async () => {
|
||||
await searchPage.setCustomSELECT(customSelect);
|
||||
await searchPage.performSearch('SeverityText:info');
|
||||
await searchPage.openSaveSearchModal();
|
||||
await searchPage.savedSearchModal.saveSearchAndWaitForNavigation(
|
||||
originalName,
|
||||
);
|
||||
|
||||
originalUrl = page.url();
|
||||
});
|
||||
|
||||
await test.step('Click Save as New Search from the action bar', async () => {
|
||||
await searchPage.clickSaveAsNew();
|
||||
});
|
||||
|
||||
await test.step('Save the new search with a different name', async () => {
|
||||
await searchPage.savedSearchModal.saveSearchAndWaitForNavigation(
|
||||
newName,
|
||||
);
|
||||
});
|
||||
|
||||
await test.step('Verify navigation to a new saved search URL', async () => {
|
||||
await expect(page).not.toHaveURL(originalUrl);
|
||||
await expect(page).toHaveURL(/\/search\/[a-f0-9]+/);
|
||||
});
|
||||
|
||||
await test.step('Verify the new search preserved the SELECT', async () => {
|
||||
await searchPage.table.waitForRowsToPopulate();
|
||||
const selectEditor = searchPage.getSELECTEditor();
|
||||
await expect(selectEditor).toContainText(
|
||||
'upper(ServiceName) as service_upper',
|
||||
);
|
||||
});
|
||||
|
||||
await test.step('Verify the new search preserved the WHERE', async () => {
|
||||
await expect(searchPage.input).toHaveValue('SeverityText:info');
|
||||
});
|
||||
|
||||
await test.step('Verify the original saved search still exists', async () => {
|
||||
await page.goto(originalUrl);
|
||||
await expect(page.getByTestId('search-page')).toBeVisible();
|
||||
await searchPage.table.waitForRowsToPopulate();
|
||||
|
||||
await expect(searchPage.input).toHaveValue('SeverityText:info');
|
||||
const selectEditor = searchPage.getSELECTEditor();
|
||||
await expect(selectEditor).toContainText(
|
||||
'upper(ServiceName) as service_upper',
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.describe('Saved Searches Listing Page', { tag: ['@search'] }, () => {
|
||||
let listPage: SavedSearchesListPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
listPage = new SavedSearchesListPage(page);
|
||||
});
|
||||
|
||||
test(
|
||||
'should display the saved searches listing page',
|
||||
{ tag: '@full-stack' },
|
||||
async () => {
|
||||
await listPage.goto();
|
||||
|
||||
await test.step('Verify the page container is visible', async () => {
|
||||
await expect(listPage.pageContainer).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify the New Search button is visible', async () => {
|
||||
await expect(listPage.newSearchButton).toBeVisible();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'should navigate to search page when clicking New Search',
|
||||
{ tag: '@full-stack' },
|
||||
async ({ page }) => {
|
||||
await listPage.goto();
|
||||
|
||||
await test.step('Click the New Search button', async () => {
|
||||
await listPage.clickNewSearch();
|
||||
});
|
||||
|
||||
await test.step('Verify navigation to the search page', async () => {
|
||||
await expect(page).toHaveURL(/\/search\?/);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'should search saved searches by name',
|
||||
{ tag: '@full-stack' },
|
||||
async ({ page }) => {
|
||||
const ts = Date.now();
|
||||
const uniqueName = `E2E Search Test ${ts}`;
|
||||
|
||||
await test.step('Create a saved search via API', async () => {
|
||||
await createSavedSearchViaApi(page, { name: uniqueName });
|
||||
});
|
||||
|
||||
await test.step('Navigate to the listing page', async () => {
|
||||
await listPage.goto();
|
||||
});
|
||||
|
||||
await test.step('Search for the unique name', async () => {
|
||||
await listPage.searchSavedSearches(uniqueName);
|
||||
});
|
||||
|
||||
await test.step('Verify the saved search appears in results', async () => {
|
||||
await expect(listPage.getSavedSearchCard(uniqueName)).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Search for a non-existent name', async () => {
|
||||
await listPage.searchSavedSearches(`nonexistent-search-xyz-${ts}`);
|
||||
});
|
||||
|
||||
await test.step('Verify no matches state is shown', async () => {
|
||||
await expect(listPage.getNoMatchesState()).toBeVisible();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'should switch between grid and list views',
|
||||
{ tag: '@full-stack' },
|
||||
async ({ page }) => {
|
||||
const ts = Date.now();
|
||||
const uniqueName = `E2E View Toggle ${ts}`;
|
||||
|
||||
await test.step('Create a saved search via API', async () => {
|
||||
await createSavedSearchViaApi(page, { name: uniqueName });
|
||||
});
|
||||
|
||||
await test.step('Navigate to the listing page', async () => {
|
||||
await listPage.goto();
|
||||
});
|
||||
|
||||
await test.step('Verify grid view shows card', async () => {
|
||||
await expect(listPage.getSavedSearchCard(uniqueName)).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Switch to list view', async () => {
|
||||
await listPage.switchToListView();
|
||||
});
|
||||
|
||||
await test.step('Verify the saved search appears in a table row', async () => {
|
||||
await expect(listPage.getSavedSearchRow(uniqueName)).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Switch back to grid view', async () => {
|
||||
await listPage.switchToGridView();
|
||||
});
|
||||
|
||||
await test.step('Verify the card reappears', async () => {
|
||||
await expect(listPage.getSavedSearchCard(uniqueName)).toBeVisible();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'should delete a saved search from the card view',
|
||||
{ tag: '@full-stack' },
|
||||
async ({ page }) => {
|
||||
const ts = Date.now();
|
||||
const uniqueName = `E2E Delete Card ${ts}`;
|
||||
|
||||
await test.step('Create a saved search via API', async () => {
|
||||
await createSavedSearchViaApi(page, { name: uniqueName });
|
||||
});
|
||||
|
||||
await test.step('Navigate to the listing page', async () => {
|
||||
await listPage.goto();
|
||||
});
|
||||
|
||||
await test.step('Delete the saved search via the card menu', async () => {
|
||||
await listPage.deleteSavedSearchFromCard(uniqueName);
|
||||
});
|
||||
|
||||
await test.step('Verify the saved search is removed', async () => {
|
||||
await expect(listPage.getSavedSearchCard(uniqueName)).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step('Verify the deletion notification appears', async () => {
|
||||
await expect(page.getByText('Saved search deleted')).toBeVisible();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'should delete a saved search from the list view',
|
||||
{ tag: '@full-stack' },
|
||||
async ({ page }) => {
|
||||
const ts = Date.now();
|
||||
const uniqueName = `E2E Delete Row ${ts}`;
|
||||
|
||||
await test.step('Create a saved search via API', async () => {
|
||||
await createSavedSearchViaApi(page, { name: uniqueName });
|
||||
});
|
||||
|
||||
await test.step('Navigate to the listing page', async () => {
|
||||
await listPage.goto();
|
||||
});
|
||||
|
||||
await test.step('Switch to list view', async () => {
|
||||
await listPage.switchToListView();
|
||||
});
|
||||
|
||||
await test.step('Delete the saved search via the row menu', async () => {
|
||||
await listPage.deleteSavedSearchFromRow(uniqueName);
|
||||
});
|
||||
|
||||
await test.step('Verify the saved search is removed', async () => {
|
||||
await expect(listPage.getSavedSearchRow(uniqueName)).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step('Verify the deletion notification appears', async () => {
|
||||
await expect(page.getByText('Saved search deleted')).toBeVisible();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'should filter saved searches by tag',
|
||||
{ tag: '@full-stack' },
|
||||
async ({ page }) => {
|
||||
const ts = Date.now();
|
||||
const taggedName = `E2E Tagged Search ${ts}`;
|
||||
const untaggedName = `E2E Untagged Search ${ts}`;
|
||||
const tag = `e2e-tag-${ts}`;
|
||||
|
||||
await test.step('Create a saved search with a tag', async () => {
|
||||
await createSavedSearchViaApi(page, {
|
||||
name: taggedName,
|
||||
tags: [tag],
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Create a saved search without the tag', async () => {
|
||||
await createSavedSearchViaApi(page, { name: untaggedName });
|
||||
});
|
||||
|
||||
await test.step('Navigate to the listing page and verify both are visible', async () => {
|
||||
await listPage.goto();
|
||||
await expect(listPage.getSavedSearchCard(taggedName)).toBeVisible();
|
||||
await expect(listPage.getSavedSearchCard(untaggedName)).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Select the tag filter', async () => {
|
||||
await listPage.selectTagFilter(tag);
|
||||
});
|
||||
|
||||
await test.step('Verify only the tagged search is shown', async () => {
|
||||
await expect(listPage.getSavedSearchCard(taggedName)).toBeVisible();
|
||||
await expect(listPage.getSavedSearchCard(untaggedName)).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step('Clear the tag filter', async () => {
|
||||
await listPage.clearTagFilter();
|
||||
});
|
||||
|
||||
await test.step('Verify both searches are visible again', async () => {
|
||||
await expect(listPage.getSavedSearchCard(taggedName)).toBeVisible();
|
||||
await expect(listPage.getSavedSearchCard(untaggedName)).toBeVisible();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ export class DashboardPage {
|
|||
await defaultNameHeading.dblclick();
|
||||
|
||||
// Fill in new name
|
||||
const nameInput = this.page.locator('input[placeholder="Dashboard Name"]');
|
||||
const nameInput = this.page.locator('input[placeholder="Name"]');
|
||||
await nameInput.fill(newName);
|
||||
await this.page.keyboard.press('Enter');
|
||||
|
||||
|
|
|
|||
97
packages/app/tests/e2e/page-objects/SavedSearchesListPage.ts
Normal file
97
packages/app/tests/e2e/page-objects/SavedSearchesListPage.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* SavedSearchesListPage - Page object for the saved searches listing page
|
||||
* Encapsulates interactions with saved search browsing, search, filtering, and management
|
||||
*/
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
|
||||
export class SavedSearchesListPage {
|
||||
readonly page: Page;
|
||||
readonly pageContainer: Locator;
|
||||
readonly searchInput: Locator;
|
||||
readonly newSearchButton: Locator;
|
||||
readonly gridViewButton: Locator;
|
||||
readonly listViewButton: Locator;
|
||||
|
||||
private readonly emptyNewSearchButton: Locator;
|
||||
private readonly confirmConfirmButton: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.pageContainer = page.getByTestId('saved-searches-list-page');
|
||||
this.searchInput = page.getByPlaceholder('Search by name');
|
||||
this.newSearchButton = page.getByTestId('new-search-button');
|
||||
this.gridViewButton = page.getByRole('button', { name: 'Grid view' });
|
||||
this.listViewButton = page.getByRole('button', { name: 'List view' });
|
||||
this.emptyNewSearchButton = page.getByTestId('empty-new-search-button');
|
||||
this.confirmConfirmButton = page.getByTestId('confirm-confirm-button');
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/search/list', { waitUntil: 'networkidle' });
|
||||
}
|
||||
|
||||
async searchSavedSearches(query: string) {
|
||||
await this.searchInput.fill(query);
|
||||
}
|
||||
|
||||
async clearSearch() {
|
||||
await this.searchInput.clear();
|
||||
}
|
||||
|
||||
async clickNewSearch() {
|
||||
await this.newSearchButton.click();
|
||||
await this.page.waitForURL(/\/search\?/);
|
||||
}
|
||||
|
||||
async switchToGridView() {
|
||||
await this.gridViewButton.click();
|
||||
}
|
||||
|
||||
async switchToListView() {
|
||||
await this.listViewButton.click();
|
||||
}
|
||||
|
||||
getSavedSearchCard(name: string) {
|
||||
return this.pageContainer.locator('a').filter({ hasText: name });
|
||||
}
|
||||
|
||||
getSavedSearchRow(name: string) {
|
||||
return this.pageContainer.locator('tr').filter({ hasText: name });
|
||||
}
|
||||
|
||||
async deleteSavedSearchFromCard(name: string) {
|
||||
const card = this.getSavedSearchCard(name);
|
||||
await card.getByRole('button').click();
|
||||
await this.page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await this.confirmConfirmButton.click();
|
||||
}
|
||||
|
||||
async deleteSavedSearchFromRow(name: string) {
|
||||
const row = this.getSavedSearchRow(name);
|
||||
await row.getByRole('button').click();
|
||||
await this.page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await this.confirmConfirmButton.click();
|
||||
}
|
||||
|
||||
getTagFilterSelect() {
|
||||
return this.page.getByPlaceholder('Filter by tag');
|
||||
}
|
||||
|
||||
async selectTagFilter(tag: string) {
|
||||
await this.getTagFilterSelect().click();
|
||||
await this.page.getByRole('option', { name: tag, exact: true }).click();
|
||||
}
|
||||
|
||||
async clearTagFilter() {
|
||||
const select = this.getTagFilterSelect();
|
||||
await select.locator('..').locator('button').click();
|
||||
}
|
||||
|
||||
getEmptyState() {
|
||||
return this.pageContainer.getByText('No saved searches yet.');
|
||||
}
|
||||
|
||||
getNoMatchesState() {
|
||||
return this.pageContainer.getByText('No matching saved searches.');
|
||||
}
|
||||
}
|
||||
|
|
@ -186,6 +186,18 @@ export class SearchPage {
|
|||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Click "Save as New Search" from the action bar menu on an existing saved search.
|
||||
* Opens the save search modal in "create" mode for duplicating the current search.
|
||||
*/
|
||||
async clickSaveAsNew() {
|
||||
// Click the action bar menu trigger (three dots icon next to the saved search name)
|
||||
await this.page.getByTestId('search-page-action-bar').click();
|
||||
await this.page
|
||||
.getByRole('menuitem', { name: 'Save as New Search' })
|
||||
.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the alerts creation modal for the current saved search
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue