mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Add dashboard listing page (#1971)
## Summary This PR introduces a new dashboards listing page, which lists the available dashboards. Each individual dashboard is no longer listed in the sidebar. The new listing page supports searching by name and filtering by tag. This PR is a continuation of @elizabetdev's #1805, with some changes, additional tests, and refactorings. This page does client-side sort and filter. There is no server-side pagination, filtering, or sorting. That is left as a future improvement, should it become necessary. ### Screenshots or video <img width="2556" height="794" alt="Screenshot 2026-03-24 at 7 45 54 AM" src="https://github.com/user-attachments/assets/e4c5dba0-6cdf-4f2a-a5f3-2e4e00979729" /> <img width="2553" height="842" alt="Screenshot 2026-03-24 at 7 45 43 AM" src="https://github.com/user-attachments/assets/fc0f5270-d6d3-47ff-be03-762abd82a7d1" /> <img width="2544" height="862" alt="Screenshot 2026-03-24 at 7 45 34 AM" src="https://github.com/user-attachments/assets/4b1957c3-0e6e-4910-ac66-830734604759" /> ### How to test locally or on Vercel The listing page can be tested in vercel preview. ### References - Linear Issue: Closes HDX-3565 - Related PRs:
This commit is contained in:
parent
c9ab6dd0f8
commit
e21811cc47
22 changed files with 1109 additions and 269 deletions
5
.changeset/tender-fans-retire.md
Normal file
5
.changeset/tender-fans-retire.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Add dashboard listing page
|
||||
2
packages/app/pages/dashboards/list.tsx
Normal file
2
packages/app/pages/dashboards/list.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import DashboardsListPage from '@/components/Dashboards/DashboardsListPage';
|
||||
export default DashboardsListPage;
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
parseAsFloat,
|
||||
parseAsStringEnum,
|
||||
|
|
@ -16,7 +17,9 @@ import {
|
|||
} from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Box,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Grid,
|
||||
Group,
|
||||
|
|
@ -584,6 +587,14 @@ function ClickhousePage() {
|
|||
|
||||
return (
|
||||
<Box p="sm" data-testid="clickhouse-dashboard-page">
|
||||
<Breadcrumbs mb="xs" mt="xs" fz="sm">
|
||||
<Anchor component={Link} href="/dashboards/list" fz="sm" c="dimmed">
|
||||
Dashboards
|
||||
</Anchor>
|
||||
<Text fz="sm" c="dimmed">
|
||||
ClickHouse
|
||||
</Text>
|
||||
</Breadcrumbs>
|
||||
<OnboardingModal requireSource={false} />
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Controller, useForm, useWatch } from 'react-hook-form';
|
||||
import { StringParam, useQueryParam } from 'use-query-params';
|
||||
|
|
@ -13,6 +14,8 @@ import {
|
|||
SavedChartConfig,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
Anchor,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Collapse,
|
||||
Container,
|
||||
|
|
@ -508,11 +511,16 @@ function DBDashboardImportPage() {
|
|||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>Create a Dashboard - {brandName}</title>
|
||||
<title>Import Dashboard - {brandName}</title>
|
||||
</Head>
|
||||
<PageHeader>
|
||||
<div>Create Dashboard > Import Dashboard</div>
|
||||
</PageHeader>
|
||||
<Breadcrumbs my="lg" ms="xs" fz="sm">
|
||||
<Anchor component={Link} href="/dashboards/list" fz="sm" c="dimmed">
|
||||
Dashboards
|
||||
</Anchor>
|
||||
<Text fz="sm" c="dimmed">
|
||||
Import
|
||||
</Text>
|
||||
</Breadcrumbs>
|
||||
<div>
|
||||
<Container>
|
||||
<Stack gap="lg" mt="xl">
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { formatRelative } from 'date-fns';
|
||||
import produce from 'immer';
|
||||
|
|
@ -38,11 +39,12 @@ import {
|
|||
SearchConditionLanguage,
|
||||
SourceKind,
|
||||
SQLInterval,
|
||||
TSource,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Box,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Flex,
|
||||
Group,
|
||||
|
|
@ -1567,17 +1569,42 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
|
|||
);
|
||||
}}
|
||||
/>
|
||||
{isLocalDashboard && (
|
||||
<Paper my="lg" p="md" data-testid="temporary-dashboard-banner">
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text size="sm">
|
||||
This is a temporary dashboard and can not be saved.
|
||||
|
||||
{isLocalDashboard ? (
|
||||
<>
|
||||
<Breadcrumbs mb="xs" mt="xs" fz="sm">
|
||||
<Anchor component={Link} href="/dashboards/list" fz="sm" c="dimmed">
|
||||
Dashboards
|
||||
</Anchor>
|
||||
<Text fz="sm" c="dimmed">
|
||||
Temporary Dashboard
|
||||
</Text>
|
||||
<Button variant="primary" fw={400} onClick={onCreateDashboard}>
|
||||
Create New Saved Dashboard
|
||||
</Button>
|
||||
</Flex>
|
||||
</Paper>
|
||||
</Breadcrumbs>
|
||||
<Paper my="lg" p="md" data-testid="temporary-dashboard-banner">
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text size="sm">
|
||||
This is a temporary dashboard and can not be saved.
|
||||
</Text>
|
||||
<Button
|
||||
variant="primary"
|
||||
fw={400}
|
||||
onClick={onCreateDashboard}
|
||||
data-testid="create-dashboard-button"
|
||||
>
|
||||
Create New Saved Dashboard
|
||||
</Button>
|
||||
</Flex>
|
||||
</Paper>
|
||||
</>
|
||||
) : (
|
||||
<Breadcrumbs mb="xs" mt="xs" fz="sm">
|
||||
<Anchor component={Link} href="/dashboards/list" fz="sm" c="dimmed">
|
||||
Dashboards
|
||||
</Anchor>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{dashboard?.name ?? 'Untitled'}
|
||||
</Text>
|
||||
</Breadcrumbs>
|
||||
)}
|
||||
<Flex mt="xs" mb="md" justify="space-between" align="center">
|
||||
<DashboardName
|
||||
|
|
|
|||
|
|
@ -18,8 +18,10 @@ import {
|
|||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Breadcrumbs,
|
||||
Card,
|
||||
Flex,
|
||||
Grid,
|
||||
|
|
@ -1245,6 +1247,14 @@ function KubernetesDashboardPage() {
|
|||
|
||||
return (
|
||||
<Box data-testid="kubernetes-dashboard-page" p="sm">
|
||||
<Breadcrumbs mb="xs" mt="xs" fz="sm">
|
||||
<Anchor component={Link} href="/dashboards/list" fz="sm" c="dimmed">
|
||||
Dashboards
|
||||
</Anchor>
|
||||
<Text fz="sm" c="dimmed">
|
||||
Kubernetes
|
||||
</Text>
|
||||
</Breadcrumbs>
|
||||
<OnboardingModal requireSource={false} />
|
||||
{metricSource && logSource && (
|
||||
<PodDetailsSidePanel
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
parseAsString,
|
||||
parseAsStringEnum,
|
||||
|
|
@ -37,7 +38,9 @@ function pickSourceConfigFields(source: TSource) {
|
|||
}
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Box,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Grid,
|
||||
Group,
|
||||
|
|
@ -1548,6 +1551,14 @@ function ServicesDashboardPage() {
|
|||
|
||||
return (
|
||||
<Box p="sm" data-testid="services-dashboard-page">
|
||||
<Breadcrumbs mb="sm" mt="xs" fz="sm">
|
||||
<Anchor component={Link} href="/dashboards/list" fz="sm" c="dimmed">
|
||||
Dashboards
|
||||
</Anchor>
|
||||
<Text fz="sm" c="dimmed">
|
||||
Services
|
||||
</Text>
|
||||
</Breadcrumbs>
|
||||
<OnboardingModal requireSource={false} />
|
||||
<ServiceDashboardEndpointSidePanel
|
||||
service={service}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ import HyperDX from '@hyperdx/browser';
|
|||
import { AlertState } from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
CloseButton,
|
||||
Collapse,
|
||||
Group,
|
||||
|
|
@ -42,17 +42,12 @@ import {
|
|||
} from '@tabler/icons-react';
|
||||
|
||||
import api from '@/api';
|
||||
import { IS_K8S_DASHBOARD_ENABLED, IS_LOCAL_MODE } from '@/config';
|
||||
import {
|
||||
useCreateDashboard,
|
||||
useDashboards,
|
||||
useUpdateDashboard,
|
||||
} from '@/dashboard';
|
||||
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, ServerDashboard } from '@/types';
|
||||
import type { SavedSearch } from '@/types';
|
||||
import { UserPreferencesModal } from '@/UserPreferencesModal';
|
||||
import { useUserPreferences } from '@/useUserPreferences';
|
||||
import { useWindowSize } from '@/utils';
|
||||
|
|
@ -74,7 +69,6 @@ const APP_VERSION =
|
|||
process.env.NEXT_PUBLIC_APP_VERSION ?? packageJson.version ?? 'dev';
|
||||
|
||||
const UNTAGGED_SEARCHES_GROUP_NAME = 'Saved Searches';
|
||||
const UNTAGGED_DASHBOARDS_GROUP_NAME = 'Saved Dashboards';
|
||||
|
||||
// Navigation link configuration
|
||||
type NavLinkConfig = {
|
||||
|
|
@ -115,37 +109,6 @@ const NAV_LINKS: NavLinkConfig[] = [
|
|||
},
|
||||
];
|
||||
|
||||
function NewDashboardButton() {
|
||||
const createDashboard = useCreateDashboard();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-testid="create-dashboard-button"
|
||||
variant="transparent"
|
||||
color="var(--color-text)"
|
||||
py="0px"
|
||||
px="sm"
|
||||
fw={400}
|
||||
onClick={() =>
|
||||
createDashboard.mutate(
|
||||
{
|
||||
name: 'My Dashboard',
|
||||
tiles: [],
|
||||
tags: [],
|
||||
},
|
||||
{
|
||||
onSuccess: data => {
|
||||
Router.push(`/dashboards/${data.id}`);
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="pe-2">+</span> Create Dashboard
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchInput({
|
||||
placeholder,
|
||||
value,
|
||||
|
|
@ -402,16 +365,8 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
} = useSavedSearches();
|
||||
const logViews = useMemo(() => logViewsData ?? [], [logViewsData]);
|
||||
|
||||
const updateDashboard = useUpdateDashboard();
|
||||
const updateLogView = useUpdateSavedSearch();
|
||||
|
||||
const {
|
||||
data: dashboardsData,
|
||||
isLoading: isDashboardsLoading,
|
||||
refetch: refetchDashboards,
|
||||
} = useDashboards();
|
||||
const dashboards = useMemo(() => dashboardsData ?? [], [dashboardsData]);
|
||||
|
||||
const router = useRouter();
|
||||
const { pathname, query } = router;
|
||||
|
||||
|
|
@ -430,11 +385,6 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
key: 'isSearchExpanded',
|
||||
defaultValue: true,
|
||||
});
|
||||
const [isDashboardsExpanded, setIsDashboardExpanded] =
|
||||
useLocalStorage<boolean>({
|
||||
key: 'isDashboardsExpanded',
|
||||
defaultValue: true,
|
||||
});
|
||||
const { width } = useWindowSize();
|
||||
|
||||
const [isPreferCollapsed, setIsPreferCollapsed] = useLocalStorage<boolean>({
|
||||
|
|
@ -475,24 +425,7 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
untaggedGroupName: UNTAGGED_SEARCHES_GROUP_NAME,
|
||||
});
|
||||
|
||||
const {
|
||||
q: dashboardsListQ,
|
||||
setQ: setDashboardsListQ,
|
||||
filteredList: filteredDashboardsList,
|
||||
groupedFilteredList: groupedFilteredDashboardsList,
|
||||
} = useSearchableList({
|
||||
items: dashboards,
|
||||
untaggedGroupName: UNTAGGED_DASHBOARDS_GROUP_NAME,
|
||||
});
|
||||
|
||||
const [isDashboardsPresetsCollapsed, setDashboardsPresetsCollapsed] =
|
||||
useLocalStorage<boolean>({
|
||||
key: 'isDashboardsPresetsCollapsed',
|
||||
defaultValue: false,
|
||||
});
|
||||
|
||||
const savedSearchesResultsRef = useRef<HTMLDivElement>(null);
|
||||
const dashboardsResultsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const renderLogViewLink = useCallback(
|
||||
(savedSearch: SavedSearch) => (
|
||||
|
|
@ -571,50 +504,6 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
[logViews, refetchLogViews, updateLogView],
|
||||
);
|
||||
|
||||
const renderDashboardLink = useCallback(
|
||||
(dashboard: ServerDashboard) => (
|
||||
<Link
|
||||
href={`/dashboards/${dashboard.id}`}
|
||||
key={dashboard.id}
|
||||
tabIndex={0}
|
||||
className={cx(styles.subMenuItem, {
|
||||
[styles.subMenuItemActive]: dashboard.id === query.dashboardId,
|
||||
})}
|
||||
draggable
|
||||
data-dashboardid={dashboard.id}
|
||||
>
|
||||
{dashboard.name}
|
||||
</Link>
|
||||
),
|
||||
[query.dashboardId],
|
||||
);
|
||||
|
||||
const handleDashboardDragEnd = useCallback(
|
||||
(target: HTMLElement | null, name: string | null) => {
|
||||
if (!target?.dataset.dashboardid || name == null) {
|
||||
return;
|
||||
}
|
||||
const dashboard = dashboards.find(
|
||||
d => d.id === target.dataset.dashboardid,
|
||||
);
|
||||
if (dashboard?.tags?.includes(name)) {
|
||||
return;
|
||||
}
|
||||
updateDashboard.mutate(
|
||||
{
|
||||
id: target.dataset.dashboardid,
|
||||
tags: name === UNTAGGED_DASHBOARDS_GROUP_NAME ? [] : [name],
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetchDashboards();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[dashboards, refetchDashboards, updateDashboard],
|
||||
);
|
||||
|
||||
const [
|
||||
UserPreferencesOpen,
|
||||
{ close: closeUserPreferences, open: openUserPreferences },
|
||||
|
|
@ -768,104 +657,17 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
{/* Dashboards */}
|
||||
<AppNavLink
|
||||
label="Dashboards"
|
||||
href="/dashboards"
|
||||
href="/dashboards/list"
|
||||
icon={<IconLayoutGrid size={16} />}
|
||||
isExpanded={isDashboardsExpanded}
|
||||
onToggle={() => setIsDashboardExpanded(!isDashboardsExpanded)}
|
||||
/>
|
||||
|
||||
{!isCollapsed && (
|
||||
<Collapse in={isDashboardsExpanded}>
|
||||
<div className={styles.subMenu}>
|
||||
<NewDashboardButton />
|
||||
|
||||
{isDashboardsLoading && dashboardsData == null ? (
|
||||
<Loader variant="dots" mx="md" my="xs" size="sm" />
|
||||
) : (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
|
||||
{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}
|
||||
</>
|
||||
)}
|
||||
|
||||
<AppNavGroupLabel
|
||||
name="Presets"
|
||||
collapsed={isDashboardsPresetsCollapsed}
|
||||
onClick={() =>
|
||||
setDashboardsPresetsCollapsed(
|
||||
!isDashboardsPresetsCollapsed,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Collapse in={!isDashboardsPresetsCollapsed}>
|
||||
<Link
|
||||
href={`/clickhouse`}
|
||||
tabIndex={0}
|
||||
className={cx(styles.subMenuItem, {
|
||||
[styles.subMenuItemActive]:
|
||||
pathname.startsWith('/clickhouse'),
|
||||
})}
|
||||
data-testid="nav-link-clickhouse-dashboard"
|
||||
>
|
||||
ClickHouse
|
||||
</Link>
|
||||
<Link
|
||||
href={`/services`}
|
||||
tabIndex={0}
|
||||
className={cx(styles.subMenuItem, {
|
||||
[styles.subMenuItemActive]:
|
||||
pathname.startsWith('/services'),
|
||||
})}
|
||||
data-testid="nav-link-services-dashboard"
|
||||
>
|
||||
Services
|
||||
</Link>
|
||||
{IS_K8S_DASHBOARD_ENABLED && (
|
||||
<Link
|
||||
href={`/kubernetes`}
|
||||
tabIndex={0}
|
||||
className={cx(styles.subMenuItem, {
|
||||
[styles.subMenuItemActive]:
|
||||
pathname.startsWith('/kubernetes'),
|
||||
})}
|
||||
data-testid="nav-link-k8s-dashboard"
|
||||
>
|
||||
Kubernetes
|
||||
</Link>
|
||||
)}
|
||||
</Collapse>
|
||||
</div>
|
||||
</Collapse>
|
||||
<Text size="xs" px="lg" py="xs" fw="lighter" fs="italic">
|
||||
Saved dashboards have moved! Try the{' '}
|
||||
<Anchor component={Link} href="/dashboards/list">
|
||||
Dashboards page
|
||||
</Anchor>
|
||||
.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Team Settings (Cloud only) */}
|
||||
|
|
|
|||
80
packages/app/src/components/Dashboards/DashboardCard.tsx
Normal file
80
packages/app/src/components/Dashboards/DashboardCard.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
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({
|
||||
name,
|
||||
href,
|
||||
description,
|
||||
tags,
|
||||
onDelete,
|
||||
}: {
|
||||
name: string;
|
||||
href: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
component={Link}
|
||||
href={href}
|
||||
withBorder
|
||||
padding="lg"
|
||||
radius="sm"
|
||||
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>
|
||||
{onDelete && (
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={e => e.preventDefault()}
|
||||
>
|
||||
<IconDots size={14} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{description && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{tags && tags.length > 0 && (
|
||||
<Group gap="xs" mt="xs">
|
||||
{tags.map(tag => (
|
||||
<Badge key={tag} variant="light" size="xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
78
packages/app/src/components/Dashboards/DashboardListRow.tsx
Normal file
78
packages/app/src/components/Dashboards/DashboardListRow.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
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,
|
||||
onDelete,
|
||||
}: {
|
||||
dashboard: Dashboard;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const href = `/dashboards/${dashboard.id}`;
|
||||
|
||||
return (
|
||||
<Table.Tr
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={e => {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
window.open(href, '_blank');
|
||||
} else {
|
||||
Router.push(href);
|
||||
}
|
||||
}}
|
||||
onAuxClick={e => {
|
||||
if (e.button === 1) {
|
||||
window.open(href, '_blank');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text fw={500} size="sm">
|
||||
{dashboard.name}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
{dashboard.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>
|
||||
<ActionIcon
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<IconDots size={14} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onDelete(dashboard.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
334
packages/app/src/components/Dashboards/DashboardsListPage.tsx
Normal file
334
packages/app/src/components/Dashboards/DashboardsListPage.tsx
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
import { useCallback, useMemo, useState } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import Router from 'next/router';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
Group,
|
||||
Menu,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconDeviceFloppy,
|
||||
IconLayoutGrid,
|
||||
IconList,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { IS_K8S_DASHBOARD_ENABLED } from '@/config';
|
||||
import {
|
||||
useCreateDashboard,
|
||||
useDashboards,
|
||||
useDeleteDashboard,
|
||||
} from '@/dashboard';
|
||||
import { useBrandDisplayName } from '@/theme/ThemeProvider';
|
||||
import { useConfirm } from '@/useConfirm';
|
||||
|
||||
import { withAppNav } from '../../layout';
|
||||
|
||||
import { DashboardCard } from './DashboardCard';
|
||||
import { DashboardListRow } from './DashboardListRow';
|
||||
|
||||
const PRESET_DASHBOARDS = [
|
||||
{
|
||||
name: 'Services',
|
||||
href: '/services',
|
||||
description: 'Monitor HTTP endpoints, latency, and error rates',
|
||||
},
|
||||
{
|
||||
name: 'ClickHouse',
|
||||
href: '/clickhouse',
|
||||
description: 'ClickHouse cluster health and query performance',
|
||||
},
|
||||
...(IS_K8S_DASHBOARD_ENABLED
|
||||
? [
|
||||
{
|
||||
name: 'Kubernetes',
|
||||
href: '/kubernetes',
|
||||
description: 'Kubernetes cluster monitoring and pod health',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
export default function DashboardsListPage() {
|
||||
const brandName = useBrandDisplayName();
|
||||
const { data: dashboards, isLoading, isError } = useDashboards();
|
||||
const confirm = useConfirm();
|
||||
const createDashboard = useCreateDashboard();
|
||||
const deleteDashboard = useDeleteDashboard();
|
||||
const [search, setSearch] = useState('');
|
||||
const [tagFilter, setTagFilter] = useQueryState('tag');
|
||||
const [viewMode, setViewMode] = useLocalStorage<'grid' | 'list'>({
|
||||
key: 'dashboardsViewMode',
|
||||
defaultValue: 'grid',
|
||||
});
|
||||
|
||||
const allTags = useMemo(() => {
|
||||
if (!dashboards) return [];
|
||||
const tags = new Set<string>();
|
||||
dashboards.forEach(d => d.tags.forEach(t => tags.add(t)));
|
||||
return Array.from(tags).sort();
|
||||
}, [dashboards]);
|
||||
|
||||
const filteredDashboards = useMemo(() => {
|
||||
if (!dashboards) return [];
|
||||
let result = dashboards;
|
||||
if (tagFilter) {
|
||||
result = result.filter(d => d.tags.includes(tagFilter));
|
||||
}
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter(
|
||||
d =>
|
||||
d.name.toLowerCase().includes(q) ||
|
||||
d.tags.some(t => t.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
return result.slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [dashboards, search, tagFilter]);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
createDashboard.mutate(
|
||||
{ name: 'My Dashboard', tiles: [], tags: [] },
|
||||
{
|
||||
onSuccess: data => {
|
||||
Router.push(`/dashboards/${data.id}`);
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: 'Failed to create dashboard',
|
||||
color: 'red',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [createDashboard]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: string) => {
|
||||
const confirmed = await confirm(
|
||||
'Are you sure you want to delete this dashboard? This action cannot be undone.',
|
||||
'Delete',
|
||||
{ variant: 'danger' },
|
||||
);
|
||||
if (!confirmed) return;
|
||||
deleteDashboard.mutate(id, {
|
||||
onSuccess: () => {
|
||||
notifications.show({
|
||||
message: 'Dashboard deleted',
|
||||
color: 'green',
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: 'Failed to delete dashboard',
|
||||
color: 'red',
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[confirm, deleteDashboard],
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="dashboards-list-page">
|
||||
<Head>
|
||||
<title>Dashboards - {brandName}</title>
|
||||
</Head>
|
||||
<PageHeader>Dashboards</PageHeader>
|
||||
<Container maw={1200} py="lg" px="lg">
|
||||
<Text fw={500} size="sm" c="dimmed" mb="sm">
|
||||
Preset Dashboards
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} mb="xl">
|
||||
{PRESET_DASHBOARDS.map(p => (
|
||||
<DashboardCard key={p.href} {...p} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Text fw={500} size="sm" c="dimmed" mb="sm">
|
||||
Team Dashboards
|
||||
</Text>
|
||||
|
||||
<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
|
||||
component={Link}
|
||||
href="/dashboards/import"
|
||||
variant="secondary"
|
||||
leftSection={<IconUpload size={16} />}
|
||||
data-testid="import-dashboard-button"
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftSection={<IconPlus size={16} />}
|
||||
rightSection={<IconChevronDown size={14} />}
|
||||
loading={createDashboard.isPending}
|
||||
data-testid="new-dashboard-button"
|
||||
>
|
||||
New Dashboard
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconDeviceFloppy size={14} />}
|
||||
onClick={handleCreate}
|
||||
data-testid="create-dashboard-button"
|
||||
>
|
||||
Saved Dashboard
|
||||
<Text size="xs" c="dimmed">
|
||||
Persisted for your team
|
||||
</Text>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href="/dashboards"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
data-testid="temp-dashboard-button"
|
||||
>
|
||||
Temporary Dashboard
|
||||
<Text size="xs" c="dimmed">
|
||||
Lives in your browser only
|
||||
</Text>
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Flex>
|
||||
|
||||
{isLoading ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
Loading dashboards...
|
||||
</Text>
|
||||
) : isError ? (
|
||||
<Text size="sm" c="red" ta="center" py="xl">
|
||||
Failed to load dashboards. Please try refreshing the page.
|
||||
</Text>
|
||||
) : filteredDashboards.length === 0 ? (
|
||||
<Stack align="center" gap="sm" py="xl">
|
||||
<IconLayoutGrid size={40} opacity={0.3} />
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{search || tagFilter
|
||||
? `No matching dashboards yet.`
|
||||
: 'No dashboards yet.'}
|
||||
</Text>
|
||||
<Group>
|
||||
<Button
|
||||
component={Link}
|
||||
href="/dashboards/import"
|
||||
variant="secondary"
|
||||
leftSection={<IconUpload size={16} />}
|
||||
data-testid="empty-import-dashboard-button"
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={handleCreate}
|
||||
loading={createDashboard.isPending}
|
||||
data-testid="empty-create-dashboard-button"
|
||||
>
|
||||
New Dashboard
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : viewMode === 'list' ? (
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<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
|
||||
key={d.id}
|
||||
dashboard={d}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
|
||||
{filteredDashboards.map(d => (
|
||||
<DashboardCard
|
||||
key={d.id}
|
||||
name={d.name}
|
||||
href={`/dashboards/${d.id}`}
|
||||
tags={d.tags}
|
||||
description={`${d.tiles.length} ${d.tiles.length === 1 ? 'tile' : 'tiles'}`}
|
||||
onDelete={() => handleDelete(d.id)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DashboardsListPage.getLayout = withAppNav;
|
||||
|
|
@ -60,8 +60,6 @@ export type SearchConfig = {
|
|||
orderBy?: string | null;
|
||||
};
|
||||
|
||||
export type ServerDashboard = z.infer<typeof DashboardSchema>;
|
||||
|
||||
export type Session = {
|
||||
errorCount: string;
|
||||
maxTimestamp: string;
|
||||
|
|
|
|||
|
|
@ -39,24 +39,9 @@ test.describe('Navigation', { tag: ['@core'] }, () => {
|
|||
contentTestId: 'service-map-page',
|
||||
},
|
||||
{
|
||||
testId: 'nav-link-dashboards',
|
||||
href: '/dashboards',
|
||||
contentTestId: 'dashboard-page',
|
||||
},
|
||||
{
|
||||
testId: 'nav-link-clickhouse-dashboard',
|
||||
href: '/clickhouse',
|
||||
contentTestId: 'clickhouse-dashboard-page',
|
||||
},
|
||||
{
|
||||
testId: 'nav-link-services-dashboard',
|
||||
href: '/services',
|
||||
contentTestId: 'services-dashboard-page',
|
||||
},
|
||||
{
|
||||
testId: 'nav-link-k8s-dashboard',
|
||||
href: '/kubernetes',
|
||||
contentTestId: 'kubernetes-dashboard-page',
|
||||
testId: 'nav-link-dashboards-list',
|
||||
href: '/dashboards/list',
|
||||
contentTestId: 'dashboards-list-page',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
* legacy "series" format.
|
||||
*/
|
||||
import { DashboardPage, TileConfig } from '../page-objects/DashboardPage';
|
||||
import { DashboardsListPage } from '../page-objects/DashboardsListPage';
|
||||
import { getApiUrl, getSources, getUserAccessKey } from '../utils/api-helpers';
|
||||
import { expect, test } from '../utils/base-test';
|
||||
|
||||
|
|
@ -144,6 +145,7 @@ test.describe(
|
|||
};
|
||||
|
||||
let dashboardPage: DashboardPage;
|
||||
let dashboardListPage: DashboardsListPage;
|
||||
let tiles: any;
|
||||
|
||||
await test.step('Create dashboard via external API', async () => {
|
||||
|
|
@ -160,10 +162,11 @@ test.describe(
|
|||
|
||||
await test.step('Navigate to dashboard and verify tiles', async () => {
|
||||
dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.goto(); // Navigate to dashboards list
|
||||
dashboardListPage = new DashboardsListPage(page);
|
||||
await dashboardListPage.goto(); // Navigate to dashboards list
|
||||
|
||||
// Find and click on the created dashboard
|
||||
await dashboardPage.goToDashboardByName(dashboardPayload.name);
|
||||
await dashboardListPage.clickDashboard(dashboardPayload.name);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: dashboardPayload.name }),
|
||||
|
|
@ -305,11 +308,11 @@ test.describe(
|
|||
expect(updateResponse.ok()).toBeTruthy();
|
||||
|
||||
// Navigate to dashboard through UI (via AppNav)
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.goto(); // Navigate to dashboards list
|
||||
const dashboardListPage = new DashboardsListPage(page);
|
||||
await dashboardListPage.goto(); // Navigate to dashboards list
|
||||
|
||||
// Find and click on the updated dashboard
|
||||
await dashboardPage.goToDashboardByName(updatedName);
|
||||
await dashboardListPage.clickDashboard(updatedName);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: updatedName }),
|
||||
|
|
@ -373,8 +376,8 @@ test.describe(
|
|||
expect(getResponse.status()).toBe(404);
|
||||
|
||||
// Verify dashboard is not present in UI
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.goto(); // Navigate to dashboards list
|
||||
const dashboardListPage = new DashboardsListPage(page);
|
||||
await dashboardListPage.goto(); // Navigate to dashboards list
|
||||
|
||||
// First verify the kept dashboard is visible (ensures data has loaded)
|
||||
const keptDashboardLink = page.locator(`text="${dashboardToKeep.name}"`);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
* that partners and automation tools would use.
|
||||
*/
|
||||
import { DashboardPage, SeriesData } from '../page-objects/DashboardPage';
|
||||
import { DashboardsListPage } from '../page-objects/DashboardsListPage';
|
||||
import { getApiUrl, getSources, getUserAccessKey } from '../utils/api-helpers';
|
||||
import { expect, test } from '../utils/base-test';
|
||||
|
||||
|
|
@ -148,6 +149,7 @@ test.describe(
|
|||
};
|
||||
|
||||
let dashboardPage: DashboardPage;
|
||||
let dashboardsListPage: DashboardsListPage;
|
||||
let tiles: any;
|
||||
|
||||
await test.step('Create dashboard via external API', async () => {
|
||||
|
|
@ -164,10 +166,11 @@ test.describe(
|
|||
|
||||
await test.step('Navigate to dashboard and verify tiles', async () => {
|
||||
dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.goto(); // Navigate to dashboards list
|
||||
dashboardsListPage = new DashboardsListPage(page);
|
||||
await dashboardsListPage.goto(); // Navigate to dashboards list
|
||||
|
||||
// Find and click on the created dashboard
|
||||
await dashboardPage.goToDashboardByName(dashboardPayload.name);
|
||||
await dashboardsListPage.clickDashboard(dashboardPayload.name);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: dashboardPayload.name }),
|
||||
|
|
@ -305,11 +308,11 @@ test.describe(
|
|||
expect(updateResponse.ok()).toBeTruthy();
|
||||
|
||||
// Navigate to dashboard through UI (via AppNav)
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.goto(); // Navigate to dashboards list
|
||||
const dashboardsListPage = new DashboardsListPage(page);
|
||||
await dashboardsListPage.goto(); // Navigate to dashboards list
|
||||
|
||||
// Find and click on the updated dashboard
|
||||
await dashboardPage.goToDashboardByName(updatedName);
|
||||
await dashboardsListPage.clickDashboard(updatedName);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: updatedName }),
|
||||
|
|
@ -373,8 +376,8 @@ test.describe(
|
|||
expect(getResponse.status()).toBe(404);
|
||||
|
||||
// Verify dashboard is not present in UI
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.goto(); // Navigate to dashboards list
|
||||
const dashboardsListPage = new DashboardsListPage(page);
|
||||
await dashboardsListPage.goto(); // Navigate to dashboards list
|
||||
|
||||
// First verify the kept dashboard is visible (ensures data has loaded)
|
||||
const keptDashboardLink = page.locator(`text="${dashboardToKeep.name}"`);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { DisplayType } from '@hyperdx/common-utils/dist/types';
|
|||
|
||||
import { AlertsPage } from '../page-objects/AlertsPage';
|
||||
import { DashboardPage } from '../page-objects/DashboardPage';
|
||||
import { DashboardsListPage } from '../page-objects/DashboardsListPage';
|
||||
import { expect, test } from '../utils/base-test';
|
||||
import {
|
||||
DEFAULT_LOGS_SOURCE_NAME,
|
||||
|
|
@ -10,9 +11,11 @@ import {
|
|||
|
||||
test.describe('Dashboard', { tag: ['@dashboard'] }, () => {
|
||||
let dashboardPage: DashboardPage;
|
||||
let dashboardsListPage: DashboardsListPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
dashboardPage = new DashboardPage(page);
|
||||
dashboardsListPage = new DashboardsListPage(page);
|
||||
await dashboardPage.goto();
|
||||
});
|
||||
|
||||
|
|
@ -98,7 +101,7 @@ test.describe('Dashboard', { tag: ['@dashboard'] }, () => {
|
|||
});
|
||||
|
||||
await test.step('Verify dashboard appears in dashboards list', async () => {
|
||||
await dashboardPage.goto();
|
||||
await dashboardsListPage.goto();
|
||||
|
||||
// Look for our dashboard in the list
|
||||
const dashboardLink = dashboardPage.page.locator(
|
||||
|
|
|
|||
240
packages/app/tests/e2e/features/dashboards-list.spec.ts
Normal file
240
packages/app/tests/e2e/features/dashboards-list.spec.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { DashboardPage } from '../page-objects/DashboardPage';
|
||||
import { DashboardsListPage } from '../page-objects/DashboardsListPage';
|
||||
import { getApiUrl } from '../utils/api-helpers';
|
||||
import { expect, test } from '../utils/base-test';
|
||||
|
||||
test.describe('Dashboards Listing Page', { tag: ['@dashboard'] }, () => {
|
||||
let dashboardsListPage: DashboardsListPage;
|
||||
let dashboardPage: DashboardPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
dashboardsListPage = new DashboardsListPage(page);
|
||||
dashboardPage = new DashboardPage(page);
|
||||
});
|
||||
|
||||
test(
|
||||
'should display the dashboards listing page with preset dashboards',
|
||||
{ tag: '@full-stack' },
|
||||
async () => {
|
||||
await dashboardsListPage.goto();
|
||||
|
||||
await test.step('Verify the page container is visible', async () => {
|
||||
await expect(dashboardsListPage.pageContainer).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify preset dashboard cards are visible', async () => {
|
||||
await expect(
|
||||
dashboardsListPage.getPresetDashboardCard('Services'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dashboardsListPage.getPresetDashboardCard('ClickHouse'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dashboardsListPage.getPresetDashboardCard('Kubernetes'),
|
||||
).toBeVisible();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'should create a new dashboard from the listing page',
|
||||
{ tag: '@full-stack' },
|
||||
async ({ page }) => {
|
||||
await dashboardsListPage.goto();
|
||||
|
||||
await test.step('Click the New Dashboard button', async () => {
|
||||
await dashboardsListPage.createNewDashboard();
|
||||
});
|
||||
|
||||
await test.step('Verify navigation to the individual dashboard page', async () => {
|
||||
await expect(page).toHaveURL(/\/dashboards\/.+/);
|
||||
});
|
||||
|
||||
await test.step('Verify the dashboard name heading "My Dashboard" is visible', async () => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'My Dashboard', level: 3 }),
|
||||
).toBeVisible();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('should search dashboards by name', { tag: '@full-stack' }, async () => {
|
||||
const ts = Date.now();
|
||||
const uniqueName = `E2E Search Dashboard ${ts}`;
|
||||
|
||||
await test.step('Create a dashboard with a unique name', async () => {
|
||||
await dashboardsListPage.goto();
|
||||
await dashboardsListPage.createNewDashboard();
|
||||
await dashboardPage.editDashboardName(uniqueName);
|
||||
});
|
||||
|
||||
await test.step('Navigate to the dashboards listing page', async () => {
|
||||
await dashboardsListPage.goto();
|
||||
});
|
||||
|
||||
await test.step('Search for the unique dashboard name', async () => {
|
||||
await dashboardsListPage.searchDashboards(uniqueName);
|
||||
});
|
||||
|
||||
await test.step('Verify the dashboard appears in results', async () => {
|
||||
await expect(
|
||||
dashboardsListPage.getDashboardCard(uniqueName),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Search for a non-existent name', async () => {
|
||||
await dashboardsListPage.searchDashboards(
|
||||
`nonexistent-dashboard-xyz-${ts}`,
|
||||
);
|
||||
});
|
||||
|
||||
await test.step('Verify no matches state is shown', async () => {
|
||||
await expect(dashboardsListPage.getNoMatchesState()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'should switch between grid and list views',
|
||||
{ tag: '@full-stack' },
|
||||
async () => {
|
||||
const ts = Date.now();
|
||||
const uniqueName = `E2E View Toggle Dashboard ${ts}`;
|
||||
|
||||
await test.step('Create a dashboard with a unique name', async () => {
|
||||
await dashboardsListPage.goto();
|
||||
await dashboardsListPage.createNewDashboard();
|
||||
await dashboardPage.editDashboardName(uniqueName);
|
||||
});
|
||||
|
||||
await test.step('Navigate to the dashboards listing page', async () => {
|
||||
await dashboardsListPage.goto();
|
||||
});
|
||||
|
||||
await test.step('Verify grid view is the default and dashboard card is visible', async () => {
|
||||
await expect(
|
||||
dashboardsListPage.getDashboardCard(uniqueName),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Switch to list view', async () => {
|
||||
await dashboardsListPage.switchToListView();
|
||||
});
|
||||
|
||||
await test.step('Verify the dashboard appears in a table row', async () => {
|
||||
await expect(
|
||||
dashboardsListPage.getDashboardRow(uniqueName),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Switch back to grid view', async () => {
|
||||
await dashboardsListPage.switchToGridView();
|
||||
});
|
||||
|
||||
await test.step('Verify the dashboard card reappears', async () => {
|
||||
await expect(
|
||||
dashboardsListPage.getDashboardCard(uniqueName),
|
||||
).toBeVisible();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'should delete a dashboard from the listing page',
|
||||
{ tag: '@full-stack' },
|
||||
async ({ page }) => {
|
||||
const ts = Date.now();
|
||||
const uniqueName = `E2E Delete Dashboard ${ts}`;
|
||||
|
||||
await test.step('Create a dashboard with a unique name', async () => {
|
||||
await dashboardsListPage.goto();
|
||||
await dashboardsListPage.createNewDashboard();
|
||||
await dashboardPage.editDashboardName(uniqueName);
|
||||
});
|
||||
|
||||
await test.step('Navigate to the dashboards listing page', async () => {
|
||||
await dashboardsListPage.goto();
|
||||
});
|
||||
|
||||
await test.step('Delete the dashboard via the card menu', async () => {
|
||||
await dashboardsListPage.deleteDashboardFromCard(uniqueName);
|
||||
});
|
||||
|
||||
await test.step('Verify the dashboard is no longer visible', async () => {
|
||||
await expect(
|
||||
dashboardsListPage.getDashboardCard(uniqueName),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step('Verify the "Dashboard deleted" notification appears', async () => {
|
||||
await expect(page.getByText('Dashboard deleted')).toBeVisible();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'should filter dashboards by tag',
|
||||
{ tag: '@full-stack' },
|
||||
async ({ page }) => {
|
||||
const ts = Date.now();
|
||||
const taggedName = `E2E Tagged Dashboard ${ts}`;
|
||||
const untaggedName = `E2E Untagged Dashboard ${ts}`;
|
||||
const tag = `e2e-tag-${ts}`;
|
||||
const API_URL = getApiUrl();
|
||||
|
||||
await test.step('Create a dashboard with a tag', async () => {
|
||||
// Create dashboard
|
||||
await dashboardsListPage.goto();
|
||||
await dashboardsListPage.createNewDashboard();
|
||||
await dashboardPage.editDashboardName(taggedName);
|
||||
|
||||
// Extract dashboard ID from the URL and PATCH tags via API
|
||||
const dashboardId = page.url().split('/dashboards/')[1]?.split('?')[0];
|
||||
await page.request.patch(`${API_URL}/dashboards/${dashboardId}`, {
|
||||
data: { tags: [tag] },
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Create a dashboard without the tag', async () => {
|
||||
await dashboardsListPage.goto();
|
||||
await dashboardsListPage.createNewDashboard();
|
||||
await dashboardPage.editDashboardName(untaggedName);
|
||||
});
|
||||
|
||||
await test.step('Navigate to the listing page and verify both are visible', async () => {
|
||||
await dashboardsListPage.goto();
|
||||
await expect(
|
||||
dashboardsListPage.getDashboardCard(taggedName),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dashboardsListPage.getDashboardCard(untaggedName),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Select the tag filter', async () => {
|
||||
await dashboardsListPage.selectTagFilter(tag);
|
||||
});
|
||||
|
||||
await test.step('Verify only the tagged dashboard is shown', async () => {
|
||||
await expect(
|
||||
dashboardsListPage.getDashboardCard(taggedName),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dashboardsListPage.getDashboardCard(untaggedName),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step('Clear the tag filter', async () => {
|
||||
await dashboardsListPage.clearTagFilter();
|
||||
});
|
||||
|
||||
await test.step('Verify both dashboards are visible again', async () => {
|
||||
await expect(
|
||||
dashboardsListPage.getDashboardCard(taggedName),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dashboardsListPage.getDashboardCard(untaggedName),
|
||||
).toBeVisible();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -280,8 +280,8 @@ test.describe('Saved Search Functionality', () => {
|
|||
});
|
||||
|
||||
await test.step('Navigate to dashboards page', async () => {
|
||||
await page.goto('/dashboards');
|
||||
await expect(page.getByTestId('dashboard-page')).toBeVisible();
|
||||
await page.goto('/dashboards/list');
|
||||
await expect(page.getByTestId('dashboards-list-page')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Navigate back to saved search', async () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { DashboardsListPage } from 'tests/e2e/page-objects/DashboardsListPage';
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
|
||||
import { DashboardPage } from '../../page-objects/DashboardPage';
|
||||
import { SearchPage } from '../../page-objects/SearchPage';
|
||||
import { expect, test } from '../../utils/base-test';
|
||||
|
||||
|
|
@ -90,8 +90,9 @@ test.describe('Multiline Input', { tag: '@search' }, () => {
|
|||
await searchPage.goto();
|
||||
await searchPage.switchToSQLMode();
|
||||
} else {
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.goto();
|
||||
const dashboardsListPage = new DashboardsListPage(page);
|
||||
await dashboardsListPage.goto();
|
||||
await dashboardsListPage.createNewDashboard();
|
||||
// Dashboard uses Controller + SQL/SearchInputV2 directly (no where-language-switch wrapper)
|
||||
await page.getByRole('textbox', { name: 'Query language' }).click();
|
||||
await page.getByRole('option', { name: 'SQL', exact: true }).click();
|
||||
|
|
@ -118,8 +119,9 @@ test.describe('Multiline Input', { tag: '@search' }, () => {
|
|||
await searchPage.goto();
|
||||
await searchPage.switchToLuceneMode();
|
||||
} else {
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.goto();
|
||||
const dashboardsListPage = new DashboardsListPage(page);
|
||||
await dashboardsListPage.goto();
|
||||
await dashboardsListPage.createNewDashboard();
|
||||
// Dashboard has no where-language-switch wrapper; use Query language textbox directly
|
||||
await page.getByRole('textbox', { name: 'Query language' }).click();
|
||||
await page.getByRole('option', { name: 'Lucene', exact: true }).click();
|
||||
|
|
|
|||
109
packages/app/tests/e2e/features/temporary-dashboard.spec.ts
Normal file
109
packages/app/tests/e2e/features/temporary-dashboard.spec.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { DashboardPage } from '../page-objects/DashboardPage';
|
||||
import { DashboardsListPage } from '../page-objects/DashboardsListPage';
|
||||
import { expect, test } from '../utils/base-test';
|
||||
|
||||
test.describe('Temporary Dashboard', { tag: ['@dashboard'] }, () => {
|
||||
let dashboardPage: DashboardPage;
|
||||
let dashboardsListPage: DashboardsListPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
dashboardPage = new DashboardPage(page);
|
||||
dashboardsListPage = new DashboardsListPage(page);
|
||||
});
|
||||
|
||||
test(
|
||||
'should navigate from listing page to temporary dashboard',
|
||||
{ tag: '@full-stack' },
|
||||
async ({ page }) => {
|
||||
await dashboardsListPage.goto();
|
||||
|
||||
await test.step('Click New Dashboard and select Temporary Dashboard', async () => {
|
||||
await dashboardsListPage.goToTempDashboard();
|
||||
});
|
||||
|
||||
await test.step('Verify the temporary dashboard banner is visible', async () => {
|
||||
await expect(dashboardPage.temporaryDashboardBanner).toBeVisible();
|
||||
await expect(dashboardPage.temporaryDashboardBanner).toContainText(
|
||||
'This is a temporary dashboard and can not be saved.',
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'should persist temporary dashboard content in URL params across navigation',
|
||||
{},
|
||||
async ({ page }) => {
|
||||
await dashboardPage.goto();
|
||||
|
||||
await test.step('Verify the temporary dashboard banner is visible', async () => {
|
||||
await expect(dashboardPage.temporaryDashboardBanner).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Add a tile to the temporary dashboard', async () => {
|
||||
await dashboardPage.addTile();
|
||||
await dashboardPage.chartEditor.createBasicChart('Temp Chart');
|
||||
});
|
||||
|
||||
await test.step('Verify the tile and chart render', async () => {
|
||||
await expect(dashboardPage.getTiles()).toHaveCount(1, {
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(dashboardPage.getChartContainers()).toHaveCount(1, {
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
let savedUrl: string;
|
||||
|
||||
await test.step('Verify the URL contains the dashboard query param', async () => {
|
||||
savedUrl = page.url();
|
||||
expect(savedUrl).toContain('dashboard=');
|
||||
});
|
||||
|
||||
await test.step('Navigate away to /search', async () => {
|
||||
await page.goto('/search');
|
||||
await expect(page).toHaveURL(/\/search/);
|
||||
});
|
||||
|
||||
await test.step('Navigate back and verify persistence', async () => {
|
||||
await page.goto(savedUrl);
|
||||
await expect(dashboardPage.getTiles()).toHaveCount(1, {
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(dashboardPage.getChartContainers().first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'should convert temporary dashboard to saved dashboard',
|
||||
{ tag: '@full-stack' },
|
||||
async ({ page }) => {
|
||||
await dashboardPage.goto();
|
||||
|
||||
await test.step('Verify the temporary dashboard banner is visible', async () => {
|
||||
await expect(dashboardPage.temporaryDashboardBanner).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Click Create New Saved Dashboard', async () => {
|
||||
await dashboardPage.createButton.click();
|
||||
});
|
||||
|
||||
await test.step('Verify navigation to a saved dashboard', async () => {
|
||||
await expect(page).toHaveURL(/\/dashboards\/.+/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('Verify the temporary banner is replaced by breadcrumbs', async () => {
|
||||
await expect(dashboardPage.temporaryDashboardBanner).toBeHidden();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId('dashboard-page')
|
||||
.getByRole('link', { name: 'Dashboards' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -142,7 +142,7 @@ export class DashboardPage {
|
|||
}
|
||||
|
||||
/**
|
||||
* Navigate to dashboards list
|
||||
* Navigate to the temporary dashboards page
|
||||
*/
|
||||
async goto() {
|
||||
await this.page.goto('/dashboards', { waitUntil: 'networkidle' });
|
||||
|
|
@ -160,7 +160,7 @@ export class DashboardPage {
|
|||
*/
|
||||
async createNewDashboard() {
|
||||
await this.createDashboardButton.click();
|
||||
await this.page.waitForURL('**/dashboards**');
|
||||
await this.page.waitForURL(/\/dashboards\/.+/);
|
||||
}
|
||||
|
||||
async changeGranularity(granularity: string) {
|
||||
|
|
@ -228,7 +228,7 @@ export class DashboardPage {
|
|||
*/
|
||||
async openNewTileEditor() {
|
||||
await this.createDashboardButton.click();
|
||||
await this.page.waitForURL('**/dashboards**');
|
||||
await this.page.waitForURL(/\/dashboards\/.+/);
|
||||
await this.addDropdownButton.click();
|
||||
await this.addTileMenuItem.click();
|
||||
await expect(this.chartEditor.nameInput).toBeVisible();
|
||||
|
|
|
|||
129
packages/app/tests/e2e/page-objects/DashboardsListPage.ts
Normal file
129
packages/app/tests/e2e/page-objects/DashboardsListPage.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* DashboardsListPage - Page object for the dashboards listing page
|
||||
* Encapsulates interactions with dashboard browsing, search, filtering, and management
|
||||
*/
|
||||
import { expect, Locator, Page } from '@playwright/test';
|
||||
|
||||
export class DashboardsListPage {
|
||||
readonly page: Page;
|
||||
readonly pageContainer: Locator;
|
||||
readonly searchInput: Locator;
|
||||
readonly newDashboardButton: Locator;
|
||||
readonly createDashboardButton: Locator;
|
||||
readonly importDashboardButton: Locator;
|
||||
readonly tempDashboardButton: Locator;
|
||||
readonly gridViewButton: Locator;
|
||||
readonly listViewButton: Locator;
|
||||
|
||||
private readonly emptyCreateDashboardButton: Locator;
|
||||
private readonly emptyImportDashboardButton: Locator;
|
||||
private readonly confirmConfirmButton: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.pageContainer = page.getByTestId('dashboards-list-page');
|
||||
this.searchInput = page.getByPlaceholder('Search by name');
|
||||
this.newDashboardButton = page.getByTestId('new-dashboard-button');
|
||||
this.createDashboardButton = page.getByTestId('create-dashboard-button');
|
||||
this.importDashboardButton = page.getByTestId('import-dashboard-button');
|
||||
this.tempDashboardButton = page.getByTestId('temp-dashboard-button');
|
||||
this.gridViewButton = page.getByRole('button', { name: 'Grid view' });
|
||||
this.listViewButton = page.getByRole('button', { name: 'List view' });
|
||||
this.emptyCreateDashboardButton = page.getByTestId(
|
||||
'empty-create-dashboard-button',
|
||||
);
|
||||
this.emptyImportDashboardButton = page.getByTestId(
|
||||
'empty-import-dashboard-button',
|
||||
);
|
||||
this.confirmConfirmButton = page.getByTestId('confirm-confirm-button');
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/dashboards/list', { waitUntil: 'networkidle' });
|
||||
}
|
||||
|
||||
async searchDashboards(query: string) {
|
||||
await this.searchInput.fill(query);
|
||||
}
|
||||
|
||||
async clearSearch() {
|
||||
await this.searchInput.clear();
|
||||
}
|
||||
|
||||
async createNewDashboard() {
|
||||
await this.newDashboardButton.click();
|
||||
await this.createDashboardButton.click();
|
||||
await this.page.waitForURL('**/dashboards/**');
|
||||
}
|
||||
|
||||
async goToTempDashboard() {
|
||||
await this.newDashboardButton.click();
|
||||
await this.tempDashboardButton.click();
|
||||
}
|
||||
|
||||
async switchToGridView() {
|
||||
await this.gridViewButton.click();
|
||||
}
|
||||
|
||||
async switchToListView() {
|
||||
await this.listViewButton.click();
|
||||
}
|
||||
|
||||
getDashboardCard(name: string) {
|
||||
return this.pageContainer.locator('a').filter({ hasText: name });
|
||||
}
|
||||
|
||||
getDashboardRow(name: string) {
|
||||
return this.pageContainer.locator('tr').filter({ hasText: name });
|
||||
}
|
||||
|
||||
async clickDashboard(name: string) {
|
||||
await this.getDashboardCard(name).click();
|
||||
await this.page.waitForURL('**/dashboards/**');
|
||||
}
|
||||
|
||||
async deleteDashboardFromCard(name: string) {
|
||||
const card = this.getDashboardCard(name);
|
||||
// Click the menu button (three dots) within the card
|
||||
await card.getByRole('button').click();
|
||||
await this.page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
// Confirm deletion
|
||||
await this.confirmConfirmButton.click();
|
||||
}
|
||||
|
||||
async deleteDashboardFromRow(name: string) {
|
||||
const row = this.getDashboardRow(name);
|
||||
// Click the menu button within the row
|
||||
await row.getByRole('button').click();
|
||||
await this.page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
// Confirm deletion
|
||||
await this.confirmConfirmButton.click();
|
||||
}
|
||||
|
||||
getPresetDashboardCard(name: string) {
|
||||
return this.pageContainer.locator('a').filter({ hasText: name });
|
||||
}
|
||||
|
||||
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() {
|
||||
// The Mantine Select clear button is a sibling button next to the textbox
|
||||
const select = this.getTagFilterSelect();
|
||||
await select.locator('..').locator('button').click();
|
||||
}
|
||||
|
||||
getEmptyState() {
|
||||
return this.pageContainer.getByText('No dashboards yet.');
|
||||
}
|
||||
|
||||
getNoMatchesState() {
|
||||
return this.pageContainer.getByText('No matching dashboards yet.');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue