From b8ec82477f6337165d0b7a18336cffeea6106f0c Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Thu, 6 Aug 2026 18:49:22 +0100 Subject: [PATCH 1/5] feat(app): add shared ChartCard component and migrate ChartBox usages Introduce ChartCard (components/charts/), which gives standalone charts the same card treatment as custom dashboard tiles (bordered surface + full-bleed header divider) by reusing ChartContainerCardHeaderProvider and DASHBOARD_TILE_PADDING_INLINE. Migrate the Service Dashboards (HTTP, Database, Errors, endpoint and DB-query side panels) and the ClickHouse page off the old ChartBox wrapper so chart surfaces look consistent across the app. Add a Storybook story and document usage in agent_docs/code_style.md. Co-authored-by: Cursor --- .changeset/shared-chart-card.md | 9 ++ agent_docs/code_style.md | 37 +++++ packages/app/src/ClickhousePage.tsx | 50 +++---- .../src/ServicesDashboardPage/DatabaseTab.tsx | 14 +- .../src/ServicesDashboardPage/ErrorsTab.tsx | 6 +- .../app/src/ServicesDashboardPage/HttpTab.tsx | 22 +-- packages/app/src/components/ChartBox.tsx | 30 ---- .../ServiceDashboardDbQuerySidePanel.tsx | 10 +- ...rviceDashboardEndpointPerformanceChart.tsx | 6 +- .../ServiceDashboardEndpointSidePanel.tsx | 10 +- .../ServiceDashboardSlowestEventsTile.tsx | 6 +- .../components/charts/ChartCard.stories.tsx | 133 ++++++++++++++++++ .../app/src/components/charts/ChartCard.tsx | 59 ++++++++ 13 files changed, 300 insertions(+), 92 deletions(-) create mode 100644 .changeset/shared-chart-card.md delete mode 100644 packages/app/src/components/ChartBox.tsx create mode 100644 packages/app/src/components/charts/ChartCard.stories.tsx create mode 100644 packages/app/src/components/charts/ChartCard.tsx diff --git a/.changeset/shared-chart-card.md b/.changeset/shared-chart-card.md new file mode 100644 index 0000000000..e7b225fe38 --- /dev/null +++ b/.changeset/shared-chart-card.md @@ -0,0 +1,9 @@ +--- +'@hyperdx/app': patch +--- + +Introduce a shared `ChartCard` component that gives standalone charts the same +card treatment as custom dashboard tiles (bordered surface + full-bleed header +divider). Migrate the Service Dashboards (HTTP, Database, Errors, endpoint and +DB-query side panels) and the ClickHouse page from the old `ChartBox` wrapper to +`ChartCard` so chart surfaces look consistent across the app. diff --git a/agent_docs/code_style.md b/agent_docs/code_style.md index acb9ff411c..4b3561311e 100644 --- a/agent_docs/code_style.md +++ b/agent_docs/code_style.md @@ -159,6 +159,43 @@ The variant → token mapping is centralized in `packages/app/src/theme/themes/s **Title copy**: Treat `title` as a short headline (like `Title` in the UI). Do **not** end it with a period. Use `description` for full sentences, which should use normal punctuation including a trailing period when appropriate. Match listing pages (e.g. dashboards and saved searches use parallel phrasing such as “No matching … yet” / “No … yet” without dots). +### Chart cards (ChartCard) + +**Use `ChartCard` (`@/components/charts/ChartCard`) to wrap a chart in a card.** +It is a bordered surface with the same header treatment as a custom dashboard +tile (a full-bleed divider under the title). It replaces the older `ChartBox`; +don't hand-roll a bordered `
`/`` around a chart. + +`ChartCard` renders the card **chrome only**. It draws the header divider by +providing `ChartContainerCardHeaderProvider`, so put a chart that renders a +`ChartContainer` inside it (`DBTimeChart`, `DBHeatmapChart`, `DBListBarChart`, +…). The tile-level controls (fullscreen, line/bar display switcher, kebab menu) +belong to dashboard tiles and are intentionally **not** part of `ChartCard`. + +| Prop | Type | Description | +|------|------|-------------| +| `children` | `ReactNode` | The chart, usually a `DB*Chart` that renders a `ChartContainer` | +| `style` | `CSSProperties` | Sizing/overflow override — pass a fixed `height`, or `flex: 1; height: 100%` to fill a flex row | +| `data-testid` | `string` | Test hook | + +```tsx +// ✅ GOOD — shared card chrome, consistent with dashboard tiles + + + + +// ❌ BAD — hand-rolled card that drifts from the dashboard look + + + +``` + +**Give it a height.** `ChartCard` is `width: 100%` and fills its parent, so the +parent (or a `style={{ height }}`) must define the height. For equal-width +side-by-side charts (e.g. the RED row) use +`style={{ flex: 1, minWidth: 0, minHeight: 0, height: '100%' }}` inside a +`Flex`. See the `Charts/ChartCard` Storybook stories for the variants. + ## Semantic design tokens (prefer over raw Mantine colors) The UI is built with **Mantine components**, but **colors and surfaces** should follow the **semantic CSS custom properties** in our themes (`--color-*`, etc.), not ad-hoc Mantine palette values. Those tokens are defined in `packages/app/src/theme/themes/**/_tokens.scss`, align with a **Click UI**–style system, and keep HyperDX and ClickStack visually consistent. They are the path toward a shared design system even while Mantine remains the component layer. diff --git a/packages/app/src/ClickhousePage.tsx b/packages/app/src/ClickhousePage.tsx index 1ad6df4fad..aac84d852b 100644 --- a/packages/app/src/ClickhousePage.tsx +++ b/packages/app/src/ClickhousePage.tsx @@ -38,7 +38,7 @@ import { PageLayout } from '@/components/PageLayout'; import { TimePicker } from '@/components/TimePicker'; import { withAppNav } from '@/layout'; -import { ChartBox } from './components/ChartBox'; +import { ChartCard } from './components/charts/ChartCard'; import DBHeatmapChart from './components/DBHeatmapChart'; import { DBSqlRowTable } from './components/DBRowTable'; import DBTableChart from './components/DBTableChart'; @@ -70,7 +70,7 @@ function InfrastructureTab({ return ( - + - + - + - + - + - + - + - + - + @@ -250,7 +250,7 @@ function InfrastructureTab({ }} onTimeRangeSelect={onTimeRangeSelect} /> - + ); @@ -355,7 +355,7 @@ function InsertsTab({ return ( - + @@ -387,10 +387,10 @@ function InsertsTab({ config={insertsPerTableConfig} onTimeRangeSelect={onTimeRangeSelect} /> - + - + - + - + @@ -484,7 +484,7 @@ function InsertsTab({ selectGroupBy: false, }} /> - + ); @@ -665,7 +665,7 @@ function ClickhousePage() { - + - + - + - + - + - + - - + diff --git a/packages/app/src/ServicesDashboardPage/DatabaseTab.tsx b/packages/app/src/ServicesDashboardPage/DatabaseTab.tsx index 01a6ce893d..e09bcb7135 100644 --- a/packages/app/src/ServicesDashboardPage/DatabaseTab.tsx +++ b/packages/app/src/ServicesDashboardPage/DatabaseTab.tsx @@ -10,7 +10,7 @@ import { Grid } from '@mantine/core'; import { IconFilter, IconTable } from '@tabler/icons-react'; import { INTEGER_NUMBER_FORMAT, MS_NUMBER_FORMAT } from '@/ChartUtils'; -import { ChartBox } from '@/components/ChartBox'; +import { ChartCard } from '@/components/charts/ChartCard'; import DisplaySwitcher from '@/components/charts/DisplaySwitcher'; import DBListBarChart from '@/components/DBListBarChart'; import DBTableChart from '@/components/DBTableChart'; @@ -305,7 +305,7 @@ function DatabaseTab({ return ( - + {source && totalTimePerQueryConfig && ( )} - + - + {source && totalThroughputPerQueryConfig && ( )} - + - + {source && expressions && (chartType === 'list' ? ( @@ -508,7 +508,7 @@ function DatabaseTab({ }} /> ))} - + ); diff --git a/packages/app/src/ServicesDashboardPage/ErrorsTab.tsx b/packages/app/src/ServicesDashboardPage/ErrorsTab.tsx index f240818756..a6ccb10c13 100644 --- a/packages/app/src/ServicesDashboardPage/ErrorsTab.tsx +++ b/packages/app/src/ServicesDashboardPage/ErrorsTab.tsx @@ -7,7 +7,7 @@ import { import { Grid } from '@mantine/core'; import { INTEGER_NUMBER_FORMAT } from '@/ChartUtils'; -import { ChartBox } from '@/components/ChartBox'; +import { ChartCard } from '@/components/charts/ChartCard'; import { DBTimeChart } from '@/components/DBTimeChart'; import { getStoredLanguage } from '@/components/SearchInput/SearchWhereInput'; import { useServiceDashboardExpressions } from '@/serviceDashboard'; @@ -33,7 +33,7 @@ function ErrorsTab({ return ( - + {source && expressions && ( )} - + ); diff --git a/packages/app/src/ServicesDashboardPage/HttpTab.tsx b/packages/app/src/ServicesDashboardPage/HttpTab.tsx index bc2ccdae36..229d58a66c 100644 --- a/packages/app/src/ServicesDashboardPage/HttpTab.tsx +++ b/packages/app/src/ServicesDashboardPage/HttpTab.tsx @@ -19,7 +19,7 @@ import { INTEGER_NUMBER_FORMAT, MS_NUMBER_FORMAT, } from '@/ChartUtils'; -import { ChartBox } from '@/components/ChartBox'; +import { ChartCard } from '@/components/charts/ChartCard'; import DisplaySwitcher from '@/components/charts/DisplaySwitcher'; import DBHistogramChart from '@/components/DBHistogramChart'; import DBListBarChart from '@/components/DBListBarChart'; @@ -73,7 +73,7 @@ export function EndpointLatencyChart({ ); return ( - + {source && expressions && (latencyChartType === 'line' ? ( @@ -172,7 +172,7 @@ export function EndpointLatencyChart({ }} /> ))} - + ); } @@ -396,7 +396,7 @@ function HttpTab({ return ( - @@ -423,10 +423,10 @@ function HttpTab({ disableDrillDown /> )} - + - @@ -456,10 +456,10 @@ function HttpTab({ }} /> )} - + - + {source && expressions && ( )} - + {source && isTraceSource(source) && ( @@ -567,7 +567,7 @@ function HttpTab({ )} - @@ -690,7 +690,7 @@ function HttpTab({ }} /> )} - + ); diff --git a/packages/app/src/components/ChartBox.tsx b/packages/app/src/components/ChartBox.tsx deleted file mode 100644 index cd1ce5802f..0000000000 --- a/packages/app/src/components/ChartBox.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Box, BoxComponentProps } from '@mantine/core'; - -export function ChartBox({ - children, - style, - 'data-testid': dataTestId, -}: { - children: React.ReactNode; - style?: BoxComponentProps['style']; - 'data-testid'?: string; -}) { - return ( - - {children} - - ); -} diff --git a/packages/app/src/components/ServiceDashboardDbQuerySidePanel.tsx b/packages/app/src/components/ServiceDashboardDbQuerySidePanel.tsx index 37a7541459..aa9cd8147d 100644 --- a/packages/app/src/components/ServiceDashboardDbQuerySidePanel.tsx +++ b/packages/app/src/components/ServiceDashboardDbQuerySidePanel.tsx @@ -12,7 +12,7 @@ import { IconServer } from '@tabler/icons-react'; import { IsolatedChartSyncProvider } from '@/chartSync'; import { INTEGER_NUMBER_FORMAT, MS_NUMBER_FORMAT } from '@/ChartUtils'; -import { ChartBox } from '@/components/ChartBox'; +import { ChartCard } from '@/components/charts/ChartCard'; import { DBTimeChart } from '@/components/DBTimeChart'; import { DrawerBody, DrawerHeader } from '@/components/DrawerUtils'; import SlowestEventsTile from '@/components/ServiceDashboardSlowestEventsTile'; @@ -99,7 +99,7 @@ export default function ServiceDashboardDbQuerySidePanel({ - + {source && expressions && ( )} - + - + {source && expressions && ( )} - + {source && ( diff --git a/packages/app/src/components/ServiceDashboardEndpointPerformanceChart.tsx b/packages/app/src/components/ServiceDashboardEndpointPerformanceChart.tsx index 61bf3cb73d..a6d1c7b434 100644 --- a/packages/app/src/components/ServiceDashboardEndpointPerformanceChart.tsx +++ b/packages/app/src/components/ServiceDashboardEndpointPerformanceChart.tsx @@ -5,7 +5,7 @@ import { } from '@hyperdx/common-utils/dist/types'; import { INTEGER_NUMBER_FORMAT, MS_NUMBER_FORMAT } from '@/ChartUtils'; -import { ChartBox } from '@/components/ChartBox'; +import { ChartCard } from '@/components/charts/ChartCard'; import DBListBarChart from '@/components/DBListBarChart'; import { useJsonColumns } from '@/hooks/useMetadata'; import { @@ -89,7 +89,7 @@ export default function ServiceDashboardEndpointPerformanceChart({ } return ( - + {source && ( )} - + ); } diff --git a/packages/app/src/components/ServiceDashboardEndpointSidePanel.tsx b/packages/app/src/components/ServiceDashboardEndpointSidePanel.tsx index e1f23217b7..638f4443c7 100644 --- a/packages/app/src/components/ServiceDashboardEndpointSidePanel.tsx +++ b/packages/app/src/components/ServiceDashboardEndpointSidePanel.tsx @@ -15,7 +15,7 @@ import { ERROR_RATE_PERCENTAGE_NUMBER_FORMAT, INTEGER_NUMBER_FORMAT, } from '@/ChartUtils'; -import { ChartBox } from '@/components/ChartBox'; +import { ChartCard } from '@/components/charts/ChartCard'; import { DBTimeChart } from '@/components/DBTimeChart'; import { DrawerBody, DrawerHeader } from '@/components/DrawerUtils'; import ServiceDashboardEndpointPerformanceChart from '@/components/ServiceDashboardEndpointPerformanceChart'; @@ -106,7 +106,7 @@ export default function ServiceDashboardEndpointSidePanel({ - + {source && expressions && ( )} - + - + {source && expressions && ( )} - + + {title} (Slower than {roundedP95}ms) @@ -137,6 +137,6 @@ export default function SlowestEventsTile({ ) )} - + ); } diff --git a/packages/app/src/components/charts/ChartCard.stories.tsx b/packages/app/src/components/charts/ChartCard.stories.tsx new file mode 100644 index 0000000000..318f9c8589 --- /dev/null +++ b/packages/app/src/components/charts/ChartCard.stories.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { Box, Flex, SegmentedControl } from '@mantine/core'; +import type { Meta } from '@storybook/nextjs'; + +import { ChartCard } from './ChartCard'; +import ChartContainer from './ChartContainer'; + +const meta = { + title: 'Charts/ChartCard', + component: ChartCard, +} satisfies Meta; + +export default meta; + +/** + * A dependency-free stand-in for a real chart body so the stories render the + * card chrome (border + header divider) without wiring up ClickHouse queries. + */ +function FakeChart({ color = 'var(--color-chart-blue)' }: { color?: string }) { + const points = [4, 18, 10, 26, 14, 30, 20, 34, 22, 40, 28, 44]; + const max = Math.max(...points); + const d = points + .map((p, i) => { + const x = (i / (points.length - 1)) * 100; + const y = 100 - (p / max) * 100; + return `${i === 0 ? 'M' : 'L'} ${x} ${y}`; + }) + .join(' '); + return ( + + + + ); +} + +/** + * The common case: a single titled chart. `ChartCard` supplies the card-mode + * header, so the title sits above a full-bleed divider inside a bordered + * surface — the same treatment as a custom dashboard tile. + */ +export const Default = () => ( + + + + + + + +); + +/** + * Header actions (toolbar items) render right-aligned on the divider row. Here a + * Rate/Vol toggle mirrors the Errors chart on the search page. + */ +export const WithToolbar = () => { + const [mode, setMode] = React.useState('rate'); + return ( + + + , + ]} + > + + + + + ); +}; + +/** + * The RED row from the trace search results: three equal-width cards under a + * shared height. Each card fills its column via `flex: 1; height: 100%`. + */ +export const RedRow = () => { + const cardStyle = { flex: 1, minWidth: 0, minHeight: 0, height: '100%' }; + return ( + + + + + + + + + + + + + + + + + + ); +}; + +/** + * With no title and no toolbar the header row is omitted entirely, so the card + * degrades to a plain bordered surface (no divider). + */ +export const NoHeader = () => ( + + + + + + + +); diff --git a/packages/app/src/components/charts/ChartCard.tsx b/packages/app/src/components/charts/ChartCard.tsx new file mode 100644 index 0000000000..e4ee027864 --- /dev/null +++ b/packages/app/src/components/charts/ChartCard.tsx @@ -0,0 +1,59 @@ +import { Box, BoxComponentProps } from '@mantine/core'; + +import { + ChartContainerCardHeaderProvider, + DASHBOARD_TILE_PADDING_INLINE, +} from './ChartContainer'; + +/** + * Card wrapper that gives a chart the same look as a tile on a custom + * dashboard: a bordered, rounded box whose chart header renders in "card mode" + * (centered title row + a full-bleed bottom divider under the title). + * + * Not dashboard-specific — use it anywhere a chart should read as a card (search + * page, service dashboards, the ClickHouse page, etc.). It replaces the older + * plain `ChartBox` so these surfaces share one consistent card treatment. + * + * This is the *visual* chrome only. The dashboard tile's toolbar controls + * (fullscreen, line/bar display switcher, kebab menu) live on the tile itself + * and are intentionally not included here. + * + * Keep the horizontal padding equal to DASHBOARD_TILE_PADDING_INLINE: the card + * header's separator is drawn full-bleed by cancelling exactly this inset, so + * any other value would leave the divider misaligned with the card edges. + */ +export function ChartCard({ + children, + style, + 'data-testid': dataTestId, +}: { + children: React.ReactNode; + style?: BoxComponentProps['style']; + 'data-testid'?: string; +}) { + return ( + + + {children} + + + ); +} From 27c00d4039c5f2df4e6e7cef81b95b467393bc8a Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Fri, 7 Aug 2026 14:56:26 +0100 Subject: [PATCH 2/5] fix(app): give Slowest cards the ChartCard header divider Route the "Slowest Queries" (ClickHouse page) and Slowest Events tile titles through a titled ChartContainer instead of a bare Text, so they get the same full-bleed card header divider as sibling chart cards and their tables are properly bounded by the reactive container. Remove the headerless ChartCard prop (padding-only patch) in favor of this approach, keep the ChartCardProps interface, narrowed style type, and enforced paddingInline divider invariant, and add ChartCard tests. Co-authored-by: Cursor --- agent_docs/code_style.md | 20 +++-- packages/app/src/ClickhousePage.tsx | 89 +++++++++---------- .../ServiceDashboardSlowestEventsTile.tsx | 48 +++++----- .../components/charts/ChartCard.stories.tsx | 16 ++-- .../app/src/components/charts/ChartCard.tsx | 37 +++++--- .../charts/__tests__/ChartCard.test.tsx | 58 ++++++++++++ 6 files changed, 176 insertions(+), 92 deletions(-) create mode 100644 packages/app/src/components/charts/__tests__/ChartCard.test.tsx diff --git a/agent_docs/code_style.md b/agent_docs/code_style.md index 4b3561311e..0b54ce1f08 100644 --- a/agent_docs/code_style.md +++ b/agent_docs/code_style.md @@ -166,16 +166,22 @@ It is a bordered surface with the same header treatment as a custom dashboard tile (a full-bleed divider under the title). It replaces the older `ChartBox`; don't hand-roll a bordered `
`/`` around a chart. -`ChartCard` renders the card **chrome only**. It draws the header divider by -providing `ChartContainerCardHeaderProvider`, so put a chart that renders a -`ChartContainer` inside it (`DBTimeChart`, `DBHeatmapChart`, `DBListBarChart`, -…). The tile-level controls (fullscreen, line/bar display switcher, kebab menu) -belong to dashboard tiles and are intentionally **not** part of `ChartCard`. +`ChartCard` renders the card **chrome only**. The header divider is drawn only +when a descendant renders a `ChartContainer` with a `title` (or `toolbarItems`) — +`ChartCard` supplies the `ChartContainerCardHeaderProvider` that switches that +header into card mode — so put a chart that renders a `ChartContainer` inside it +(`DBTimeChart`, `DBTableChart`, `DBHeatmapChart`, `DBListBarChart`, …). Content +with its own heading (e.g. a bespoke table card) should still route that heading +through a titled `ChartContainer` rather than a bare `Text`, so it gets the same +card header — divider and top padding included — instead of sitting flush against +the top border. The tile-level controls (fullscreen, line/bar display switcher, +kebab menu) belong to dashboard tiles and are intentionally **not** part of +`ChartCard`. | Prop | Type | Description | |------|------|-------------| -| `children` | `ReactNode` | The chart, usually a `DB*Chart` that renders a `ChartContainer` | -| `style` | `CSSProperties` | Sizing/overflow override — pass a fixed `height`, or `flex: 1; height: 100%` to fill a flex row | +| `children` | `ReactNode` | The chart, usually a `DB*Chart` (or a titled `ChartContainer`) | +| `style` | `CSSProperties` | Sizing/overflow override — pass a fixed `height`, or `flex: 1; height: 100%` to fill a flex row (`paddingInline` is pinned to keep the divider aligned) | | `data-testid` | `string` | Test hook | ```tsx diff --git a/packages/app/src/ClickhousePage.tsx b/packages/app/src/ClickhousePage.tsx index aac84d852b..fc626ff1dd 100644 --- a/packages/app/src/ClickhousePage.tsx +++ b/packages/app/src/ClickhousePage.tsx @@ -39,6 +39,7 @@ import { TimePicker } from '@/components/TimePicker'; import { withAppNav } from '@/layout'; import { ChartCard } from './components/charts/ChartCard'; +import ChartContainer from './components/charts/ChartContainer'; import DBHeatmapChart from './components/DBHeatmapChart'; import { DBSqlRowTable } from './components/DBRowTable'; import DBTableChart from './components/DBTableChart'; @@ -796,59 +797,53 @@ function ClickhousePage() { - - - Slowest Queries - - { - return ( - - ); - }} - config={{ - select: `event_time, query_kind, + + + { + return ( + + ); + }} + config={{ + select: `event_time, query_kind, read_rows, formatReadableSize(memory_usage) as memory_usage, query_duration_ms, query`, - dateRange: searchedTimeRange, - from, - where: `( + dateRange: searchedTimeRange, + from, + where: `( type='ExceptionWhileProcessing' OR type='QueryFinish' )`, - timestampValueExpression: 'event_time', - connection, - orderBy: [ - { - valueExpression: 'query_duration_ms', - ordering: 'DESC', - }, - ], - filters: [ - ...filters, - { - type: 'sql_ast', - operator: '=', - left: 'query_kind', - right: `'Select'`, - }, - ], - limit: { limit: 100 }, - }} - /> + timestampValueExpression: 'event_time', + connection, + orderBy: [ + { + valueExpression: 'query_duration_ms', + ordering: 'DESC', + }, + ], + filters: [ + ...filters, + { + type: 'sql_ast', + operator: '=', + left: 'query_kind', + right: `'Select'`, + }, + ], + limit: { limit: 100 }, + }} + /> + diff --git a/packages/app/src/components/ServiceDashboardSlowestEventsTile.tsx b/packages/app/src/components/ServiceDashboardSlowestEventsTile.tsx index 6f128453bf..75116681e6 100644 --- a/packages/app/src/components/ServiceDashboardSlowestEventsTile.tsx +++ b/packages/app/src/components/ServiceDashboardSlowestEventsTile.tsx @@ -4,12 +4,13 @@ import { pickSampleWeightExpressionProps, type TTraceSource, } from '@hyperdx/common-utils/dist/types'; -import { Group, Text } from '@mantine/core'; +import { Text } from '@mantine/core'; import { ChartCard } from '@/components/charts/ChartCard'; import { useQueriedChartConfig } from '@/hooks/useChartConfig'; import { useServiceDashboardExpressions } from '@/serviceDashboard'; +import ChartContainer from './charts/ChartContainer'; import ChartErrorState from './charts/ChartErrorState'; import DBSqlRowTableWithSideBar from './DBSqlRowTableWithSidebar'; @@ -67,24 +68,27 @@ export default function SlowestEventsTile({ return ( - - {title} - (Slower than {roundedP95}ms) - - {isLoading && !data ? ( -
- Loading Chart Data... -
- ) : isError ? ( - - ) : data?.data.length === 0 ? ( -
- No data found within time range. -
- ) : ( - source && - expressions && ( - <> + {title}} + toolbarItems={[ + + (Slower than {roundedP95}ms) + , + ]} + > + {isLoading && !data ? ( +
+ Loading Chart Data... +
+ ) : isError ? ( + + ) : data?.data.length === 0 ? ( +
+ No data found within time range. +
+ ) : ( + source && + expressions && ( - - ) - )} + ) + )} +
); } diff --git a/packages/app/src/components/charts/ChartCard.stories.tsx b/packages/app/src/components/charts/ChartCard.stories.tsx index 318f9c8589..080dc04a45 100644 --- a/packages/app/src/components/charts/ChartCard.stories.tsx +++ b/packages/app/src/components/charts/ChartCard.stories.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Box, Flex, SegmentedControl } from '@mantine/core'; +import { Box, Flex, SegmentedControl, Text } from '@mantine/core'; import type { Meta } from '@storybook/nextjs'; import { ChartCard } from './ChartCard'; @@ -119,14 +119,18 @@ export const RedRow = () => { }; /** - * With no title and no toolbar the header row is omitted entirely, so the card - * degrades to a plain bordered surface (no divider). + * Non-chart content (e.g. a table card) still routes its heading through a + * titled `ChartContainer` so it gets the same card header — divider included — + * as the chart cards above, instead of a bare `Text` that would sit flush + * against the top border with no divider. */ -export const NoHeader = () => ( +export const TableCard = () => ( - - + + + table rows… + diff --git a/packages/app/src/components/charts/ChartCard.tsx b/packages/app/src/components/charts/ChartCard.tsx index e4ee027864..8d6238a102 100644 --- a/packages/app/src/components/charts/ChartCard.tsx +++ b/packages/app/src/components/charts/ChartCard.tsx @@ -1,18 +1,36 @@ -import { Box, BoxComponentProps } from '@mantine/core'; +import { Box } from '@mantine/core'; import { ChartContainerCardHeaderProvider, DASHBOARD_TILE_PADDING_INLINE, } from './ChartContainer'; +export interface ChartCardProps { + children: React.ReactNode; + /** + * Sizing/overflow override merged over the card defaults. Kept as plain + * `CSSProperties` (not Mantine's `MantineStyleProp`) because the defaults are + * object-spread — a resolver function or style array would be silently + * dropped. `paddingInline` is intentionally not honored here (see below). + */ + style?: React.CSSProperties; + 'data-testid'?: string; +} + /** * Card wrapper that gives a chart the same look as a tile on a custom * dashboard: a bordered, rounded box whose chart header renders in "card mode" * (centered title row + a full-bleed bottom divider under the title). * * Not dashboard-specific — use it anywhere a chart should read as a card (search - * page, service dashboards, the ClickHouse page, etc.). It replaces the older - * plain `ChartBox` so these surfaces share one consistent card treatment. + * page, service dashboards, the ClickHouse page, etc.). + * + * The header divider is only drawn when a descendant renders a `ChartContainer` + * with a `title` or `toolbarItems` (e.g. `DBTimeChart`, `DBTableChart`); this + * component just provides the context that switches that header into card mode. + * Content with its own heading should render it through a titled + * `ChartContainer` (rather than a bare `Text`) so it gets the same card header — + * divider included — and the top padding the header supplies. * * This is the *visual* chrome only. The dashboard tile's toolbar controls * (fullscreen, line/bar display switcher, kebab menu) live on the tile itself @@ -20,17 +38,15 @@ import { * * Keep the horizontal padding equal to DASHBOARD_TILE_PADDING_INLINE: the card * header's separator is drawn full-bleed by cancelling exactly this inset, so - * any other value would leave the divider misaligned with the card edges. + * any other value would leave the divider misaligned with the card edges. It is + * re-applied after the caller `style` so an override can't silently break the + * divider alignment. */ export function ChartCard({ children, style, 'data-testid': dataTestId, -}: { - children: React.ReactNode; - style?: BoxComponentProps['style']; - 'data-testid'?: string; -}) { +}: ChartCardProps) { return ( diff --git a/packages/app/src/components/charts/__tests__/ChartCard.test.tsx b/packages/app/src/components/charts/__tests__/ChartCard.test.tsx new file mode 100644 index 0000000000..b0b6040341 --- /dev/null +++ b/packages/app/src/components/charts/__tests__/ChartCard.test.tsx @@ -0,0 +1,58 @@ +import { screen } from '@testing-library/react'; + +import { ChartCard } from '@/components/charts/ChartCard'; +import ChartContainer from '@/components/charts/ChartContainer'; + +// The header row (the Group wrapping the title) gets a bottom border only in +// "card mode", which ChartCard turns on via its context provider. +function headerRowFor(title: string) { + return screen.getByText(title).parentElement as HTMLElement; +} + +describe('ChartCard', () => { + it('puts a nested titled ChartContainer into card mode so it draws the divider', () => { + renderWithMantine( + + +
chart
+
+
, + ); + + expect(headerRowFor('Throughput').style.borderBottom).toBe( + '1px solid var(--color-border)', + ); + }); + + it('leaves a standalone ChartContainer header plain (no divider)', () => { + renderWithMantine( + +
chart
+
, + ); + + expect(headerRowFor('Throughput').style.borderBottom).toBe(''); + }); + + it('merges a caller style over the card defaults', () => { + renderWithMantine( + +
chart
+
, + ); + + expect(screen.getByTestId('card').style.height).toBe('321px'); + }); + + it('re-applies the divider padding invariant even if a caller overrides it', () => { + renderWithMantine( + +
chart
+
, + ); + + // paddingInline is pinned to the tile inset regardless of caller style so + // the full-bleed header divider stays aligned with the card edges. + expect(screen.getByTestId('card').style.paddingInline).not.toBe('999px'); + }); +}); From 7c93d16b9907b52bd949e0ec2046351c0fabd31e Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Fri, 14 Aug 2026 15:58:32 +0100 Subject: [PATCH 3/5] chore(app): clear lint warnings to stay under max-warnings budget The lint job exceeded the eslint --max-warnings cap by 2. Remove an unsafe type assertion in the new ChartCard test and adopt the React 19 context provider shorthand in the two service dashboard side panels, bringing the app back under budget with no behavior change. Co-authored-by: Cursor --- .../src/components/ServiceDashboardDbQuerySidePanel.tsx | 4 ++-- .../src/components/ServiceDashboardEndpointSidePanel.tsx | 4 ++-- .../src/components/charts/__tests__/ChartCard.test.tsx | 8 ++++++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/app/src/components/ServiceDashboardDbQuerySidePanel.tsx b/packages/app/src/components/ServiceDashboardDbQuerySidePanel.tsx index aa9cd8147d..3a22294ca8 100644 --- a/packages/app/src/components/ServiceDashboardDbQuerySidePanel.tsx +++ b/packages/app/src/components/ServiceDashboardDbQuerySidePanel.tsx @@ -79,7 +79,7 @@ export default function ServiceDashboardDbQuerySidePanel({ }, }} > - +
-
+ ); } diff --git a/packages/app/src/components/ServiceDashboardEndpointSidePanel.tsx b/packages/app/src/components/ServiceDashboardEndpointSidePanel.tsx index 638f4443c7..64e6c80bf1 100644 --- a/packages/app/src/components/ServiceDashboardEndpointSidePanel.tsx +++ b/packages/app/src/components/ServiceDashboardEndpointSidePanel.tsx @@ -86,7 +86,7 @@ export default function ServiceDashboardEndpointSidePanel({ }, }} > - +
-
+ ); } diff --git a/packages/app/src/components/charts/__tests__/ChartCard.test.tsx b/packages/app/src/components/charts/__tests__/ChartCard.test.tsx index b0b6040341..27d724a5c8 100644 --- a/packages/app/src/components/charts/__tests__/ChartCard.test.tsx +++ b/packages/app/src/components/charts/__tests__/ChartCard.test.tsx @@ -5,8 +5,12 @@ import ChartContainer from '@/components/charts/ChartContainer'; // The header row (the Group wrapping the title) gets a bottom border only in // "card mode", which ChartCard turns on via its context provider. -function headerRowFor(title: string) { - return screen.getByText(title).parentElement as HTMLElement; +function headerRowFor(title: string): HTMLElement { + const headerRow = screen.getByText(title).parentElement; + if (headerRow == null) { + throw new Error(`Expected a parent element for "${title}"`); + } + return headerRow; } describe('ChartCard', () => { From edbc38890baefebe982faa32a41088c8bc6c6dff Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Mon, 17 Aug 2026 11:57:32 +0100 Subject: [PATCH 4/5] feat(app): make ChartCard header sticky MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the card-mode ChartContainer header (title/toolbar + full-bleed divider) to the top of the card's scroll area so it stays visible while the body scrolls — relevant for cards with overflow:auto around a long table or list. Uses an opaque card-matching background and a z-index so scrolled content doesn't bleed through. No effect on non-scrolling cards. Co-authored-by: Cursor --- .changeset/shared-chart-card.md | 8 ++++--- .../src/components/charts/ChartContainer.tsx | 9 +++++++ .../charts/__tests__/ChartCard.test.tsx | 24 +++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/.changeset/shared-chart-card.md b/.changeset/shared-chart-card.md index e7b225fe38..799d84a610 100644 --- a/.changeset/shared-chart-card.md +++ b/.changeset/shared-chart-card.md @@ -4,6 +4,8 @@ Introduce a shared `ChartCard` component that gives standalone charts the same card treatment as custom dashboard tiles (bordered surface + full-bleed header -divider). Migrate the Service Dashboards (HTTP, Database, Errors, endpoint and -DB-query side panels) and the ClickHouse page from the old `ChartBox` wrapper to -`ChartCard` so chart surfaces look consistent across the app. +divider). The card header is sticky, so it stays visible while the card body +scrolls (e.g. cards wrapping a long table or list). Migrate the Service +Dashboards (HTTP, Database, Errors, endpoint and DB-query side panels) and the +ClickHouse page from the old `ChartBox` wrapper to `ChartCard` so chart surfaces +look consistent across the app. diff --git a/packages/app/src/components/charts/ChartContainer.tsx b/packages/app/src/components/charts/ChartContainer.tsx index d92ad1c168..49133ba534 100644 --- a/packages/app/src/components/charts/ChartContainer.tsx +++ b/packages/app/src/components/charts/ChartContainer.tsx @@ -118,6 +118,15 @@ function ChartContainer({ paddingTop: HEADER_SPACING_SLIM, paddingBottom: HEADER_SPACING_SLIM, borderBottom: '1px solid var(--color-border)', + // Pin the header to the top of the card's scroll area so it + // stays visible while the body scrolls (e.g. cards with + // overflow:auto around a long table/list). Needs an opaque + // background matching the card so scrolled content doesn't + // bleed through, and a z-index to sit above that content. + position: 'sticky', + top: 0, + zIndex: 2, + background: 'var(--color-bg-body)', } : undefined } diff --git a/packages/app/src/components/charts/__tests__/ChartCard.test.tsx b/packages/app/src/components/charts/__tests__/ChartCard.test.tsx index 27d724a5c8..5f6ebca3bd 100644 --- a/packages/app/src/components/charts/__tests__/ChartCard.test.tsx +++ b/packages/app/src/components/charts/__tests__/ChartCard.test.tsx @@ -28,6 +28,30 @@ describe('ChartCard', () => { ); }); + it('makes the card-mode header sticky so it stays visible while the body scrolls', () => { + renderWithMantine( + + +
chart
+
+
, + ); + + const header = headerRowFor('Throughput'); + expect(header.style.position).toBe('sticky'); + expect(header.style.top).toBe('0px'); + }); + + it('leaves a standalone ChartContainer header plain (not sticky, no divider)', () => { + renderWithMantine( + +
chart
+
, + ); + + expect(headerRowFor('Throughput').style.position).toBe(''); + }); + it('leaves a standalone ChartContainer header plain (no divider)', () => { renderWithMantine( From 8fe580f45a4ddc1e58b7593519e1cc10b5005e2a Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Mon, 17 Aug 2026 12:56:05 +0100 Subject: [PATCH 5/5] fix(app): pin ChartCard header and unify header height Make the card-mode header genuinely stay put for scrolling content and keep tile headers visually consistent: - Give scrollable (disableReactiveContainer) card content its own internal scroll region and render DBListBarChart in normal flow, so long lists (e.g. "Top 20 Most Time Consuming Queries") scroll under a fixed/sticky header instead of dragging it away once past the first card-height. - Reserve a uniform card-header min-height (43px, accounting for the 1px border under border-box) so title-only headers line up with headers that carry controls (display switcher, indicators, kebab). - Keep the "(Slower than Nms)" label on one line in the Slowest Events tile. Co-authored-by: Cursor --- .changeset/shared-chart-card.md | 7 +- .../app/src/components/DBListBarChart.tsx | 9 ++- .../ServiceDashboardSlowestEventsTile.tsx | 2 +- .../src/components/charts/ChartContainer.tsx | 39 +++++++++-- .../charts/__tests__/ChartCard.test.tsx | 64 ++++++++++++++++++- 5 files changed, 110 insertions(+), 11 deletions(-) diff --git a/.changeset/shared-chart-card.md b/.changeset/shared-chart-card.md index 799d84a610..096f168e93 100644 --- a/.changeset/shared-chart-card.md +++ b/.changeset/shared-chart-card.md @@ -4,8 +4,11 @@ Introduce a shared `ChartCard` component that gives standalone charts the same card treatment as custom dashboard tiles (bordered surface + full-bleed header -divider). The card header is sticky, so it stays visible while the card body -scrolls (e.g. cards wrapping a long table or list). Migrate the Service +divider). The card header stays pinned while the card body scrolls (e.g. cards +wrapping a long list like "Top 20 Most Time Consuming Queries"): in card mode +the header is a fixed row and scrollable list content gets its own internal +scroll region, so the header no longer scrolls away once you pass the first +card-height of content. Migrate the Service Dashboards (HTTP, Database, Errors, endpoint and DB-query side panels) and the ClickHouse page from the old `ChartBox` wrapper to `ChartCard` so chart surfaces look consistent across the app. diff --git a/packages/app/src/components/DBListBarChart.tsx b/packages/app/src/components/DBListBarChart.tsx index b2f7a9c077..f008b346bd 100644 --- a/packages/app/src/components/DBListBarChart.tsx +++ b/packages/app/src/components/DBListBarChart.tsx @@ -270,7 +270,14 @@ export default function DBListBarChart({ ]); return ( - + {isLoading && !data ? (
Loading Chart Data... diff --git a/packages/app/src/components/ServiceDashboardSlowestEventsTile.tsx b/packages/app/src/components/ServiceDashboardSlowestEventsTile.tsx index 75116681e6..be0910712b 100644 --- a/packages/app/src/components/ServiceDashboardSlowestEventsTile.tsx +++ b/packages/app/src/components/ServiceDashboardSlowestEventsTile.tsx @@ -71,7 +71,7 @@ export default function SlowestEventsTile({ {title}} toolbarItems={[ - + (Slower than {roundedP95}ms) , ]} diff --git a/packages/app/src/components/charts/ChartContainer.tsx b/packages/app/src/components/charts/ChartContainer.tsx index 49133ba534..fcde00745e 100644 --- a/packages/app/src/components/charts/ChartContainer.tsx +++ b/packages/app/src/components/charts/ChartContainer.tsx @@ -66,6 +66,16 @@ export const DASHBOARD_TILE_PADDING_INLINE = const HEADER_SPACING = DASHBOARD_TILE_PADDING_INLINE; const HEADER_SPACING_SLIM = 'calc(var(--mantine-spacing-md) * 0.25)'; +// Reserve a consistent card-header height so tiles with a title-only header line +// up with tiles whose header carries controls (display switcher, indicators, +// kebab, etc.). Sized to the tallest of those controls — the DisplaySwitcher: an +// 18px `xs` ActionIcon inside a `py-2` (8px) group = 34px content, plus the +// header's slim vertical padding (spacing-md * 0.25 top and bottom = 8px) and the +// 1px bottom border, which count toward the box under `box-sizing: border-box` = +// 43px. The header centers its content, so shorter headers pad up to match +// instead of changing where the divider sits. +const CARD_HEADER_MIN_HEIGHT = 43; + function ChartContainer({ title, toolbarItems, @@ -117,12 +127,19 @@ function ChartContainer({ // separator so every tile has a consistent header. paddingTop: HEADER_SPACING_SLIM, paddingBottom: HEADER_SPACING_SLIM, + // Uniform header height whether or not the header has + // controls, so titles and dividers align across tiles. + minHeight: CARD_HEADER_MIN_HEIGHT, borderBottom: '1px solid var(--color-border)', - // Pin the header to the top of the card's scroll area so it - // stays visible while the body scrolls (e.g. cards with - // overflow:auto around a long table/list). Needs an opaque - // background matching the card so scrolled content doesn't - // bleed through, and a z-index to sit above that content. + // Keep the header pinned to the top of the card while the + // body scrolls. The scrollable body lives in its own region + // below (see the `disableReactiveContainer` branch), so the + // header must never shrink or scroll with it. `position: + // sticky` is a belt-and-suspenders fallback for the case + // where the card itself (rather than the body region) is the + // scroll container; it needs an opaque background matching + // the card and a z-index to sit above scrolled content. + flexShrink: 0, position: 'sticky', top: 0, zIndex: 2, @@ -195,7 +212,17 @@ function ChartContainer({ )} {disableReactiveContainer ? ( - children + cardHeader ? ( + // In a card, the header row above is fixed (flexShrink: 0) and the + // body owns the scroll. This keeps a sticky-style header genuinely + // pinned for tall, normal-flow content (e.g. a long bar list) that + // would otherwise scroll the whole card and drag the header away. +
+ {children} +
+ ) : ( + children + ) ) : (
{ ); }); - it('makes the card-mode header sticky so it stays visible while the body scrolls', () => { + it('keeps the card-mode header pinned (fixed + sticky) while the body scrolls', () => { renderWithMantine( @@ -38,10 +38,72 @@ describe('ChartCard', () => { ); const header = headerRowFor('Throughput'); + // The header never shrinks or scrolls with the body... + expect(header.style.flexShrink).toBe('0'); + // ...and stays put if the card itself is the scroll container. expect(header.style.position).toBe('sticky'); expect(header.style.top).toBe('0px'); }); + it('reserves a uniform header height whether or not the header has controls', () => { + renderWithMantine( + <> + + +
chart
+
+
+ + ]} + > +
chart
+
+
+ , + ); + + // Both card headers reserve the same min height so titles/dividers align + // across tiles regardless of whether the header carries a control. + expect(headerRowFor('No controls').style.minHeight).toBe('43px'); + expect(headerRowFor('With controls').style.minHeight).toBe('43px'); + }); + + it('gives scrollable (disableReactiveContainer) card content its own scroll region', () => { + renderWithMantine( + + +
list
+
+
, + ); + + const scrollRegion = screen.getByTestId('list-body').parentElement; + if (scrollRegion == null) { + throw new Error('Expected a scroll region wrapping the card body'); + } + // The body owns the scroll so the fixed header stays pinned above a long, + // normal-flow list (e.g. "Top 20 Most Time Consuming Queries"). + expect(scrollRegion.style.overflow).toBe('auto'); + expect(scrollRegion.style.flexGrow).toBe('1'); + expect(scrollRegion.style.minHeight).toBe('0'); + }); + + it('does not wrap standalone (non-card) disableReactiveContainer content in a scroll region', () => { + renderWithMantine( + +
list
+
, + ); + + const parent = screen.getByTestId('list-body').parentElement; + if (parent == null) { + throw new Error('Expected a parent element for the body'); + } + expect(parent.style.overflow).toBe(''); + }); + it('leaves a standalone ChartContainer header plain (not sticky, no divider)', () => { renderWithMantine(