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..0b54ce1f08 100644 --- a/agent_docs/code_style.md +++ b/agent_docs/code_style.md @@ -159,6 +159,49 @@ 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**. 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` (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 +// ✅ 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..fc626ff1dd 100644 --- a/packages/app/src/ClickhousePage.tsx +++ b/packages/app/src/ClickhousePage.tsx @@ -38,7 +38,8 @@ 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 ChartContainer from './components/charts/ChartContainer'; import DBHeatmapChart from './components/DBHeatmapChart'; import { DBSqlRowTable } from './components/DBRowTable'; import DBTableChart from './components/DBTableChart'; @@ -70,7 +71,7 @@ function InfrastructureTab({ return ( - + - + - + - + - + - + - + - + - + @@ -250,7 +251,7 @@ function InfrastructureTab({ }} onTimeRangeSelect={onTimeRangeSelect} /> - + ); @@ -355,7 +356,7 @@ function InsertsTab({ return ( - + @@ -387,10 +388,10 @@ function InsertsTab({ config={insertsPerTableConfig} onTimeRangeSelect={onTimeRangeSelect} /> - + - + - + - + @@ -484,7 +485,7 @@ function InsertsTab({ selectGroupBy: false, }} /> - + ); @@ -665,7 +666,7 @@ 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/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) - - {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 new file mode 100644 index 0000000000..080dc04a45 --- /dev/null +++ b/packages/app/src/components/charts/ChartCard.stories.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { Box, Flex, SegmentedControl, Text } 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 ( + + + + + + + + + + + + + + + + + + ); +}; + +/** + * 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 TableCard = () => ( + + + + + table rows… + + + + +); diff --git a/packages/app/src/components/charts/ChartCard.tsx b/packages/app/src/components/charts/ChartCard.tsx new file mode 100644 index 0000000000..8d6238a102 --- /dev/null +++ b/packages/app/src/components/charts/ChartCard.tsx @@ -0,0 +1,76 @@ +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.). + * + * 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 + * 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. 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, +}: ChartCardProps) { + return ( + + + {children} + + + ); +} 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'); + }); +});