diff --git a/src/features/instance/status/analytics/hooks/useAnalyticsRecords.ts b/src/features/instance/status/analytics/hooks/useAnalyticsRecords.ts index 5f92516e9..d1042bbf8 100644 --- a/src/features/instance/status/analytics/hooks/useAnalyticsRecords.ts +++ b/src/features/instance/status/analytics/hooks/useAnalyticsRecords.ts @@ -30,6 +30,12 @@ export interface UseAnalyticsRecordsResult { fieldKeys: Set; /** Subset of `requiredFields` that did not appear on any row. */ missingFields: string[]; + /** True while `data` is the previous window's rows held by + * `keepPreviousData` during an in-flight window change — the rows do + * NOT belong to the requested [startTime, endTime]. Consumers that + * pair `data` with the requested window (CSV export filenames, + * previous-vs-current deltas) must gate on this. */ + isPlaceholderData: boolean; refetch: () => void; } @@ -132,6 +138,7 @@ export function useAnalyticsRecords({ isEmpty: data.length === 0, fieldKeys, missingFields, + isPlaceholderData: query.isPlaceholderData, refetch: query.refetch, }; } diff --git a/src/features/instance/status/analytics/tabs/HealthTab.tsx b/src/features/instance/status/analytics/tabs/HealthTab.tsx index 645c6b483..c3e15710e 100644 --- a/src/features/instance/status/analytics/tabs/HealthTab.tsx +++ b/src/features/instance/status/analytics/tabs/HealthTab.tsx @@ -1,3 +1,4 @@ +import { KpiStrip } from './kpi/KpiStrip'; import { MetricPanel } from './MetricPanel'; const METRICS = [ @@ -10,8 +11,11 @@ const METRICS = [ export function HealthTab() { return ( -
- {METRICS.map((m) => )} +
+ +
+ {METRICS.map((m) => )} +
); } diff --git a/src/features/instance/status/analytics/tabs/__tests__/ConnectionsPanel.test.tsx b/src/features/instance/status/analytics/tabs/__tests__/ConnectionsPanel.test.tsx index a840c9f94..d7b1aa226 100644 --- a/src/features/instance/status/analytics/tabs/__tests__/ConnectionsPanel.test.tsx +++ b/src/features/instance/status/analytics/tabs/__tests__/ConnectionsPanel.test.tsx @@ -31,6 +31,7 @@ function makeResult(overrides: Partial = {}): HookResult { isEmpty: true, fieldKeys: new Set(), missingFields: [], + isPlaceholderData: false, refetch: vi.fn(), ...overrides, }; diff --git a/src/features/instance/status/analytics/tabs/__tests__/MetricPanel.test.tsx b/src/features/instance/status/analytics/tabs/__tests__/MetricPanel.test.tsx index 889a5d684..a7194a800 100644 --- a/src/features/instance/status/analytics/tabs/__tests__/MetricPanel.test.tsx +++ b/src/features/instance/status/analytics/tabs/__tests__/MetricPanel.test.tsx @@ -24,6 +24,7 @@ function setHookResult(overrides: Partial isEmpty: true, fieldKeys: new Set(), missingFields: [], + isPlaceholderData: false, refetch: vi.fn(), ...overrides, }); diff --git a/src/features/instance/status/analytics/tabs/__tests__/StorageTab.test.tsx b/src/features/instance/status/analytics/tabs/__tests__/StorageTab.test.tsx index d30d55676..7088de4fc 100644 --- a/src/features/instance/status/analytics/tabs/__tests__/StorageTab.test.tsx +++ b/src/features/instance/status/analytics/tabs/__tests__/StorageTab.test.tsx @@ -49,6 +49,7 @@ function setHookResult(rows: ReturnType) { isEmpty: rows.length === 0, fieldKeys: new Set(['size', 'database', 'table']), missingFields: [], + isPlaceholderData: false, refetch: vi.fn(), }); } @@ -63,6 +64,7 @@ describe('StorageTab', () => { isEmpty: true, fieldKeys: new Set(), missingFields: [], + isPlaceholderData: false, refetch: vi.fn(), }); const { container } = render( diff --git a/src/features/instance/status/analytics/tabs/__tests__/tabs.smoke.test.tsx b/src/features/instance/status/analytics/tabs/__tests__/tabs.smoke.test.tsx index bcd1b9944..3c09843f5 100644 --- a/src/features/instance/status/analytics/tabs/__tests__/tabs.smoke.test.tsx +++ b/src/features/instance/status/analytics/tabs/__tests__/tabs.smoke.test.tsx @@ -29,6 +29,7 @@ function happyState() { isEmpty: false, fieldKeys: new Set(['count', 'mean', 'period', 'p50', 'p95', 'p99']), missingFields: [], + isPlaceholderData: false, refetch: vi.fn(), }); } @@ -42,6 +43,7 @@ function loadingState() { isEmpty: true, fieldKeys: new Set(), missingFields: [], + isPlaceholderData: false, refetch: vi.fn(), }); } @@ -55,6 +57,7 @@ function emptyMissingFieldsState(missing: string[]) { isEmpty: true, fieldKeys: new Set(), missingFields: missing, + isPlaceholderData: false, refetch: vi.fn(), }); } diff --git a/src/features/instance/status/analytics/tabs/kpi/KpiSparkline.tsx b/src/features/instance/status/analytics/tabs/kpi/KpiSparkline.tsx new file mode 100644 index 000000000..f89a6080c --- /dev/null +++ b/src/features/instance/status/analytics/tabs/kpi/KpiSparkline.tsx @@ -0,0 +1,78 @@ +import type { KpiPoint } from './kpiMath'; + +interface Props { + points: KpiPoint[]; + /** Full current-window bounds, so a half-empty window draws half a line + * instead of stretching the data to fill the tile. */ + xDomain: [number, number]; +} + +const VIEW_W = 120; +const VIEW_H = 32; +/** Inset so the stroke isn't clipped at the extremes. */ +const PAD_Y = 2; + +/** Axis-less inline-SVG sparkline for the KPI tiles. Decorative only + * (aria-hidden) — the tile's value + delta carry the information. Drawn in + * the muted de-emphasis hue with the final segment in the accent chart hue + * so "now" reads at a glance in both themes. */ +export function KpiSparkline({ points, xDomain }: Props) { + if (points.length < 2) { return