From cf07e0cd5a9ba305389c28ad218cc4c548f58de9 Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Mon, 23 Mar 2026 15:31:31 -0400 Subject: [PATCH 1/6] feat: Add dashboard listing page --- .changeset/tender-fans-retire.md | 5 + packages/app/pages/dashboards/index.tsx | 4 +- packages/app/src/ClickhousePage.tsx | 11 + packages/app/src/DBDashboardImportPage.tsx | 16 +- packages/app/src/DBDashboardPage.tsx | 13 +- packages/app/src/KubernetesDashboardPage.tsx | 10 + packages/app/src/ServicesDashboardPage.tsx | 11 + packages/app/src/components/AppNav/AppNav.tsx | 211 +------------ .../components/Dashboards/DashboardCard.tsx | 80 +++++ .../Dashboards/DashboardListRow.tsx | 73 +++++ .../Dashboards/DashboardsListPage.tsx | 298 ++++++++++++++++++ 11 files changed, 516 insertions(+), 216 deletions(-) create mode 100644 .changeset/tender-fans-retire.md 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 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/index.tsx b/packages/app/pages/dashboards/index.tsx index 18f0f8157b..cf1c0270f6 100644 --- a/packages/app/pages/dashboards/index.tsx +++ b/packages/app/pages/dashboards/index.tsx @@ -1,2 +1,2 @@ -import DBDashboardPage from '@/DBDashboardPage'; -export default DBDashboardPage; +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..185d067212 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..4ba867d849 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..7dbb1047ff 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'; @@ -42,7 +43,9 @@ import { } from '@hyperdx/common-utils/dist/types'; import { ActionIcon, + Anchor, Box, + Breadcrumbs, Button, Flex, Group, @@ -1507,7 +1510,15 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { )} - + + + 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 }, @@ -770,104 +659,8 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) { label="Dashboards" href="/dashboards" icon={} - 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 - - )} - -
-
- )} - {/* Team Settings (Cloud only) */} {!IS_LOCAL_MODE && ( 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..922074ea27 --- /dev/null +++ b/packages/app/src/components/Dashboards/DashboardListRow.tsx @@ -0,0 +1,73 @@ +import Link from 'next/link'; +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; +}) { + return ( + Router.push(`/dashboards/${dashboard.id}`)} + > + + e.stopPropagation()} + > + {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..f10369993c --- /dev/null +++ b/packages/app/src/components/Dashboards/DashboardsListPage.tsx @@ -0,0 +1,298 @@ +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, + Card, + 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, IS_LOCAL_MODE } 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 } = useDashboards(); + const confirm = useConfirm(); + const createDashboard = useCreateDashboard(); + const deleteDashboard = useDeleteDashboard(); + const [search, setSearch] = useState(''); + const [tagFilter, setTagFilter] = useQueryState('tag'); + const [viewMode, setViewMode] = useLocalStorage({ + 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(() => { + if (IS_LOCAL_MODE) { + Router.push('/dashboards'); + return; + } + createDashboard.mutate( + { name: 'My Dashboard', tiles: [], tags: [] }, + { + onSuccess: data => { + Router.push(`/dashboards/${data.id}`); + }, + }, + ); + }, [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 }} + /> + {allTags.length > 0 && ( +