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..431542c414 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, @@ -50,7 +52,6 @@ import { Input, Menu, Modal, - Paper, Text, Title, Tooltip, @@ -86,12 +87,7 @@ import { DBTimeChart } from '@/components/DBTimeChart'; import FullscreenPanelModal from '@/components/FullscreenPanelModal'; import SectionHeader from '@/components/SectionHeader'; import { TimePicker } from '@/components/TimePicker'; -import { - Dashboard, - type Tile, - useCreateDashboard, - useDeleteDashboard, -} from '@/dashboard'; +import { Dashboard, type Tile, useDeleteDashboard } from '@/dashboard'; import ChartContainer from './components/charts/ChartContainer'; import { DBPieChart } from './components/DBPieChart'; @@ -825,7 +821,7 @@ function downloadObjectAsJson(object: object, fileName = 'output') { downloadAnchorNode.remove(); } -function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { +function DBDashboardPage() { const brandName = useBrandDisplayName(); const confirm = useConfirm(); @@ -835,13 +831,10 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { const { dashboard, setDashboard, - dashboardHash, - isLocalDashboard, isFetching: isFetchingDashboard, isSetting: isSavingDashboard, } = useDashboard({ - dashboardId: dashboardId as string | undefined, - presetConfig, + dashboardId, }); const { data: sources } = useSources(); @@ -987,7 +980,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { // Initialize query/filter state once when dashboard changes. useEffect(() => { if (!dashboard?.id || !router.isReady) return; - if (!isLocalDashboard && isFetchingDashboard) return; + if (isFetchingDashboard) return; if (initializedDashboard.current === dashboard.id) return; const isSwitchingDashboards = initializedDashboard.current != null && @@ -1029,7 +1022,6 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { dashboard?.savedQuery, dashboard?.savedQueryLanguage, dashboard?.savedFilterValues, - isLocalDashboard, isFetchingDashboard, router.isReady, router.query, @@ -1040,7 +1032,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ]); const handleSaveQuery = useCallback(() => { - if (!dashboard || isLocalDashboard) return; + if (!dashboard) return; // Execute the query first (updates URL) onSubmit(); @@ -1071,16 +1063,9 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { }); }, ); - }, [ - dashboard, - isLocalDashboard, - setDashboard, - getValues, - rawFilterQueries, - onSubmit, - ]); + }, [dashboard, setDashboard, getValues, rawFilterQueries, onSubmit]); const handleRemoveSavedQuery = useCallback(() => { - if (!dashboard || isLocalDashboard) return; + if (!dashboard) return; setDashboard( produce(dashboard, draft => { @@ -1097,7 +1082,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { }); }, ); - }, [dashboard, isLocalDashboard, setDashboard]); + }, [dashboard, setDashboard]); const [editedTile, setEditedTile] = useState(); @@ -1431,22 +1416,6 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { [dashboard, setDashboard], ); - const createDashboard = useCreateDashboard(); - const onCreateDashboard = useCallback(() => { - createDashboard.mutate( - { - name: 'My Dashboard', - tiles: [], - tags: [], - }, - { - onSuccess: data => { - router.push(`/dashboards/${data.id}`); - }, - }, - ); - }, [createDashboard, router]); - const [isSaving, setIsSaving] = useState(false); const hasTiles = dashboard && dashboard.tiles.length > 0; @@ -1495,21 +1464,16 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ); }} /> - {isLocalDashboard && ( - - - - This is a temporary dashboard and can not be saved. - - - - - )} - + + + Dashboards + + + {dashboard?.name ?? 'Untitled'} + + + { if (dashboard != null) { @@ -1521,7 +1485,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { }} /> - {!isLocalDashboard && dashboard?.id && ( + {dashboard?.id && ( )} - {!isLocalDashboard /* local dashboards cant be "deleted" */ && ( - - - - - - - - - {hasTiles && ( - } - onClick={() => { - if (!sources || !dashboard) { - notifications.show({ - color: 'red', - message: 'Export Failed', - }); - return; - } - downloadObjectAsJson( - convertToDashboardTemplate( - dashboard, - // TODO: fix this type issue - sources, - connections, - ), - dashboard?.name, - ); - }} - > - Export Dashboard - - )} + + + + + + + + {hasTiles && ( } + leftSection={} onClick={() => { - if (dashboard && !dashboard.tiles.length) { - router.push( - `/dashboards/import?dashboardId=${dashboard.id}`, - ); - } else { - router.push('/dashboards/import'); + if (!sources || !dashboard) { + notifications.show({ + color: 'red', + message: 'Export Failed', + }); + return; } + downloadObjectAsJson( + convertToDashboardTemplate( + dashboard, + // TODO: fix this type issue + sources, + connections, + ), + dashboard?.name, + ); }} > - {hasTiles ? 'Import New Dashboard' : 'Import Dashboard'} + Export Dashboard - + )} + } + onClick={() => { + if (dashboard && !dashboard.tiles.length) { + router.push( + `/dashboards/import?dashboardId=${dashboard.id}`, + ); + } else { + router.push('/dashboards/import'); + } + }} + > + {hasTiles ? 'Import New Dashboard' : 'Import Dashboard'} + + + } + onClick={handleSaveQuery} + > + {hasSavedQueryAndFilterDefaults + ? 'Update Default Query & Filters' + : 'Save Query & Filters as Default'} + + {hasSavedQueryAndFilterDefaults && ( } - onClick={handleSaveQuery} + data-testid="remove-default-query-filters-menu-item" + leftSection={} + color="red" + onClick={handleRemoveSavedQuery} > - {hasSavedQueryAndFilterDefaults - ? 'Update Default Query & Filters' - : 'Save Query & Filters as Default'} + Remove Default Query & Filters - {hasSavedQueryAndFilterDefaults && ( - } - color="red" - onClick={handleRemoveSavedQuery} - > - Remove Default Query & Filters - - )} - - } - color="red" - onClick={() => - deleteDashboard.mutate(dashboard?.id ?? '', { + )} + + } + color="red" + onClick={() => { + if (dashboard?.id) { + deleteDashboard.mutate(dashboard?.id, { onSuccess: () => { router.push('/dashboards'); }, - }) + }); } - > - Delete Dashboard - - - - )} + }} + > + Delete Dashboard + + + {/* - ); -} - function SearchInput({ placeholder, value, @@ -402,16 +367,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 +387,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 +427,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 +506,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,102 +661,15 @@ 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 - - )} - -
-
+ + 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..61b007adfc --- /dev/null +++ b/packages/app/src/components/Dashboards/DashboardsListPage.tsx @@ -0,0 +1,303 @@ +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, + 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 }} + /> + {allTags.length > 0 && ( +