Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .claude/skills/page-layout/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 `<Head><title>`, 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 |
1 change: 1 addition & 0 deletions agent_docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
135 changes: 135 additions & 0 deletions agent_docs/page_layout.md
Original file line number Diff line number Diff line change
@@ -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.
167 changes: 90 additions & 77 deletions packages/app/src/SessionsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
} from '@hyperdx/common-utils/dist/types';
import {
Anchor,
Box,
Button,
Code,
Flex,
Expand All @@ -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';
Expand Down Expand Up @@ -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}</title>
</Head>
Expand All @@ -382,80 +379,96 @@ export default function SessionsPage() {
}
/>
)}
<Box p="sm" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<form
data-testid="sessions-search-form"
onSubmit={e => {
e.preventDefault();
onSubmit();
return false;
}}
>
<Flex gap="xs" direction="column" wrap="nowrap">
<Group justify="space-between" gap="xs" wrap="nowrap" flex={1}>
<SourceSelectControlled
control={control}
name="source"
allowedSourceKinds={[SourceKind.Session]}
/>
<SearchWhereInput
tableConnection={tcFromSource(traceTrace)}
control={control}
name="where"
onSubmit={onSubmit}
enableHotkey
width="50%"
/>
<TimePicker
inputValue={displayedTimeInputValue}
setInputValue={setDisplayedTimeInputValue}
onSearch={range => {
onSearch(range);
}}
/>
<Button
variant="primary"
type="submit"
px="sm"
leftSection={<IconPlayerPlay size={16} />}
style={{ flexShrink: 0 }}
>
Run
</Button>
</Group>
</Flex>
</form>

{isSessionsLoading || isSessionSourceLoading ? (
<Group mt="md" align="center" justify="center" gap="xs">
<IconRefresh className="spin-animate" size={14} />
{isSessionSourceLoading ? 'Loading...' : 'Searching sessions...'}
</Group>
) : (
<>
{!sessions.length ? (
<Flex
align="center"
justify="center"
style={{ flex: 1, minHeight: 0 }}
<form
className={`SessionsPage ${styles.pageForm}`}
data-testid="sessions-search-form"
onSubmit={e => {
e.preventDefault();
onSubmit();
return false;
}}
>
<PageLayout
data-testid="sessions-page"
header={
<PageHeader>
<Group
justify="space-between"
gap="xs"
wrap="nowrap"
w="100%"
className={styles.toolbar}
>
<SessionSetupInstructions />
</Flex>
) : (
<div style={{ minHeight: 0 }} className="mt-4">
<SessionCardList
onClick={session => {
setSelectedSession(session);
<SourceSelectControlled
control={control}
name="source"
allowedSourceKinds={[SourceKind.Session]}
/>
<SearchWhereInput
tableConnection={tcFromSource(traceTrace)}
control={control}
name="where"
onSubmit={onSubmit}
enableHotkey
width="50%"
/>
<TimePicker
inputValue={displayedTimeInputValue}
setInputValue={setDisplayedTimeInputValue}
onSearch={range => {
onSearch(range);
}}
sessions={sessions}
isSessionLoading={isSessionsLoading}
/>
</div>
)}
</>
)}
</Box>
</div>
<Button
variant="primary"
type="submit"
px="sm"
leftSection={<IconPlayerPlay size={16} />}
style={{ flexShrink: 0 }}
>
Run
</Button>
</Group>
</PageHeader>
}
padded
content={
<>
{isSessionsLoading || isSessionSourceLoading ? (
<Group mt="md" align="center" justify="center" gap="xs">
<IconRefresh className="spin-animate" size={14} />
{isSessionSourceLoading
? 'Loading...'
: 'Searching sessions...'}
</Group>
) : (
<>
{!sessions.length ? (
<Flex
align="center"
justify="center"
style={{ flex: 1, minHeight: 0 }}
>
<SessionSetupInstructions />
</Flex>
) : (
<div style={{ minHeight: 0 }}>
<SessionCardList
onClick={session => {
setSelectedSession(session);
}}
sessions={sessions}
isSessionLoading={isSessionsLoading}
/>
</div>
)}
</>
)}
</>
}
/>
</form>
</>
);
}

Expand Down
Loading
Loading