diff --git a/.claude/rules/ui.md b/.claude/rules/ui.md index 6ebc15cf..638878bc 100644 --- a/.claude/rules/ui.md +++ b/.claude/rules/ui.md @@ -21,7 +21,7 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments **Admin pages** (ADMIN-only, under `/admin/`): - **Sensitive Keys** (`ui/src/pages/Admin/SensitiveKeysPage.tsx`) — global sensitive key masking config. Shows agent built-in defaults as outlined Badge reference, editable Tag pills for custom keys, amber-highlighted push-to-agents toggle. Keys add to (not replace) agent defaults. Per-app sensitive key additions managed via `ApplicationConfigController` API. Note: `AppConfigDetailPage.tsx` exists but is not routed in `router.tsx`. -- **Server Metrics** (`ui/src/pages/Admin/ServerMetricsAdminPage.tsx`) — dashboard over the `server_metrics` ClickHouse table. Visibility matches Database/ClickHouse pages: gated on `capabilities.infrastructureEndpoints` in `buildAdminTreeNodes`; backend is `@ConditionalOnProperty(infrastructureendpoints) + @PreAuthorize('hasRole(ADMIN)')`. Uses the generic `/api/v1/admin/server-metrics/{catalog,instances,query}` API via `ui/src/api/queries/admin/serverMetrics.ts` hooks (`useServerMetricsCatalog`, `useServerMetricsInstances`, `useServerMetricsSeries`). Toolbar: server-instance badges + DS `Select` window picker (15 min / 1 h / 6 h / 24 h / 7 d). Sections: Server health (agents/ingestion/auth), JVM (memory/CPU/GC/threads), HTTP & DB pools, Alerting (conditional on catalog), Deployments (conditional on catalog). Each panel is a `ThemedChart` with `Line`/`Area` children from the design system; multi-series responses are flattened into overlap rows by bucket timestamp. Alerting and Deployments rows are hidden when their metrics aren't in the catalog (zero-deploy / alerting-disabled installs). +- **Server Metrics** (`ui/src/pages/Admin/ServerMetricsAdminPage.tsx`) — dashboard over the `server_metrics` ClickHouse table. Visibility matches Database/ClickHouse pages: gated on `capabilities.infrastructureEndpoints` in `buildAdminTreeNodes`; backend is `@ConditionalOnProperty(infrastructureendpoints) + @PreAuthorize('hasRole(ADMIN)')`. Uses the generic `/api/v1/admin/server-metrics/{catalog,instances,query}` API via `ui/src/api/queries/admin/serverMetrics.ts` hooks (`useServerMetricsCatalog`, `useServerMetricsInstances`, `useServerMetricsSeries`), all three of which take a `ServerMetricsRange = { from: Date; to: Date }`. Time range is driven by the global TopBar picker via `useGlobalFilters()` — no page-local selector; bucket size auto-scales through `stepSecondsFor(windowSeconds)` (10 s up to 1 h buckets). Toolbar is just server-instance badges. Sections: Server health (agents/ingestion/auth), JVM (memory/CPU/GC/threads), HTTP & DB pools, Alerting (conditional on catalog), Deployments (conditional on catalog). Each panel is a `ThemedChart` with `Line`/`Area` children from the design system; multi-series responses are flattened into overlap rows by bucket timestamp. Alerting and Deployments rows are hidden when their metrics aren't in the catalog (zero-deploy / alerting-disabled installs). ## Key UI Files diff --git a/docs/server-self-metrics.md b/docs/server-self-metrics.md index d84ec7bd..56788512 100644 --- a/docs/server-self-metrics.md +++ b/docs/server-self-metrics.md @@ -8,7 +8,7 @@ This is the reference for anyone building a server-health dashboard on top of th ## Built-in admin dashboard -The server ships a ready-to-use dashboard at **`/admin/server-metrics`** in the web UI. It renders the 17 panels listed below using `ThemedChart` from the design system, with a time-range selector (15 min / 1 h / 6 h / 24 h / 7 d) and live auto-refresh. Visibility mirrors the Database and ClickHouse admin pages: +The server ships a ready-to-use dashboard at **`/admin/server-metrics`** in the web UI. It renders the 17 panels listed below using `ThemedChart` from the design system. The window is driven by the app-wide time-range control in the TopBar (same one used by Exchanges, Dashboard, and Runtime), so every panel automatically reflects the range you've selected globally. Visibility mirrors the Database and ClickHouse admin pages: - Requires the `ADMIN` role. - Hidden when `cameleer.server.security.infrastructureendpoints=false` (both the backend endpoints and the sidebar entry disappear). @@ -519,3 +519,4 @@ Below are 17 panels, each expressed as a single `POST /api/v1/admin/server-metri - 2026-04-23 — initial write. Write-only backend. - 2026-04-23 — added generic REST API (`/api/v1/admin/server-metrics/{catalog,instances,query}`) so dashboards don't need direct ClickHouse access. All 17 suggested panels now expressed as single-endpoint queries. - 2026-04-24 — shipped the built-in `/admin/server-metrics` UI dashboard. Gated by `infrastructureendpoints` + ADMIN, identical visibility to `/admin/{database,clickhouse}`. Source: `ui/src/pages/Admin/ServerMetricsAdminPage.tsx`. +- 2026-04-24 — dashboard now uses the global time-range control (`useGlobalFilters`) instead of a page-local picker. Bucket size auto-scales with the selected window (10 s → 1 h). Query hooks now take a `ServerMetricsRange = { from: Date; to: Date }` instead of a `windowSeconds` number so they work for any absolute or rolling range the TopBar supplies. diff --git a/ui/src/api/queries/admin/serverMetrics.ts b/ui/src/api/queries/admin/serverMetrics.ts index 4207a51e..f1d921e6 100644 --- a/ui/src/api/queries/admin/serverMetrics.ts +++ b/ui/src/api/queries/admin/serverMetrics.ts @@ -49,30 +49,47 @@ export interface ServerMetricQueryRequest { serverInstanceIds?: string[] | null; } +// ── Range helper ─────────────────────────────────────────────────────── + +/** + * Time range driving every hook below. Callers pass the window they want + * to render; the hooks never invent their own "now" — that's the job of + * the global time-range control. + */ +export interface ServerMetricsRange { + from: Date; + to: Date; +} + +function serializeRange(range: ServerMetricsRange) { + return { + from: range.from.toISOString(), + to: range.to.toISOString(), + }; +} + // ── Query Hooks ──────────────────────────────────────────────────────── -export function useServerMetricsCatalog(windowSeconds = 3600) { +export function useServerMetricsCatalog(range: ServerMetricsRange) { const refetchInterval = useRefreshInterval(60_000); + const { from, to } = serializeRange(range); return useQuery({ - queryKey: ['admin', 'server-metrics', 'catalog', windowSeconds], - queryFn: async () => { - const to = new Date(); - const from = new Date(to.getTime() - windowSeconds * 1000); - const params = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() }); + queryKey: ['admin', 'server-metrics', 'catalog', from, to], + queryFn: () => { + const params = new URLSearchParams({ from, to }); return adminFetch(`/server-metrics/catalog?${params}`); }, refetchInterval, }); } -export function useServerMetricsInstances(windowSeconds = 3600) { +export function useServerMetricsInstances(range: ServerMetricsRange) { const refetchInterval = useRefreshInterval(60_000); + const { from, to } = serializeRange(range); return useQuery({ - queryKey: ['admin', 'server-metrics', 'instances', windowSeconds], - queryFn: async () => { - const to = new Date(); - const from = new Date(to.getTime() - windowSeconds * 1000); - const params = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() }); + queryKey: ['admin', 'server-metrics', 'instances', from, to], + queryFn: () => { + const params = new URLSearchParams({ from, to }); return adminFetch(`/server-metrics/instances?${params}`); }, refetchInterval, @@ -80,28 +97,23 @@ export function useServerMetricsInstances(windowSeconds = 3600) { } /** - * Run a time-series query against the server_metrics table. + * Generic time-series query against the server_metrics table. * - * The window [from, to) is supplied in seconds of "now minus N" so the panel - * refreshes automatically at the polling interval without the caller - * recomputing timestamps. + * The caller owns the window — passing the globally-selected range keeps + * every panel aligned with the app-wide time control and allows inspection + * of historical windows, not just "last N seconds from now". */ export function useServerMetricsSeries( request: Omit, - windowSeconds: number, + range: ServerMetricsRange, opts?: { enabled?: boolean }, ) { const refetchInterval = useRefreshInterval(30_000); + const { from, to } = serializeRange(range); return useQuery({ - queryKey: ['admin', 'server-metrics', 'query', request, windowSeconds], - queryFn: async () => { - const to = new Date(); - const from = new Date(to.getTime() - windowSeconds * 1000); - const body: ServerMetricQueryRequest = { - ...request, - from: from.toISOString(), - to: to.toISOString(), - }; + queryKey: ['admin', 'server-metrics', 'query', request, from, to], + queryFn: () => { + const body: ServerMetricQueryRequest = { ...request, from, to }; return adminFetch('/server-metrics/query', { method: 'POST', body: JSON.stringify(body), diff --git a/ui/src/pages/Admin/ServerMetricsAdminPage.tsx b/ui/src/pages/Admin/ServerMetricsAdminPage.tsx index 16099336..68e57f34 100644 --- a/ui/src/pages/Admin/ServerMetricsAdminPage.tsx +++ b/ui/src/pages/Admin/ServerMetricsAdminPage.tsx @@ -1,7 +1,7 @@ -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { ThemedChart, Area, Line, CHART_COLORS, - Badge, EmptyState, Spinner, Select, + Badge, EmptyState, Spinner, useGlobalFilters, } from '@cameleer/design-system'; import { useServerMetricsCatalog, @@ -9,19 +9,28 @@ import { useServerMetricsSeries, type ServerMetricQueryResponse, type ServerMetricSeries, + type ServerMetricsRange, } from '../../api/queries/admin/serverMetrics'; import chartCardStyles from '../../styles/chart-card.module.css'; import styles from './ServerMetricsAdminPage.module.css'; -// ── Window options ───────────────────────────────────────────────────── +// ── Step picker ──────────────────────────────────────────────────────── -const WINDOWS: { label: string; seconds: number; step: number }[] = [ - { label: 'Last 15 min', seconds: 15 * 60, step: 60 }, - { label: 'Last 1 h', seconds: 60 * 60, step: 60 }, - { label: 'Last 6 h', seconds: 6 * 60 * 60, step: 300 }, - { label: 'Last 24 h', seconds: 24 * 60 * 60, step: 300 }, - { label: 'Last 7 d', seconds: 7 * 24 * 60 * 60, step: 3600 }, -]; +/** + * Choose a bucket width that keeps the rendered series readable regardless + * of the window size the global time-range control hands us. + * + * Targets roughly 30–120 points per series — any denser and the chart + * becomes a blur; any sparser and short windows look empty. Clamped to the + * [10, 3600] range the backend accepts. + */ +function stepSecondsFor(windowSeconds: number): number { + if (windowSeconds <= 30 * 60) return 10; // ≤ 30 min → 10 s buckets + if (windowSeconds <= 2 * 60 * 60) return 60; // ≤ 2 h → 1 min + if (windowSeconds <= 12 * 60 * 60) return 300; // ≤ 12 h → 5 min + if (windowSeconds <= 48 * 60 * 60) return 900; // ≤ 48 h → 15 min + return 3600; // longer → 1 h +} // ── Panel component ──────────────────────────────────────────────────── @@ -36,7 +45,7 @@ interface PanelProps { mode?: 'raw' | 'delta'; yLabel?: string; asArea?: boolean; - windowSeconds: number; + range: ServerMetricsRange; stepSeconds: number; formatValue?: (v: number) => string; } @@ -44,11 +53,11 @@ interface PanelProps { function Panel({ title, subtitle, metric, statistic, groupByTags, filterTags, aggregation, mode = 'raw', yLabel, asArea = false, - windowSeconds, stepSeconds, formatValue, + range, stepSeconds, formatValue, }: PanelProps) { const { data, isLoading, isError, error } = useServerMetricsSeries( { metric, statistic, groupByTags, filterTags, aggregation, mode, stepSeconds }, - windowSeconds, + range, ); return ( @@ -157,20 +166,28 @@ function formatPct(frac: number): string { // ── Page ─────────────────────────────────────────────────────────────── export default function ServerMetricsAdminPage() { - const [windowIdx, setWindowIdx] = useState(1); // default: last 1 h - const windowOpt = WINDOWS[windowIdx]; - const windowSeconds = windowOpt.seconds; - const stepSeconds = windowOpt.step; + // Drive the entire page from the global time-range control in the TopBar. + const { timeRange } = useGlobalFilters(); + const range: ServerMetricsRange = useMemo( + () => ({ from: timeRange.start, to: timeRange.end }), + [timeRange.start, timeRange.end], + ); + const windowSeconds = Math.max( + 1, + Math.round((range.to.getTime() - range.from.getTime()) / 1000), + ); + const stepSeconds = stepSecondsFor(windowSeconds); - const { data: catalog } = useServerMetricsCatalog(windowSeconds); - const { data: instances } = useServerMetricsInstances(windowSeconds); + const { data: catalog } = useServerMetricsCatalog(range); + const { data: instances } = useServerMetricsInstances(range); const has = (metricName: string) => (catalog ?? []).some((c) => c.metricName === metricName); return (
- {/* Toolbar */} + {/* Toolbar — just server-instance badges. Time range is driven by + the global time-range control in the TopBar. */}
{(instances ?? []).slice(0, 8).map((i) => ( @@ -183,11 +200,6 @@ export default function ServerMetricsAdminPage() { )}
-