From 35b517e4d47fc06dc4c50dba1310faf9111cc9c0 Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Tue, 24 Mar 2026 07:44:19 -0400 Subject: [PATCH 1/2] feat: Add dashboard listing page --- .changeset/tender-fans-retire.md | 5 + packages/app/pages/dashboards/list.tsx | 2 + packages/app/src/ClickhousePage.tsx | 11 + packages/app/src/DBDashboardImportPage.tsx | 16 +- packages/app/src/DBDashboardPage.tsx | 49 ++- packages/app/src/KubernetesDashboardPage.tsx | 10 + packages/app/src/ServicesDashboardPage.tsx | 11 + packages/app/src/components/AppNav/AppNav.tsx | 220 +----------- .../components/Dashboards/DashboardCard.tsx | 80 +++++ .../Dashboards/DashboardListRow.tsx | 78 +++++ .../Dashboards/DashboardsListPage.tsx | 317 ++++++++++++++++++ packages/app/src/types.ts | 2 - .../app/tests/e2e/core/navigation.spec.ts | 21 +- .../dashboard-external-api-config.spec.ts | 17 +- .../dashboard-external-api-series.spec.ts | 17 +- .../app/tests/e2e/features/dashboard.spec.ts | 5 +- .../e2e/features/dashboards-list.spec.ts | 240 +++++++++++++ .../e2e/features/search/saved-search.spec.ts | 4 +- .../e2e/features/shared/multiline.spec.ts | 12 +- .../e2e/features/temporary-dashboard.spec.ts | 113 +++++++ .../tests/e2e/page-objects/DashboardPage.ts | 6 +- .../e2e/page-objects/DashboardsListPage.ts | 125 +++++++ 22 files changed, 1092 insertions(+), 269 deletions(-) create mode 100644 .changeset/tender-fans-retire.md create mode 100644 packages/app/pages/dashboards/list.tsx create mode 100644 packages/app/src/components/Dashboards/DashboardCard.tsx create mode 100644 packages/app/src/components/Dashboards/DashboardListRow.tsx create mode 100644 packages/app/src/components/Dashboards/DashboardsListPage.tsx create mode 100644 packages/app/tests/e2e/features/dashboards-list.spec.ts create mode 100644 packages/app/tests/e2e/features/temporary-dashboard.spec.ts create mode 100644 packages/app/tests/e2e/page-objects/DashboardsListPage.ts diff --git a/.changeset/tender-fans-retire.md b/.changeset/tender-fans-retire.md new file mode 100644 index 0000000000..0661ac6d3d --- /dev/null +++ b/.changeset/tender-fans-retire.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +feat: Add dashboard listing page diff --git a/packages/app/pages/dashboards/list.tsx b/packages/app/pages/dashboards/list.tsx new file mode 100644 index 0000000000..cf1c0270f6 --- /dev/null +++ b/packages/app/pages/dashboards/list.tsx @@ -0,0 +1,2 @@ +import DashboardsListPage from '@/components/Dashboards/DashboardsListPage'; +export default DashboardsListPage; diff --git a/packages/app/src/ClickhousePage.tsx b/packages/app/src/ClickhousePage.tsx index 6c878d885d..a3563d6825 100644 --- a/packages/app/src/ClickhousePage.tsx +++ b/packages/app/src/ClickhousePage.tsx @@ -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 ( + + + Dashboards + + + ClickHouse + + diff --git a/packages/app/src/DBDashboardImportPage.tsx b/packages/app/src/DBDashboardImportPage.tsx index 228b4ecacf..82a1b8c6bf 100644 --- a/packages/app/src/DBDashboardImportPage.tsx +++ b/packages/app/src/DBDashboardImportPage.tsx @@ -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 (
- Create a Dashboard - {brandName} + Import Dashboard - {brandName} - -
Create Dashboard > Import Dashboard
-
+ + + Dashboards + + + Import + +
diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index e464de0415..7396dacfd0 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -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, @@ -1495,17 +1497,42 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ); }} /> - {isLocalDashboard && ( - - - - This is a temporary dashboard and can not be saved. + + {isLocalDashboard ? ( + <> + + + Dashboards + + + Temporary Dashboard - - - + + + + + This is a temporary dashboard and can not be saved. + + + + + + ) : ( + + + Dashboards + + + {dashboard?.name ?? 'Untitled'} + + )} + + + Dashboards + + + Kubernetes + + {metricSource && logSource && ( + + + Dashboards + + + Services + + - createDashboard.mutate( - { - name: 'My Dashboard', - tiles: [], - tags: [], - }, - { - onSuccess: data => { - Router.push(`/dashboards/${data.id}`); - }, - }, - ) - } - > - + Create Dashboard - - ); -} - 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({ - key: 'isDashboardsExpanded', - defaultValue: true, - }); const { width } = useWindowSize(); const [isPreferCollapsed, setIsPreferCollapsed] = useLocalStorage({ @@ -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({ - key: 'isDashboardsPresetsCollapsed', - defaultValue: false, - }); - const savedSearchesResultsRef = useRef(null); - const dashboardsResultsRef = useRef(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) => ( - - {dashboard.name} - - ), - [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 */} } - isExpanded={isDashboardsExpanded} - onToggle={() => setIsDashboardExpanded(!isDashboardsExpanded)} /> - {!isCollapsed && ( - -
- - - {isDashboardsLoading && dashboardsData == null ? ( - - ) : ( - <> - { - ( - dashboardsResultsRef?.current - ?.firstChild as HTMLAnchorElement - )?.focus?.(); - }} - /> - - - - {dashboards.length === 0 && ( -
- No saved dashboards -
- )} - - {dashboardsListQ && - filteredDashboardsList.length === 0 ? ( -
- No results matching {dashboardsListQ} -
- ) : null} - - )} - - - setDashboardsPresetsCollapsed( - !isDashboardsPresetsCollapsed, - ) - } - /> - - - ClickHouse - - - Services - - {IS_K8S_DASHBOARD_ENABLED && ( - - Kubernetes - - )} - -
-
+ + Dashboards have moved! Try the{' '} + + Dashboards page + + . + )} {/* Team Settings (Cloud only) */} diff --git a/packages/app/src/components/Dashboards/DashboardCard.tsx b/packages/app/src/components/Dashboards/DashboardCard.tsx new file mode 100644 index 0000000000..f35b24ef1a --- /dev/null +++ b/packages/app/src/components/Dashboards/DashboardCard.tsx @@ -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 ( + + + + {name} + + {onDelete && ( + + + e.preventDefault()} + > + + + + + } + onClick={e => { + e.preventDefault(); + onDelete(); + }} + > + Delete + + + + )} + + + {description && ( + + {description} + + )} + + {tags && tags.length > 0 && ( + + {tags.map(tag => ( + + {tag} + + ))} + + )} + + ); +} diff --git a/packages/app/src/components/Dashboards/DashboardListRow.tsx b/packages/app/src/components/Dashboards/DashboardListRow.tsx new file mode 100644 index 0000000000..58e34c48f6 --- /dev/null +++ b/packages/app/src/components/Dashboards/DashboardListRow.tsx @@ -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 ( + { + if (e.metaKey || e.ctrlKey) { + window.open(href, '_blank'); + } else { + Router.push(href); + } + }} + onAuxClick={e => { + if (e.button === 1) { + window.open(href, '_blank'); + } + }} + > + + + {dashboard.name} + + + + + {dashboard.tags.map(tag => ( + + {tag} + + ))} + + + + + {dashboard.tiles.length} + + + + + + e.stopPropagation()} + > + + + + + } + onClick={e => { + e.stopPropagation(); + onDelete(dashboard.id); + }} + > + Delete + + + + + + ); +} diff --git a/packages/app/src/components/Dashboards/DashboardsListPage.tsx b/packages/app/src/components/Dashboards/DashboardsListPage.tsx new file mode 100644 index 0000000000..c84f165d6c --- /dev/null +++ b/packages/app/src/components/Dashboards/DashboardsListPage.tsx @@ -0,0 +1,317 @@ +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 { Tooltip } from 'recharts'; +import { + ActionIcon, + Button, + ButtonGroup, + Container, + Flex, + Group, + Select, + SimpleGrid, + Stack, + Table, + Text, + TextInput, +} from '@mantine/core'; +import { useLocalStorage } from '@mantine/hooks'; +import { notifications } from '@mantine/notifications'; +import { + 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(); + 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 ( +
+ + Dashboards - {brandName} + + Dashboards + + + Preset Dashboards + + + {PRESET_DASHBOARDS.map(p => ( + + ))} + + + + Team Dashboards + + + + + } + value={search} + onChange={e => setSearch(e.currentTarget.value)} + style={{ flex: 1, maxWidth: 400 }} + miw={100} + /> + {allTags.length > 0 && ( +