diff --git a/.claude/skills/page-layout/SKILL.md b/.claude/skills/page-layout/SKILL.md new file mode 100644 index 0000000000..7d2cadccd2 --- /dev/null +++ b/.claude/skills/page-layout/SKILL.md @@ -0,0 +1,44 @@ +--- +name: page-layout +description: Add or update app page headers and layout using PageHeader and PageLayout. Use when creating a new page, fixing inconsistent headers, or migrating Service Map / Kubernetes / dashboard pages to the shared layout. +--- + +# Page layout and headers + +Read **`agent_docs/page_layout.md`** before changing any page shell. It is the source of truth for API, examples, and migration steps. + +## Quick rules + +1. **Default for new pages**: `PageLayout` with `title`, optional `leading` / `actions`, and `content` — only when the sticky bar is **text-only** (no inputs in that bar). +2. **Sticky bar contains inputs** (source pickers, SQL/search, sliders, time range, Run, etc.): **do not** use `PageHeader` / `PageLayout` **`title`**. The sticky row is for controls only. Put **where you are** in the **`breadcrumbs` prop** (renders **inside the sticky `PageHeader`**, above the toolbar) when the page lives under a hierarchy (e.g. `Dashboards` → `Kubernetes`); otherwise you may omit `breadcrumbs` and rely on ``, the sidebar active item, and the empty state copy. +3. **Never** duplicate location: do not set `title="Kubernetes Dashboard"` **and** breadcrumbs that repeat the same page name. +4. **Never** use `<Text size="xl">` as the page title in the body. +5. **Global controls** (time range, Run, Save, sampling) go in `actions`, right-aligned. **Context** pickers go in `leading` (no `title` when those slots are used for inputs). **Breadcrumbs** use the `breadcrumbs` prop so they stay in the sticky header, not in `content`. +6. **Full-height tools** (maps, large charts): `fillViewport` on `PageLayout`. +7. **Search / Chart Explorer**: keep bespoke toolbars unless the task is explicitly to redesign them. + +## Workflow + +1. Read `agent_docs/page_layout.md`. +2. Open a similar page already on `PageHeader` / `PageLayout` (e.g. `AlertsPage.tsx`, `DBServiceMapPage.tsx`, `KubernetesDashboardPage.tsx`). +3. Implement using `@/components/PageLayout` or `@/components/PageHeader`. +4. Preserve or add `data-testid` on the page root for E2E tests. +5. Run `yarn lint:fix` in the repo root when done. +6. If the page has E2E coverage, run the relevant spec under `packages/app/tests/e2e/`. + +## Imports + +```tsx +import { PageHeader } from '@/components/PageHeader'; +import { PageLayout } from '@/components/PageLayout'; +``` + +## Reference implementations + +| Page | File | Pattern | +|------|------|---------| +| List page | `AlertsPage.tsx` | `PageHeader` + `title` + `Container` (no inputs in header) | +| Tool page with inputs + hierarchy | `KubernetesDashboardPage.tsx`, `ClickhousePage.tsx` | `PageLayout` **without** `title`; **`breadcrumbs`** + `leading` + `actions` in one sticky header | +| Tool page (top-level) | `DBServiceMapPage.tsx` | `PageLayout` **without** `title`; `leading` / `actions` only; no duplicate breadcrumb unless you add a real hierarchy | +| Custom toolbar | `SessionsPage.tsx` | `PageLayout` + `header` = custom `PageHeader` `children` (single-row inputs, no `title`) | +| Custom title | `TeamPage.tsx` | `PageHeader` with `children` only | diff --git a/agent_docs/README.md b/agent_docs/README.md index 957afbe811..67af911e0a 100644 --- a/agent_docs/README.md +++ b/agent_docs/README.md @@ -16,6 +16,7 @@ Instead of stuffing all instructions into `AGENTS.md` (which goes into every con - **`tech_stack.md`** - Technology choices, UI component patterns, library usage - **`development.md`** - Development workflows, testing strategy, common tasks, debugging - **`code_style.md`** - Code patterns and best practices (read only when actively coding) +- **`page_layout.md`** - PageHeader, PageLayout, and consistent page chrome (titles, actions, tool pages) - **`data_viz_colors.md`** - Chart, heatmap, and semantic status colors. Read before adding or changing any color in a chart, sparkline, heatmap, legend, or status pill. ## Usage Pattern diff --git a/agent_docs/page_layout.md b/agent_docs/page_layout.md new file mode 100644 index 0000000000..18d7274bb2 --- /dev/null +++ b/agent_docs/page_layout.md @@ -0,0 +1,135 @@ +# Page layout and headers + +HyperDX app pages share a sticky top header bar and a scrollable content region below it. Use the shared layout primitives so titles, controls, and spacing stay consistent across Search, list pages, and tool pages (Service Map, Kubernetes, Chart Explorer). + +## Components + +| Component | Path | Use when | +|-----------|------|----------| +| `PageHeader` | `packages/app/src/components/PageHeader.tsx` | You only need the header bar (page already has its own content wrapper). | +| `PageLayout` | `packages/app/src/components/PageLayout.tsx` | You want header + flex content column in one wrapper (recommended for new pages). | + +Both live next to `withAppNav` in `packages/app/src/layout.tsx`, which wraps pages with the left nav and scroll container. + +## PageHeader API + +```tsx +<PageHeader title="Alerts" /> + +{/* Sticky bar has inputs: no `title` — pass breadcrumbs into the header */} +<PageHeader + breadcrumbs={<Breadcrumbs>...</Breadcrumbs>} + leading={<SourceSelectControlled ... />} + actions={<TimePicker ... />} +/> + +<PageHeader> + {/* Custom title row, e.g. editable team name on Team settings */} +</PageHeader> +``` + +| Prop | Purpose | +|------|---------| +| `title` | Plain page title (`<h1>`). **Use only when the sticky bar has no inputs** (list/settings pages). If the bar has pickers, search, sliders, or Run, omit `title` and use **`breadcrumbs`** (or rely on nav + document title only). | +| `breadcrumbs` | Location trail **inside the sticky header**, rendered above the toolbar when `leading` / `actions` are set. Use Mantine `Breadcrumbs` (e.g. `Dashboards` → current page). Do not duplicate the same text as `title`. | +| `leading` | Left cluster: source picker, badges, or other controls. When inputs are present, **do not** pair with `title` for the same page name. | +| `actions` | Right-aligned cluster: time range, Run/Save, sampling, refresh. | +| `children` | Full custom header when slots are not enough. Do not combine with `title` / `leading` / `actions` / `breadcrumbs` unless you use the breadcrumbs-only branch. | + +Header styling is defined in `PageHeader.module.scss`: sticky top, bottom border, horizontal padding `var(--mantine-spacing-sm)` (same as Search `px="sm"`). Single-row headers keep `min-height: 60px`; stacked header (breadcrumbs + toolbar) grows with content. + +## PageLayout API + +```tsx +<PageLayout + data-testid="service-map-page" + breadcrumbs={<Breadcrumbs>...</Breadcrumbs>} + leading={sourceSelect} + actions={headerActions} + fillViewport + content={<ServiceMap ... />} +/> +``` + +| Prop | Purpose | +|------|---------| +| `title`, `leading`, `actions`, `breadcrumbs`, `children` | Forwarded to `PageHeader` (same rules as above). | +| `header` | Replace the default `PageHeader` entirely. | +| `content` | Main page body below the header (required). | +| `fillViewport` | Set `height: 100vh` on the page root for full-height canvases (maps, charts). | +| `contentClassName` | Extra class on the content region (e.g. padding). | + +## When to use which pattern + +### List / settings pages (Alerts, Dashboards, Saved Searches, Team) + +- Use `PageHeader` at the top of the page. +- Put filters, tables, and tabs in a `Container` below the header (see `AlertsPage.tsx`, `DashboardsListPage.tsx`). +- Simple title-only pages: `<PageHeader title="Dashboards" />` — **only** when the header row has **no** inputs. + +### Tool / canvas pages (Service Map, Kubernetes, Clickhouse, Chart Explorer) + +- Prefer `PageLayout` with `fillViewport` when the main UI should fill remaining height. +- Put **global** controls in `actions` (time picker, sampling, Run)—not inside the canvas. +- Put **context** controls in `leading` (source picker, environment). +- **If the sticky header row includes any inputs** (selectors, sliders, search, time range, Run): **omit `title`**. Express location with the **`breadcrumbs` prop** on `PageLayout` / `PageHeader` so the trail stays **inside the sticky header** above the toolbar (see `KubernetesDashboardPage.tsx`). Do **not** repeat the same label as both `title` and the last breadcrumb. Top-level tools (e.g. Service Map) may omit `breadcrumbs` if `<Head><title>` and the sidebar active state are enough. + +### Search and Chart Explorer (complex query chrome) + +These pages use bespoke multi-row toolbars instead of a single title row. That is intentional until those flows are redesigned. Do not force a `title` on them; if you add a header row, use `PageHeader` `children` or a dedicated toolbar component, not ad-hoc `Text size="xl"` in the body. + +### Client Sessions (query + list) + +Sessions keeps a **single-row** query toolbar (source, where filter, time range, Run). Put the full toolbar in a custom `PageHeader` via `PageLayout` `header` — do not split controls across `title` / `leading` / `actions` / `content`. + +## Do / don't + +```tsx +// ❌ Ad-hoc title in page body +<Group justify="space-between"> + <Text size="xl">Service Map</Text> + <TimePicker ... /> +</Group> + +// ✅ Dashboard tool page: breadcrumbs in header, inputs in same sticky block (no `title`) +<PageLayout + breadcrumbs={<Breadcrumbs>...</Breadcrumbs>} + leading={<SourceSelect ... />} + actions={<TimePicker ... />} + content={<>...</>} +/> + +// ❌ Title + breadcrumbs repeating the same page name +<PageLayout title="Kubernetes Dashboard" breadcrumbs={<Breadcrumbs>… Kubernetes</Breadcrumbs>} /> + +// ❌ Duplicate padding wrapper around the whole page including title +<Box p="sm"> + <Text size="xl">Alerts</Text> + ... +</Box> + +// ✅ Header outside padded content +<PageHeader title="Alerts" /> +<Container py="lg">...</Container> +``` + +## Migrating an existing page + +1. Replace `Text size="xl"` title + `Group justify="space-between"` with `PageLayout` or `PageHeader` slots. +2. Move time picker and primary actions to `actions`. +3. Move source pickers and badges to `leading`. +4. If the sticky bar has inputs, **do not** set `title`; pass **`breadcrumbs`** on `PageLayout` when the route has a hierarchy (breadcrumbs render inside the sticky `PageHeader`, not in `content`). +5. Keep `data-testid` on `PageLayout` / page root for E2E tests. +6. Run affected Playwright tests (`packages/app/tests/e2e/`). + +## Pages using PageHeader / PageLayout today + +- Alerts, Dashboards, Saved Searches — `PageHeader` with `title` +- Team — custom `PageHeader` `children` (editable name) +- Service Map — `PageLayout` with `fillViewport`, `leading` / `actions` only (no `title`; top-level tool) +- Kubernetes Dashboard, Clickhouse Dashboard — `PageLayout` with `breadcrumbs`, `leading`, `actions`, `padded` (no `title`) +- Client Sessions — `PageLayout` with custom `PageHeader` containing the full single-row query toolbar + +## Knip + +`packages/app` Knip entry roots are `pages/`, `scripts/`, and e2e tests. After you add or move a `PageLayout` import, run `yarn knip` from the repo root (or `yarn knip` in `packages/app` if configured) so new consumers stay wired. diff --git a/packages/app/src/SessionsPage.tsx b/packages/app/src/SessionsPage.tsx index 25661456a6..70b4490fdc 100644 --- a/packages/app/src/SessionsPage.tsx +++ b/packages/app/src/SessionsPage.tsx @@ -16,7 +16,6 @@ import { } from '@hyperdx/common-utils/dist/types'; import { Anchor, - Box, Button, Code, Flex, @@ -32,6 +31,8 @@ import { import { useVirtualizer } from '@tanstack/react-virtual'; import EmptyState from '@/components/EmptyState'; +import { PageHeader } from '@/components/PageHeader'; +import { PageLayout } from '@/components/PageLayout'; import { SourceSelectControlled } from '@/components/SourceSelect'; import { TimePicker } from '@/components/TimePicker'; import { parseTimeQuery, useNewTimeQuery } from '@/timeQuery'; @@ -352,11 +353,7 @@ export default function SessionsPage() { const targetSession = sessions.find(s => s.sessionId === selectedSession?.id); return ( - <div - className="SessionsPage" - data-testid="sessions-page" - style={{ display: 'flex', flexDirection: 'column', height: '100%' }} - > + <> <Head> <title>Client Sessions - {brandName} @@ -382,80 +379,96 @@ export default function SessionsPage() { } /> )} - -
{ - e.preventDefault(); - onSubmit(); - return false; - }} - > - - - - - { - onSearch(range); - }} - /> - - - -
- - {isSessionsLoading || isSessionSourceLoading ? ( - - - {isSessionSourceLoading ? 'Loading...' : 'Searching sessions...'} - - ) : ( - <> - {!sessions.length ? ( - { + e.preventDefault(); + onSubmit(); + return false; + }} + > + + - - - ) : ( -
- { - setSelectedSession(session); + + + { + onSearch(range); }} - sessions={sessions} - isSessionLoading={isSessionsLoading} /> -
- )} - - )} -
- + + + + } + padded + content={ + <> + {isSessionsLoading || isSessionSourceLoading ? ( + + + {isSessionSourceLoading + ? 'Loading...' + : 'Searching sessions...'} + + ) : ( + <> + {!sessions.length ? ( + + + + ) : ( +
+ { + setSelectedSession(session); + }} + sessions={sessions} + isSessionLoading={isSessionsLoading} + /> +
+ )} + + )} + + } + /> + + ); } diff --git a/packages/app/src/components/PageHeader.module.scss b/packages/app/src/components/PageHeader.module.scss index 7bcaadbbcf..47964b9227 100644 --- a/packages/app/src/components/PageHeader.module.scss +++ b/packages/app/src/components/PageHeader.module.scss @@ -5,11 +5,63 @@ color: var(--color-text-default); display: flex; font-weight: 500; - height: 60px; + gap: 16px; + min-height: 60px; justify-content: space-between; line-height: 1; - padding: 0 32px; + + /* Match Search page toolbar: DBSearchPage uses Mantine `px="sm"` */ + padding: 0 var(--mantine-spacing-sm); position: sticky; top: 0; z-index: 100; } + +/* Breadcrumbs row + toolbar row inside one sticky header */ +.headerStacked { + align-items: stretch; + flex-direction: column; + gap: var(--mantine-spacing-xs); + justify-content: flex-start; + min-height: auto; + padding-block: var(--mantine-spacing-xs); +} + +.breadcrumbsRow { + flex-shrink: 0; + font-weight: 400; + line-height: 1.2; +} + +.toolbarRow { + align-items: center; + display: flex; + flex: 1; + gap: 16px; + justify-content: space-between; + min-height: 44px; + min-width: 0; +} + +.start { + align-items: center; + display: flex; + flex: 1; + gap: 12px; + min-width: 0; +} + +.title { + font-size: inherit; + font-weight: inherit; + line-height: inherit; + margin: 0; + white-space: nowrap; +} + +.actions { + align-items: center; + display: flex; + flex-shrink: 0; + gap: 12px; +} diff --git a/packages/app/src/components/PageHeader.tsx b/packages/app/src/components/PageHeader.tsx index 61ee10efbe..61b7a31cad 100644 --- a/packages/app/src/components/PageHeader.tsx +++ b/packages/app/src/components/PageHeader.tsx @@ -1,5 +1,88 @@ +import classNames from 'classnames'; + import styles from './PageHeader.module.scss'; -export const PageHeader = ({ children }: { children: React.ReactNode }) => { - return
{children}
; +export type PageHeaderProps = { + /** Plain-text page title for standard list and tool pages. */ + title?: string; + /** Content after the title (source picker, badge, edit control). */ + leading?: React.ReactNode; + /** Right-aligned controls (time picker, Run/Save, sampling). */ + actions?: React.ReactNode; + /** + * Location trail inside the sticky header (above the toolbar when `leading` / `actions` exist). + * Use with dashboard-style routes; omit `title` when the toolbar has inputs. + */ + breadcrumbs?: React.ReactNode; + /** Custom header when structured slots are not enough (e.g. editable team name). */ + children?: React.ReactNode; + className?: string; + 'data-testid'?: string; }; + +export function PageHeader({ + title, + leading, + actions, + breadcrumbs, + children, + className, + 'data-testid': testId, +}: PageHeaderProps) { + const hasToolbar = title != null || leading != null || actions != null; + const hasBreadcrumbs = breadcrumbs != null; + + if (!hasToolbar && !hasBreadcrumbs) { + return ( +
+ {children} +
+ ); + } + + const toolbarInner = ( + <> +
+ {title != null &&

{title}

} + {leading} +
+ {actions != null &&
{actions}
} + + ); + + if (hasBreadcrumbs && hasToolbar) { + return ( +
+
{breadcrumbs}
+
{toolbarInner}
+
+ ); + } + + if (hasBreadcrumbs && !hasToolbar) { + return ( +
+
{breadcrumbs}
+ {children} +
+ ); + } + + return ( +
+ {toolbarInner} +
+ ); +} diff --git a/packages/app/src/components/PageLayout.module.scss b/packages/app/src/components/PageLayout.module.scss new file mode 100644 index 0000000000..ef6ee44f1d --- /dev/null +++ b/packages/app/src/components/PageLayout.module.scss @@ -0,0 +1,22 @@ +.root { + background-color: var(--color-bg-body); + display: flex; + flex-direction: column; + min-height: 100%; +} + +.fillViewport { + height: 100vh; +} + +.content { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + min-width: 0; +} + +.padded { + padding: var(--mantine-spacing-sm); +} diff --git a/packages/app/src/components/PageLayout.tsx b/packages/app/src/components/PageLayout.tsx new file mode 100644 index 0000000000..c4398434f0 --- /dev/null +++ b/packages/app/src/components/PageLayout.tsx @@ -0,0 +1,75 @@ +import classNames from 'classnames'; + +import { PageHeader, type PageHeaderProps } from './PageHeader'; + +import styles from './PageLayout.module.scss'; + +export type PageLayoutProps = Pick< + PageHeaderProps, + 'title' | 'leading' | 'actions' | 'breadcrumbs' | 'children' +> & { + /** Page content below the header. */ + content: React.ReactNode; + /** Custom header when `title` / `leading` / `actions` are not enough. */ + header?: React.ReactNode; + className?: string; + contentClassName?: string; + 'data-testid'?: string; + fillViewport?: boolean; + /** Apply standard page body padding (`var(--mantine-spacing-sm)`). */ + padded?: boolean; +}; + +export function PageLayout({ + title, + leading, + actions, + breadcrumbs, + children: headerChildren, + header, + content, + className, + contentClassName, + 'data-testid': testId, + fillViewport = false, + padded = false, +}: PageLayoutProps) { + const headerNode = + header ?? + (title != null || + leading != null || + actions != null || + breadcrumbs != null || + headerChildren ? ( + + {headerChildren} + + ) : null); + + return ( +
+ {headerNode} +
+ {content} +
+
+ ); +} diff --git a/packages/app/styles/SessionsPage.module.scss b/packages/app/styles/SessionsPage.module.scss index 27b37010bf..828142f848 100644 --- a/packages/app/styles/SessionsPage.module.scss +++ b/packages/app/styles/SessionsPage.module.scss @@ -1,3 +1,14 @@ +.pageForm { + display: flex; + flex-direction: column; + height: 100%; +} + +.toolbar { + flex: 1; + min-width: 0; +} + .sessionCard { &:hover { .emailText {