diff --git a/.changeset/page-migrate-dashboard.md b/.changeset/page-migrate-dashboard.md new file mode 100644 index 0000000000..dc70c2ec0a --- /dev/null +++ b/.changeset/page-migrate-dashboard.md @@ -0,0 +1,7 @@ +--- +'@hyperdx/app': patch +--- + +chore: migrate the custom Dashboard page to shared `PageLayout` / `PageHeader`. Breadcrumbs, the editable dashboard name, dashboard actions (Favorite, Tags, Menu), and the "Created by … Updated …" meta now live in a single page header, while the query toolbar (SQL/Lucene WHERE, time range, granularity, Live, refresh, edit filters, Run) is pinned to the top of the scroll container as a dedicated sticky row — the chrome above scrolls away and only the toolbar follows the user. The "Updated …" meta moves to the right side of the breadcrumbs row instead of sitting as a separate body line. + +`PageHeader` gains a `stickyRow` slot that any page can use to declare a single row that should be the only pinned element, with the rest of the header treated as scrolling chrome. Other pages are unaffected — a `PageHeader` without `stickyRow` keeps the existing fully-sticky behavior. diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 1fc157053b..2225949dd3 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -116,6 +116,8 @@ import DBTableChart from '@/components/DBTableChart'; import { DBTimeChart } from '@/components/DBTimeChart'; import { FavoriteButton } from '@/components/FavoriteButton'; import FullscreenPanelModal from '@/components/FullscreenPanelModal'; +import { PageHeader } from '@/components/PageHeader'; +import { PageLayout } from '@/components/PageLayout'; import { TimePicker } from '@/components/TimePicker'; import { parseTimeRangeInput } from '@/components/TimePicker/utils'; import { @@ -2218,8 +2220,308 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { dashboard?.savedQuery || dashboard?.savedFilterValues?.length, ); - return ( - + const dashboardMeta = + !isLocalDashboard && dashboard ? ( + + {dashboard.createdBy && ( + + Created by {dashboard.createdBy.name || dashboard.createdBy.email}.{' '} + + )} + {dashboard.updatedAt && ( + + + {dashboard.updatedBy + ? ` by ${dashboard.updatedBy.name || dashboard.updatedBy.email}` + : ''} + + } + > + {`Updated ${formatDistanceToNow(new Date(dashboard.updatedAt), { addSuffix: true })}.`} + + )} + + ) : null; + + const pageBreadcrumbs = ( + + + + Dashboards + + + {isLocalDashboard + ? 'Temporary Dashboard' + : (dashboard?.name ?? 'Untitled')} + + + {dashboardMeta} + + ); + + const dashboardName = ( + { + if (dashboard != null) { + setDashboard({ + ...dashboard, + name: editedName, + }); + } + }} + /> + ); + + const dashboardActions = !isLocalDashboard ? ( + + {dashboard?.id && ( + + )} + {dashboard?.id && ( + + + + )} + {/* local dashboards cant be "deleted" */} + + + + + + + + + {containers.length > 0 && ( + <> + View + + ) : ( + + ) + } + onClick={() => setTocVisible(v => !v)} + data-testid="toggle-toc-menu-item" + > + {tocVisible + ? 'Hide table of contents' + : 'Show table of contents'} + + } + onClick={handleCollapseAll} + disabled={collapsibleContainers.length === 0} + data-testid="collapse-all-sections-menu-item" + > + Collapse all sections + + } + onClick={handleExpandAll} + disabled={collapsibleContainers.length === 0} + data-testid="expand-all-sections-menu-item" + > + Expand all sections + + + + )} + {hasTiles && ( + } + onClick={() => { + if (!sources || !dashboard) { + notifications.show({ + color: 'red', + message: 'Export Failed', + }); + return; + } + downloadObjectAsJson( + convertToDashboardTemplate( + dashboard, + sources, + connections, + allDashboards ?? [], + ), + dashboard?.name, + ); + }} + > + 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 && ( + } + color="red" + onClick={handleRemoveSavedQuery} + > + Remove Default Query & Filters + + )} + + } + color="red" + onClick={() => + deleteDashboard.mutate(dashboard?.id ?? '', { + onSuccess: () => { + router.push('/dashboards'); + }, + }) + } + > + Delete Dashboard + + + + + ) : null; + + const titleRow = ( + + {dashboardName} + {dashboardActions} + + ); + + const queryToolbar = ( + { + e.preventDefault(); + onSubmit(); + }} + > + + setValue('whereLanguage', lang) + } + label="WHERE" + enableHotkey + allowMultiline + minWidth={300} + data-testid="search-input" + /> + { + onSearch(range); + }} + /> + + + + + + + + + + + setShowFiltersModal(true)} + data-testid="edit-filters-button" + size="input-sm" + > + + + + + + ); + + // Extracted for the same reason as `KubernetesDashboardPage`: keeps the + // `` return shallow and prevents the tile-grid tree below + // from being wrapped in an extra indentation level. + const dashboardBody = ( + <> {dashboard?.name ? `${dashboard.name}` : 'Dashboard'} – {brandName} @@ -2261,321 +2563,23 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ); }} /> - - {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> - </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> - </> - ) : ( - <Group align="flex-start" mb="xs" mt="xs" justify="space-between"> - <Breadcrumbs fz="sm"> - <Anchor component={Link} href="/dashboards/list" fz="sm" c="dimmed"> - Dashboards - </Anchor> - <Text fz="sm" c="dimmed" maw={500} truncate="end" lh={1}> - {dashboard?.name ?? 'Untitled'} - </Text> - </Breadcrumbs> - {!isLocalDashboard && dashboard && ( - <Text size="xs" c="dimmed"> - {dashboard.createdBy && ( - <span> - Created by{' '} - {dashboard.createdBy.name || dashboard.createdBy.email}.{' '} - </span> - )} - {dashboard.updatedAt && ( - <Tooltip - label={ - <> - <FormatTime value={dashboard.updatedAt} format="short" /> - {dashboard.updatedBy - ? ` by ${dashboard.updatedBy.name || dashboard.updatedBy.email}` - : ''} - </> - } - > - <span>{`Updated ${formatDistanceToNow(new Date(dashboard.updatedAt), { addSuffix: true })}.`}</span> - </Tooltip> - )} + {isLocalDashboard && ( + <Paper mt="xs" mb="md" 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> - )} - </Group> - )} - <Flex mt="xs" mb="md" justify="space-between" align="flex-start"> - <EditablePageName - key={`${dashboardHash}`} - name={dashboard?.name ?? ''} - onSave={editedName => { - if (dashboard != null) { - setDashboard({ - ...dashboard, - name: editedName, - }); - } - }} - /> - <Group gap="xs"> - {!isLocalDashboard && dashboard?.id && ( - <FavoriteButton - resourceType="dashboard" - resourceId={dashboard.id} - /> - )} - {!isLocalDashboard && dashboard?.id && ( - <Tags - allowCreate - values={dashboard?.tags || []} - onChange={handleUpdateTags} + <Button + variant="primary" + fw={400} + onClick={onCreateDashboard} + data-testid="create-dashboard-button" > - <Button - variant="secondary" - px="xs" - size="xs" - style={{ flexShrink: 0 }} - > - <IconTags size={14} className="me-2" /> - {dashboard?.tags?.length || 0}{' '} - {dashboard?.tags?.length === 1 ? 'Tag' : 'Tags'} - </Button> - </Tags> - )} - {!isLocalDashboard /* local dashboards cant be "deleted" */ && ( - <Menu width={250}> - <Menu.Target> - <ActionIcon - variant="secondary" - size="input-xs" - data-testid="dashboard-menu-button" - > - <IconDotsVertical size={14} /> - </ActionIcon> - </Menu.Target> - - <Menu.Dropdown> - {containers.length > 0 && ( - <> - <Menu.Label>View</Menu.Label> - <Menu.Item - leftSection={ - tocVisible ? ( - <IconLayoutSidebarRightCollapse size={16} /> - ) : ( - <IconLayoutSidebarRightExpand size={16} /> - ) - } - onClick={() => setTocVisible(v => !v)} - data-testid="toggle-toc-menu-item" - > - {tocVisible - ? 'Hide table of contents' - : 'Show table of contents'} - </Menu.Item> - <Menu.Item - leftSection={<IconChevronsUp size={16} />} - onClick={handleCollapseAll} - disabled={collapsibleContainers.length === 0} - data-testid="collapse-all-sections-menu-item" - > - Collapse all sections - </Menu.Item> - <Menu.Item - leftSection={<IconChevronsDown size={16} />} - onClick={handleExpandAll} - disabled={collapsibleContainers.length === 0} - data-testid="expand-all-sections-menu-item" - > - Expand all sections - </Menu.Item> - <Menu.Divider /> - </> - )} - {hasTiles && ( - <Menu.Item - leftSection={<IconDownload size={16} />} - onClick={() => { - if (!sources || !dashboard) { - notifications.show({ - color: 'red', - message: 'Export Failed', - }); - return; - } - downloadObjectAsJson( - convertToDashboardTemplate( - dashboard, - sources, - connections, - allDashboards ?? [], - ), - dashboard?.name, - ); - }} - > - Export Dashboard - </Menu.Item> - )} - <Menu.Item - leftSection={<IconUpload size={16} />} - onClick={() => { - if (dashboard && !dashboard.tiles.length) { - router.push( - `/dashboards/import?dashboardId=${dashboard.id}`, - ); - } else { - router.push('/dashboards/import'); - } - }} - > - {hasTiles ? 'Import New Dashboard' : 'Import Dashboard'} - </Menu.Item> - <Menu.Divider /> - <Menu.Item - data-testid="save-default-query-filters-menu-item" - leftSection={<IconDeviceFloppy size={16} />} - onClick={handleSaveQuery} - > - {hasSavedQueryAndFilterDefaults - ? 'Update Default Query & Filters' - : 'Save Query & Filters as Default'} - </Menu.Item> - {hasSavedQueryAndFilterDefaults && ( - <Menu.Item - data-testid="remove-default-query-filters-menu-item" - leftSection={<IconX size={16} />} - color="red" - onClick={handleRemoveSavedQuery} - > - Remove Default Query & Filters - </Menu.Item> - )} - <Menu.Divider /> - <Menu.Item - leftSection={<IconTrash size={16} />} - color="red" - onClick={() => - deleteDashboard.mutate(dashboard?.id ?? '', { - onSuccess: () => { - router.push('/dashboards'); - }, - }) - } - > - Delete Dashboard - </Menu.Item> - </Menu.Dropdown> - </Menu> - )} - </Group> - {/* <Button variant="outline" size="sm"> - Save - </Button> */} - </Flex> - <Flex - gap="sm" - mt="sm" - wrap="wrap" - component="form" - onSubmit={e => { - e.preventDefault(); - onSubmit(); - }} - > - <SearchWhereInput - tableConnections={tableConnections} - control={control} - name="where" - onSubmit={onSubmit} - onLanguageChange={(lang: 'sql' | 'lucene') => - setValue('whereLanguage', lang) - } - label="WHERE" - enableHotkey - allowMultiline - minWidth={300} - data-testid="search-input" - /> - <TimePicker - inputValue={displayedTimeInputValue} - setInputValue={setDisplayedTimeInputValue} - onSearch={range => { - onSearch(range); - }} - /> - <GranularityPickerControlled control={control} name="granularity" /> - <Tooltip - withArrow - label={ - isRefreshEnabled - ? `Auto-refreshing with ${granularityOverride} interval` - : 'Enable auto-refresh' - } - fz="xs" - color="gray" - > - <Button - onClick={() => setIsLive(prev => !prev)} - size="sm" - variant={isLive ? 'primary' : 'secondary'} - title={isLive ? 'Disable auto-refresh' : 'Enable auto-refresh'} - > - Live - </Button> - </Tooltip> - <Tooltip withArrow label="Refresh dashboard" fz="xs" color="gray"> - <ActionIcon - onClick={refresh} - loading={manualRefreshCooloff} - disabled={manualRefreshCooloff} - variant="secondary" - title="Refresh dashboard" - size="input-sm" - > - <IconRefresh size={18} /> - </ActionIcon> - </Tooltip> - <Tooltip withArrow label="Edit Filters" fz="xs" color="gray"> - <ActionIcon - variant="secondary" - onClick={() => setShowFiltersModal(true)} - data-testid="edit-filters-button" - size="input-sm" - > - <IconFilterEdit size={18} /> - </ActionIcon> - </Tooltip> - <Button - data-testid="search-submit-button" - variant="primary" - type="submit" - leftSection={<IconPlayerPlay size={16} />} - style={{ flexShrink: 0 }} - > - Run - </Button> - </Flex> + Create New Saved Dashboard + </Button> + </Flex> + </Paper> + )} {shouldShowIgnoredFiltersBanner && ignoredFilterExpressions.length > 0 && ( <Alert @@ -2768,7 +2772,20 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { onRemoveFilter={handleRemoveFilter} isLoading={isSavingDashboard || isFetchingDashboard} /> - </Box> + </> + ); + + return ( + <PageLayout + data-testid="dashboard-page" + header={ + <PageHeader breadcrumbs={pageBreadcrumbs} stickyRow={queryToolbar}> + {titleRow} + </PageHeader> + } + padded + content={dashboardBody} + /> ); } diff --git a/packages/app/src/DashboardFilters.tsx b/packages/app/src/DashboardFilters.tsx index 141362670b..78a29dfdb6 100644 --- a/packages/app/src/DashboardFilters.tsx +++ b/packages/app/src/DashboardFilters.tsx @@ -77,7 +77,7 @@ const DashboardFilters = ({ }); return ( - <Group mt="sm" align="start"> + <Group align="start"> {Object.values(filters).map(filter => { const queriedFilterValues = filterValuesById?.get(filter.id); const included = filterValues[filter.expression]?.included; diff --git a/packages/app/src/components/PageHeader.module.scss b/packages/app/src/components/PageHeader.module.scss index 47964b9227..3408b57ead 100644 --- a/packages/app/src/components/PageHeader.module.scss +++ b/packages/app/src/components/PageHeader.module.scss @@ -17,6 +17,42 @@ z-index: 100; } +/* Applied automatically when a `stickyRow` is provided: the chrome scrolls + away with the page and the `stickyRow` below becomes the only pinned bar. + The chrome's bottom border and bottom padding are also dropped — the + sticky row owns both the separator and the breathing room between page + chrome and scrolling content, so doubling up creates a visible gap. */ +.notSticky { + position: static; + top: auto; + z-index: auto; + border-bottom: none; + padding-block-end: 0; +} + +/* Sibling of `.header`. Pinned to the top of the scroll container; styled + to match the header (body background + bottom border) so chart/list + content scrolls cleanly underneath. Vertical padding adapts to the + "stuck" state via `.stickyRowAttached` — see below. */ +.stickyRow { + background-color: var(--color-bg-body); + border-bottom: 1px solid var(--color-border); + padding: var(--mantine-spacing-sm); + position: sticky; + top: 0; + z-index: 100; +} + +/* Applied when the sticky row sits directly under chrome AND is not + currently stuck. In that state we drop the row's `padding-top` so the + chrome and sticky row read as one continuous block (the chrome's own + bottom edge already provides spacing). As soon as the row sticks at + `top: 0`, this class is removed and the row's normal `sm` top padding + takes over, giving the toolbar breathing room from the viewport edge. */ +.stickyRowAttached { + padding-top: 0; +} + /* Breadcrumbs row + toolbar row inside one sticky header */ .headerStacked { align-items: stretch; diff --git a/packages/app/src/components/PageHeader.tsx b/packages/app/src/components/PageHeader.tsx index 3ce58dcbaf..6945556553 100644 --- a/packages/app/src/components/PageHeader.tsx +++ b/packages/app/src/components/PageHeader.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import styles from './PageHeader.module.scss'; @@ -45,6 +46,21 @@ export type PageHeaderProps = { children?: React.ReactNode; className?: string; 'data-testid'?: string; + /** + * Single-row content that should be the only sticky element on the + * page. When provided, the rest of the header (`breadcrumbs`, + * `title` / `leading` / `actions`, `children`) becomes non-sticky + * chrome that scrolls away, and this row is pinned to the top of the + * scroll container instead. + * + * Use this for pages that have a tall header (e.g. dashboards with + * breadcrumbs + an editable name + a query toolbar) where only a + * specific row — typically the controls users keep reaching for while + * scrolling, like the query toolbar — should remain visible. + * + * When omitted, the header behaves as a single sticky block. + */ + stickyRow?: React.ReactNode; }; export function PageHeader({ @@ -55,20 +71,36 @@ export function PageHeader({ children, className, 'data-testid': testId, + stickyRow, }: PageHeaderProps) { const hasToolbar = title != null || leading != null || actions != null; const hasBreadcrumbs = breadcrumbs != null; + const hasStickyRow = stickyRow != null; - if (!hasToolbar && !hasBreadcrumbs) { - return ( - <header - className={classNames(styles.header, className)} - data-testid={testId} - > - {children} - </header> + // Detect when the sticky row reaches `top: 0` of the scroll container. + // `IntersectionObserver` with `rootMargin: -1px` at the top fires the + // moment the row's top edge is clipped by the viewport (i.e. it has + // become stuck). We use this to swap the row's `padding-top` between + // "attached to chrome" (no top padding, tight at rest) and "stuck" + // (standard top padding so the toolbar isn't flush with the edge). + const stickyRowRef = useRef<HTMLDivElement>(null); + const [isStuck, setIsStuck] = useState(false); + useEffect(() => { + const node = stickyRowRef.current; + if (!node || !hasStickyRow) return; + const observer = new IntersectionObserver( + ([entry]) => setIsStuck(entry.intersectionRatio < 1), + { threshold: [1], rootMargin: '-1px 0px 0px 0px' }, ); - } + observer.observe(node); + return () => observer.disconnect(); + }, [hasStickyRow]); + // When a `stickyRow` is provided, the chrome above it scrolls with the + // page. The chrome and the sticky row render as siblings (Fragment) so + // the sticky row's containing block is the page layout root rather + // than the chrome `<header>`, which lets it stay pinned for the full + // length of the page rather than only the height of the header. + const chromeStickyClass = hasStickyRow ? styles.notSticky : undefined; const toolbarInner = ( <> @@ -80,36 +112,75 @@ export function PageHeader({ </> ); - if (hasBreadcrumbs && hasToolbar) { - return ( + const chrome = + !hasToolbar && !hasBreadcrumbs ? ( + // Only render the chrome `<header>` if there's something to put in + // it. A page with only a `stickyRow` (no breadcrumbs, no title) + // skips the empty chrome entirely. + children != null ? ( + <header + className={classNames(styles.header, chromeStickyClass, className)} + data-testid={testId} + > + {children} + </header> + ) : null + ) : hasBreadcrumbs && hasToolbar ? ( <header - className={classNames(styles.header, styles.headerStacked, className)} + className={classNames( + styles.header, + styles.headerStacked, + chromeStickyClass, + className, + )} data-testid={testId} > <div className={styles.breadcrumbsRow}>{breadcrumbs}</div> <div className={styles.toolbarRow}>{toolbarInner}</div> </header> - ); - } - - if (hasBreadcrumbs && !hasToolbar) { - return ( + ) : hasBreadcrumbs && !hasToolbar ? ( <header - className={classNames(styles.header, styles.headerStacked, className)} + className={classNames( + styles.header, + styles.headerStacked, + chromeStickyClass, + className, + )} data-testid={testId} > <div className={styles.breadcrumbsRow}>{breadcrumbs}</div> {children} </header> + ) : ( + <header + className={classNames(styles.header, chromeStickyClass, className)} + data-testid={testId} + > + {toolbarInner} + </header> ); - } + + if (!hasStickyRow) return chrome; return ( - <header - className={classNames(styles.header, className)} - data-testid={testId} - > - {toolbarInner} - </header> + <> + {chrome} + <div + ref={stickyRowRef} + className={classNames( + styles.stickyRow, + // While the row is in its at-rest position directly under + // chrome, drop its `padding-top` so the chrome's bottom edge + // and the toolbar read as one continuous block. Once stuck, + // the standard padding kicks back in so the toolbar isn't + // flush against the viewport edge. + chrome != null && !isStuck && styles.stickyRowAttached, + )} + data-stuck={isStuck ? 'true' : undefined} + data-testid="page-header-sticky-row" + > + {stickyRow} + </div> + </> ); }