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" */}
+
+
+ ) : 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"
+ >
+
+
+
+ }
+ style={{ flexShrink: 0 }}
+ >
+ Run
+
+
+ );
+
+ // 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 ? (
- <>
-
-
- Dashboards
-
-
- Temporary Dashboard
-
-
-
-
-
- This is a temporary dashboard and can not be saved.
-
-
-
-
- >
- ) : (
-
-
-
- Dashboards
-
-
- {dashboard?.name ?? 'Untitled'}
-
-
- {!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 })}.`}
-
- )}
+ {isLocalDashboard && (
+
+
+
+ This is a temporary dashboard and can not be saved.
- )}
-
- )}
-
- {
- if (dashboard != null) {
- setDashboard({
- ...dashboard,
- name: editedName,
- });
- }
- }}
- />
-
- {!isLocalDashboard && dashboard?.id && (
-
- )}
- {!isLocalDashboard && dashboard?.id && (
-
-
-
- )}
- {!isLocalDashboard /* local dashboards cant be "deleted" */ && (
-
- )}
-
- {/* */}
-
- {
- 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"
- >
-
-
-
- }
- style={{ flexShrink: 0 }}
- >
- Run
-
-
+ Create New Saved Dashboard
+
+
+
+ )}
{shouldShowIgnoredFiltersBanner &&
ignoredFilterExpressions.length > 0 && (
-
+ >
+ );
+
+ return (
+
+ {titleRow}
+
+ }
+ 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 (
-
+
{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 (
-
+ // 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(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 ``, 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 `` 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 ? (
+
+ ) : null
+ ) : hasBreadcrumbs && hasToolbar ? (
{breadcrumbs}
{toolbarInner}
- );
- }
-
- if (hasBreadcrumbs && !hasToolbar) {
- return (
+ ) : hasBreadcrumbs && !hasToolbar ? (
+ ) : (
+
);
- }
+
+ if (!hasStickyRow) return chrome;
return (
-
+ <>
+ {chrome}
+
+ {stickyRow}
+
+ >
);
}