-
+
{
/* Suppress the omitted-pairs banner when all-dropped fires — the
all-dropped banner already cites the count, so showing both is
@@ -409,7 +409,7 @@ export function ReplicationLatencyRenderer(props: SpecRegistryRendererProps): JS
padding: '4px 8px',
fontSize: 12,
borderLeft: '3px solid var(--color-info, #3b82f6)',
- background: theme === 'dark' ? '#0f172a' : '#eff6ff',
+ background: 'color-mix(in srgb, var(--color-accent, #3b82f6) 10%, transparent)',
color: 'currentColor',
}}
>
@@ -433,7 +433,6 @@ export function ReplicationLatencyRenderer(props: SpecRegistryRendererProps): JS
-
+
{
/* Zero the primitive's generic skipped-count banner — the
RecognitionBanner above already reports omissions with
cause-specific copy, so letting both render double-reports. */
}
-
+
);
@@ -463,13 +462,12 @@ export function ReplicationLatencyRenderer(props: SpecRegistryRendererProps): JS
interface RecognitionBannerProps {
data: HeatmapData;
- theme: 'light' | 'dark';
}
/** Renders a single role='status' banner combining the two omission causes
* (unparseable path vs. missing percentile value) and the
* heuristic-recovered source count. Any subset may be present. */
-function RecognitionBanner({ data, theme }: RecognitionBannerProps) {
+function RecognitionBanner({ data }: RecognitionBannerProps) {
const unparseable = data.unparseablePathCount ?? 0;
const missingValue = data.missingValueCount ?? 0;
const unrecognized = data.unrecognizedSources ?? [];
@@ -504,7 +502,7 @@ function RecognitionBanner({ data, theme }: RecognitionBannerProps) {
padding: '4px 8px',
fontSize: 12,
borderLeft: '3px solid var(--color-warning, #f59e0b)',
- background: theme === 'dark' ? '#1f2937' : '#fffbeb',
+ background: 'color-mix(in srgb, var(--color-warning) 12%, transparent)',
color: 'currentColor',
}}
>
diff --git a/src/features/instance/status/analytics/pipeline/resource-usage.ts b/src/features/instance/status/analytics/pipeline/resource-usage.ts
index 976d77937..a4b28d73b 100644
--- a/src/features/instance/status/analytics/pipeline/resource-usage.ts
+++ b/src/features/instance/status/analytics/pipeline/resource-usage.ts
@@ -1,4 +1,4 @@
-import type { MetricSpec } from '../types/analytics.ts';
+import type { MetricSpec } from '../types/analytics';
// Per-field `crossNode` aggregator overrides (e.g. cpuUtilization's
// `crossNode: 'max'`) are honored by pipeline.ts as of Step 4.5: the
diff --git a/src/features/instance/status/analytics/pipeline/response-200.tsx b/src/features/instance/status/analytics/pipeline/response-200.ts
similarity index 84%
rename from src/features/instance/status/analytics/pipeline/response-200.tsx
rename to src/features/instance/status/analytics/pipeline/response-200.ts
index 375ccb1f8..392e33772 100644
--- a/src/features/instance/status/analytics/pipeline/response-200.tsx
+++ b/src/features/instance/status/analytics/pipeline/response-200.ts
@@ -1,6 +1,6 @@
// Thin re-export — this metric is defined in the `wrapperMetrics` factory
// table (wrapperMetrics.tsx). Kept so existing import paths stay stable.
-import { wrapperMetrics } from './wrapperMetrics.tsx';
+import { wrapperMetrics } from './wrapperMetrics';
export const response200Spec = wrapperMetrics['response_200'].spec;
export const Response200Renderer = wrapperMetrics['response_200'].Renderer;
diff --git a/src/features/instance/status/analytics/pipeline/runTransform.ts b/src/features/instance/status/analytics/pipeline/runTransform.ts
index 2d61f6b3e..9d384be2a 100644
--- a/src/features/instance/status/analytics/pipeline/runTransform.ts
+++ b/src/features/instance/status/analytics/pipeline/runTransform.ts
@@ -1,5 +1,5 @@
-import type { Transform } from '../types/analytics.ts';
-import { type NamedTransformKey, namedTransforms } from './transforms.ts';
+import type { Transform } from '../types/analytics';
+import { type NamedTransformKey, namedTransforms } from './transforms';
/** Apply a Transform to a scalar. `period` is the record's period in ms; only
* `rate` consults it. Null input short-circuits to null. The pipeline resolves
diff --git a/src/features/instance/status/analytics/pipeline/storage-volume.ts b/src/features/instance/status/analytics/pipeline/storage-volume.ts
index a4bd06632..a62554fea 100644
--- a/src/features/instance/status/analytics/pipeline/storage-volume.ts
+++ b/src/features/instance/status/analytics/pipeline/storage-volume.ts
@@ -1,4 +1,4 @@
-import type { MetricSpec } from '../types/analytics.ts';
+import type { MetricSpec } from '../types/analytics';
export const storageVolumeSpec: MetricSpec = {
title: 'Storage volume (available)',
diff --git a/src/features/instance/status/analytics/pipeline/success.tsx b/src/features/instance/status/analytics/pipeline/success.ts
similarity index 83%
rename from src/features/instance/status/analytics/pipeline/success.tsx
rename to src/features/instance/status/analytics/pipeline/success.ts
index b95b0d9fe..5acb90467 100644
--- a/src/features/instance/status/analytics/pipeline/success.tsx
+++ b/src/features/instance/status/analytics/pipeline/success.ts
@@ -1,6 +1,6 @@
// Thin re-export — this metric is defined in the `wrapperMetrics` factory
// table (wrapperMetrics.tsx). Kept so existing import paths stay stable.
-import { wrapperMetrics } from './wrapperMetrics.tsx';
+import { wrapperMetrics } from './wrapperMetrics';
export const successSpec = wrapperMetrics['success'].spec;
export const SuccessRenderer = wrapperMetrics['success'].Renderer;
diff --git a/src/features/instance/status/analytics/pipeline/tls-reused.ts b/src/features/instance/status/analytics/pipeline/tls-reused.ts
index 130919eab..85fb9d4a1 100644
--- a/src/features/instance/status/analytics/pipeline/tls-reused.ts
+++ b/src/features/instance/status/analytics/pipeline/tls-reused.ts
@@ -1,4 +1,4 @@
-import type { MetricSpec } from '../types/analytics.ts';
+import type { MetricSpec } from '../types/analytics';
export const tlsReusedSpec: MetricSpec = {
title: 'TLS session reuse ratio',
diff --git a/src/features/instance/status/analytics/pipeline/transfer.tsx b/src/features/instance/status/analytics/pipeline/transfer.ts
similarity index 83%
rename from src/features/instance/status/analytics/pipeline/transfer.tsx
rename to src/features/instance/status/analytics/pipeline/transfer.ts
index 383bf8220..2179e0453 100644
--- a/src/features/instance/status/analytics/pipeline/transfer.tsx
+++ b/src/features/instance/status/analytics/pipeline/transfer.ts
@@ -1,6 +1,6 @@
// Thin re-export — this metric is defined in the `wrapperMetrics` factory
// table (wrapperMetrics.tsx). Kept so existing import paths stay stable.
-import { wrapperMetrics } from './wrapperMetrics.tsx';
+import { wrapperMetrics } from './wrapperMetrics';
export const transferSpec = wrapperMetrics['transfer'].spec;
export const TransferRenderer = wrapperMetrics['transfer'].Renderer;
diff --git a/src/features/instance/status/analytics/pipeline/utilization.ts b/src/features/instance/status/analytics/pipeline/utilization.ts
index dcd761376..7879d0e32 100644
--- a/src/features/instance/status/analytics/pipeline/utilization.ts
+++ b/src/features/instance/status/analytics/pipeline/utilization.ts
@@ -1,4 +1,4 @@
-import type { MetricSpec } from '../types/analytics.ts';
+import type { MetricSpec } from '../types/analytics';
export const utilizationSpec: MetricSpec = {
title: 'Cluster utilization',
diff --git a/src/features/instance/status/analytics/pipeline/wrapperMetrics.tsx b/src/features/instance/status/analytics/pipeline/wrapperMetrics.tsx
index cf8f021de..bd433ae4f 100644
--- a/src/features/instance/status/analytics/pipeline/wrapperMetrics.tsx
+++ b/src/features/instance/status/analytics/pipeline/wrapperMetrics.tsx
@@ -10,10 +10,10 @@
// `index.ts#specRegistry`.
import type { ComponentType } from 'react';
-import { DimensionSelectorRenderer } from '../primitives/DimensionSelectorRenderer.tsx';
-import { TrafficByTypeRenderer } from '../primitives/TrafficByTypeRenderer.tsx';
-import type { MetricSpec, SpecRegistryRendererProps } from '../types/analytics.ts';
-import { QUANTILE_DEFAULT, QUANTILE_FIELDS } from './quantileFields.ts';
+import { DimensionSelectorRenderer } from '../primitives/DimensionSelectorRenderer';
+import { TrafficByTypeRenderer } from '../primitives/TrafficByTypeRenderer';
+import type { MetricSpec, SpecRegistryRendererProps } from '../types/analytics';
+import { QUANTILE_DEFAULT, QUANTILE_FIELDS } from './quantileFields';
export interface WrapperMetric {
spec: MetricSpec;
diff --git a/src/features/instance/status/analytics/primitives/DimensionChipRow.tsx b/src/features/instance/status/analytics/primitives/DimensionChipRow.tsx
index f536420f9..3ff54067c 100644
--- a/src/features/instance/status/analytics/primitives/DimensionChipRow.tsx
+++ b/src/features/instance/status/analytics/primitives/DimensionChipRow.tsx
@@ -6,7 +6,7 @@
// active at a time, arrow keys move focus and selection
// (useRovingRadioGroup).
-import { useRovingRadioGroup } from '../hooks/useRovingRadioGroup.ts';
+import { useRovingRadioGroup } from '../hooks/useRovingRadioGroup';
interface DimensionChipRowProps {
/** Selectable dimension values, in display order. */
diff --git a/src/features/instance/status/analytics/primitives/DimensionSelectorRenderer.tsx b/src/features/instance/status/analytics/primitives/DimensionSelectorRenderer.tsx
index aa81b2c81..d6833f14c 100644
--- a/src/features/instance/status/analytics/primitives/DimensionSelectorRenderer.tsx
+++ b/src/features/instance/status/analytics/primitives/DimensionSelectorRenderer.tsx
@@ -12,14 +12,14 @@
// chart. The pipeline re-runs with the chosen field substituted.
import { useMemo, useState } from 'react';
-import { NodeLegend } from '../charts/NodeLegend.tsx';
-import { useNodeFilteredSeries } from '../hooks/useNodeFilteredSeries.ts';
-import { useRovingRadioGroup } from '../hooks/useRovingRadioGroup.ts';
-import { runPipeline } from '../pipeline/pipeline.ts';
-import type { AnalyticsDataPoint, MetricSpec, SeriesData, TimeRange } from '../types/analytics.ts';
-import { DimensionChipRow } from './DimensionChipRow.tsx';
-import { DimensionCombobox } from './DimensionCombobox.tsx';
-import { LineChart } from './LineChart.tsx';
+import { NodeLegend } from '../charts/NodeLegend';
+import { useNodeFilteredSeries } from '../hooks/useNodeFilteredSeries';
+import { useRovingRadioGroup } from '../hooks/useRovingRadioGroup';
+import { runPipeline } from '../pipeline/pipeline';
+import type { AnalyticsDataPoint, MetricSpec, SeriesData, TimeRange } from '../types/analytics';
+import { DimensionChipRow } from './DimensionChipRow';
+import { DimensionCombobox } from './DimensionCombobox';
+import { LineChart } from './LineChart';
const OTHER_KEY = 'Other';
const CHIP_LIMIT = 12;
diff --git a/src/features/instance/status/analytics/primitives/FallbackRenderer.tsx b/src/features/instance/status/analytics/primitives/FallbackRenderer.tsx
index 08c165200..01725c372 100644
--- a/src/features/instance/status/analytics/primitives/FallbackRenderer.tsx
+++ b/src/features/instance/status/analytics/primitives/FallbackRenderer.tsx
@@ -1,5 +1,5 @@
-import type { AnalyticsDataPoint, SeriesData, TimeRange } from '../types/analytics.ts';
-import { SmallMultiples } from './SmallMultiples.tsx';
+import type { AnalyticsDataPoint, SeriesData } from '../types/analytics';
+import { SmallMultiples } from './SmallMultiples';
const isDev = import.meta.env?.DEV ?? false;
@@ -26,11 +26,6 @@ const MAX_FALLBACK_PANELS = 8;
interface Props {
metric: string;
records: AnalyticsDataPoint[];
- /** Unused by this renderer — accepted (optionally) only because
- * MetricRenderer's callsites still pass them; drop from both sides when
- * those callsites are next touched. */
- window?: TimeRange;
- nodes?: string[];
/** Optional inline banner shown above the dev hint — used by callers that
* fell through to FallbackRenderer for a known reason (e.g. legacy chart
* failed to load), so users see the cause. */
diff --git a/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx b/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx
index 888ac6f95..5c1f3c8fb 100644
--- a/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx
+++ b/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx
@@ -1,18 +1,13 @@
import { formatValue } from '@/lib/formatValue';
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
-import { useResolvedTheme } from '../lib/theme.ts';
-import type { AxisSpec, HeatmapCell, HeatmapData } from '../types/analytics.ts';
-import { computeCellSize } from './computeCellSize.ts';
-export { computeCellSize } from './computeCellSize.ts';
+import { useResolvedTheme } from '../lib/theme';
+import type { AxisSpec, HeatmapCell, HeatmapData } from '../types/analytics';
+import { computeCellSize } from './computeCellSize';
+export { computeCellSize } from './computeCellSize';
interface Props {
data: HeatmapData;
- /** @deprecated Ignored — the color-stop ramp branches on the resolved app
- * theme via useResolvedTheme(), and the confidence greys are
- * `--chart-heatmap-*` CSS tokens. Kept optional only while the pipeline
- * renderers (owned by a parallel refactor) still pass it. */
- theme?: 'light' | 'dark';
title?: string;
height?: number;
}
diff --git a/src/features/instance/status/analytics/primitives/LineChart.tsx b/src/features/instance/status/analytics/primitives/LineChart.tsx
index fa1cff541..c2a592a26 100644
--- a/src/features/instance/status/analytics/primitives/LineChart.tsx
+++ b/src/features/instance/status/analytics/primitives/LineChart.tsx
@@ -10,18 +10,14 @@ import {
XAxis,
YAxis,
} from 'recharts';
-import { NODE_PALETTE } from '../lib/nodeColors.ts';
-import { getChartColors } from '../lib/theme.ts';
-import { formatAxisTick, formatTooltipTime } from '../lib/time.ts';
-import type { AxisSpec, SeriesData, Threshold } from '../types/analytics.ts';
-import { tooltipContentStyle, tooltipItemStyle, tooltipLabelStyle } from './tooltipStyle.ts';
+import { NODE_PALETTE } from '../lib/nodeColors';
+import { getChartColors } from '../lib/theme';
+import { formatAxisTick, formatTooltipTime } from '../lib/time';
+import type { AxisSpec, SeriesData, Threshold } from '../types/analytics';
+import { tooltipContentStyle, tooltipItemStyle, tooltipLabelStyle } from './tooltipStyle';
interface Props {
data: SeriesData;
- /** @deprecated Ignored — colors resolve via `--chart-*` CSS tokens, so the
- * chart re-themes with the app automatically. Kept optional only while
- * the pipeline renderers (owned by a parallel refactor) still pass it. */
- theme?: 'light' | 'dark';
yAxis?: AxisSpec | { left: AxisSpec; right?: AxisSpec };
height?: number;
/** Optional accessible label override; otherwise composed from series labels. */
diff --git a/src/features/instance/status/analytics/primitives/LineChartWithNodeLegend.tsx b/src/features/instance/status/analytics/primitives/LineChartWithNodeLegend.tsx
index 95934dea4..b711fc63a 100644
--- a/src/features/instance/status/analytics/primitives/LineChartWithNodeLegend.tsx
+++ b/src/features/instance/status/analytics/primitives/LineChartWithNodeLegend.tsx
@@ -5,11 +5,11 @@
// behavior as the chip-selector panels and small-multiples grids.
import { useMemo } from 'react';
-import { NodeLegend } from '../charts/NodeLegend.tsx';
-import { useNodeSelection } from '../hooks/useNodeSelection.ts';
-import { getNodeColor } from '../lib/nodeColors.ts';
-import type { AxisSpec, Series, SeriesData } from '../types/analytics.ts';
-import { LineChart } from './LineChart.tsx';
+import { NodeLegend } from '../charts/NodeLegend';
+import { useNodeSelection } from '../hooks/useNodeSelection';
+import { getNodeColor } from '../lib/nodeColors';
+import type { AxisSpec, Series, SeriesData } from '../types/analytics';
+import { LineChart } from './LineChart';
interface Props {
data: SeriesData;
diff --git a/src/features/instance/status/analytics/primitives/MetricRenderer.tsx b/src/features/instance/status/analytics/primitives/MetricRenderer.tsx
index 1f04841cc..390b807f7 100644
--- a/src/features/instance/status/analytics/primitives/MetricRenderer.tsx
+++ b/src/features/instance/status/analytics/primitives/MetricRenderer.tsx
@@ -1,13 +1,12 @@
import { Component, type ReactNode } from 'react';
-import { useResolvedTheme } from '../lib/theme.ts';
-import { derivedRegistry } from '../pipeline/derived/index.ts';
-import { specRegistry } from '../pipeline/index.ts';
-import { runPipeline } from '../pipeline/pipeline.ts';
-import type { AnalyticsDataPoint, AxisSpec, MetricSpec, SeriesData, TimeRange } from '../types/analytics.ts';
-import { FallbackRenderer } from './FallbackRenderer.tsx';
-import { LineChartWithNodeLegend } from './LineChartWithNodeLegend.tsx';
-import { SmallMultiples } from './SmallMultiples.tsx';
-import { StackedAreaChart } from './StackedAreaChart.tsx';
+import { derivedRegistry } from '../pipeline/derived/index';
+import { specRegistry } from '../pipeline/index';
+import { runPipeline } from '../pipeline/pipeline';
+import type { AnalyticsDataPoint, AxisSpec, MetricSpec, SeriesData, TimeRange } from '../types/analytics';
+import { FallbackRenderer } from './FallbackRenderer';
+import { LineChartWithNodeLegend } from './LineChartWithNodeLegend';
+import { SmallMultiples } from './SmallMultiples';
+import { StackedAreaChart } from './StackedAreaChart';
function renderPrimitive(
primitive: MetricSpec['primitive'],
@@ -112,17 +111,10 @@ export function MetricRenderer({
viewMode,
fillParent,
}: Props) {
- // Only the registry Renderers still take a theme prop (their signatures
- // live in pipeline/, owned by a parallel refactor); primitives re-theme
- // via CSS tokens and need nothing passed.
- const theme = useResolvedTheme();
-
const errorFallback = (
);
@@ -137,7 +129,6 @@ export function MetricRenderer({
records={records}
timeRange={timeRange}
nodes={nodes}
- theme={theme}
viewMode={viewMode}
fillParent={fillParent}
/>
@@ -154,7 +145,6 @@ export function MetricRenderer({
records={records}
timeRange={timeRange}
nodes={nodes}
- theme={theme}
viewMode={viewMode}
fillParent={fillParent}
/>
@@ -212,7 +202,7 @@ export function MetricRenderer({
body = renderPrimitive(entry.spec.primitive, seriesData, entry.spec.yAxis, xDomain, fillParent);
}
} else {
- body = ;
+ body = ;
}
}
diff --git a/src/features/instance/status/analytics/primitives/PerPathRateRenderer.tsx b/src/features/instance/status/analytics/primitives/PerPathRateRenderer.tsx
index a73d90ca2..4a9b71759 100644
--- a/src/features/instance/status/analytics/primitives/PerPathRateRenderer.tsx
+++ b/src/features/instance/status/analytics/primitives/PerPathRateRenderer.tsx
@@ -6,22 +6,18 @@
// the path-discovery + viewMode threading.
import { useMemo, useState } from 'react';
-import { NodeLegend } from '../charts/NodeLegend.tsx';
-import { useNodeSelection } from '../hooks/useNodeSelection.ts';
-import { getNodeColor } from '../lib/nodeColors.ts';
-import { shortenNodeLabel } from '../lib/nodeLabels.ts';
-import type { AnalyticsDataPoint, AxisSpec, SeriesData, Threshold, TimeRange } from '../types/analytics.ts';
-import { DimensionChipRow } from './DimensionChipRow.tsx';
-import { LineChart } from './LineChart.tsx';
+import { NodeLegend } from '../charts/NodeLegend';
+import { useNodeSelection } from '../hooks/useNodeSelection';
+import { getNodeColor } from '../lib/nodeColors';
+import { shortenNodeLabel } from '../lib/nodeLabels';
+import type { AnalyticsDataPoint, AxisSpec, SeriesData, Threshold, TimeRange } from '../types/analytics';
+import { DimensionChipRow } from './DimensionChipRow';
+import { LineChart } from './LineChart';
interface Props {
records: AnalyticsDataPoint[];
timeRange?: TimeRange;
nodes: string[];
- /** @deprecated Ignored — theming resolves via `--chart-*` CSS tokens.
- * Kept optional only while pipeline renderers (owned by a parallel
- * refactor) still pass it. */
- theme?: 'light' | 'dark';
viewMode?: 'per-node' | 'aggregate';
yAxis?: AxisSpec | { left: AxisSpec; right?: AxisSpec };
thresholds?: Threshold[];
diff --git a/src/features/instance/status/analytics/primitives/SmallMultiples.tsx b/src/features/instance/status/analytics/primitives/SmallMultiples.tsx
index d90bccfe5..4556e1848 100644
--- a/src/features/instance/status/analytics/primitives/SmallMultiples.tsx
+++ b/src/features/instance/status/analytics/primitives/SmallMultiples.tsx
@@ -1,9 +1,9 @@
import { useMemo } from 'react';
-import { NodeLegend } from '../charts/NodeLegend.tsx';
-import { useNodeSelection } from '../hooks/useNodeSelection.ts';
-import { getNodeColor } from '../lib/nodeColors.ts';
-import type { AxisSpec, SeriesData } from '../types/analytics.ts';
-import { LineChart } from './LineChart.tsx';
+import { NodeLegend } from '../charts/NodeLegend';
+import { useNodeSelection } from '../hooks/useNodeSelection';
+import { getNodeColor } from '../lib/nodeColors';
+import type { AxisSpec, SeriesData } from '../types/analytics';
+import { LineChart } from './LineChart';
interface Panel {
title: string;
diff --git a/src/features/instance/status/analytics/primitives/StackedAreaChart.tsx b/src/features/instance/status/analytics/primitives/StackedAreaChart.tsx
index 6d1de89be..b83f9f9bb 100644
--- a/src/features/instance/status/analytics/primitives/StackedAreaChart.tsx
+++ b/src/features/instance/status/analytics/primitives/StackedAreaChart.tsx
@@ -1,12 +1,12 @@
import { formatValue } from '@/lib/formatValue';
import { useMemo } from 'react';
import { Area, AreaChart, CartesianGrid, Legend, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
-import { NODE_PALETTE } from '../lib/nodeColors.ts';
-import { getChartColors, useResolvedTheme } from '../lib/theme.ts';
-import { formatAxisTick, formatTooltipTime } from '../lib/time.ts';
-import type { AxisFormatter, AxisSpec, SeriesData } from '../types/analytics.ts';
-import { sortByMagnitude } from './sortByMagnitude.ts';
-import { tooltipContentStyle, tooltipLabelStyle } from './tooltipStyle.ts';
+import { NODE_PALETTE } from '../lib/nodeColors';
+import { getChartColors, useResolvedTheme } from '../lib/theme';
+import { formatAxisTick, formatTooltipTime } from '../lib/time';
+import type { AxisFormatter, AxisSpec, SeriesData } from '../types/analytics';
+import { sortByMagnitude } from './sortByMagnitude';
+import { tooltipContentStyle, tooltipLabelStyle } from './tooltipStyle';
interface Props {
data: SeriesData;
diff --git a/src/features/instance/status/analytics/primitives/TrafficByTypeRenderer.tsx b/src/features/instance/status/analytics/primitives/TrafficByTypeRenderer.tsx
index d76a42f60..ef95a4e42 100644
--- a/src/features/instance/status/analytics/primitives/TrafficByTypeRenderer.tsx
+++ b/src/features/instance/status/analytics/primitives/TrafficByTypeRenderer.tsx
@@ -15,17 +15,17 @@
// the stack pre-pipeline.
import { useMemo, useState } from 'react';
-import { NodeLegend } from '../charts/NodeLegend.tsx';
-import { useNodeSelection } from '../hooks/useNodeSelection.ts';
-import { useRovingRadioGroup } from '../hooks/useRovingRadioGroup.ts';
-import { useSoloToggleSelection } from '../hooks/useSoloToggleSelection.ts';
-import { getTypeColor } from '../lib/colorAllocators/typeColors.ts';
-import { getNodeColor } from '../lib/nodeColors.ts';
-import { runPipeline } from '../pipeline/pipeline.ts';
-import type { AnalyticsDataPoint, MetricSpec, Series, SeriesData, TimeRange } from '../types/analytics.ts';
-import { SmallMultiples } from './SmallMultiples.tsx';
-import { StackedAreaChart } from './StackedAreaChart.tsx';
-import { TypeFilterChipRow } from './TypeFilterChipRow.tsx';
+import { NodeLegend } from '../charts/NodeLegend';
+import { useNodeSelection } from '../hooks/useNodeSelection';
+import { useRovingRadioGroup } from '../hooks/useRovingRadioGroup';
+import { useSoloToggleSelection } from '../hooks/useSoloToggleSelection';
+import { getTypeColor } from '../lib/colorAllocators/typeColors';
+import { getNodeColor } from '../lib/nodeColors';
+import { runPipeline } from '../pipeline/pipeline';
+import type { AnalyticsDataPoint, MetricSpec, Series, SeriesData, TimeRange } from '../types/analytics';
+import { SmallMultiples } from './SmallMultiples';
+import { StackedAreaChart } from './StackedAreaChart';
+import { TypeFilterChipRow } from './TypeFilterChipRow';
interface Props {
/** Underlying spec (groupBy on `typeField`, primitive `stacked-area`). */
diff --git a/src/features/instance/status/analytics/tabs/ConnectionsPanel.tsx b/src/features/instance/status/analytics/tabs/ConnectionsPanel.tsx
index 254307d99..9bc9689f8 100644
--- a/src/features/instance/status/analytics/tabs/ConnectionsPanel.tsx
+++ b/src/features/instance/status/analytics/tabs/ConnectionsPanel.tsx
@@ -1,10 +1,10 @@
import { useMemo } from 'react';
-import { useAnalyticsContext } from '../context/AnalyticsContext.tsx';
-import { useAnalyticsRecords } from '../hooks/useAnalyticsRecords.ts';
-import { ConnectionsRenderer } from '../pipeline/connections.tsx';
-import type { AnalyticsDataPoint } from '../types/analytics.ts';
-import { PanelErrorBoundary } from './PanelErrorBoundary.tsx';
-import { collectNodes, PanelCard, PanelStateOrChart } from './PanelShell.tsx';
+import { useAnalyticsContext } from '../context/AnalyticsContext';
+import { useAnalyticsRecords } from '../hooks/useAnalyticsRecords';
+import { ConnectionsRenderer } from '../pipeline/connections';
+import type { AnalyticsDataPoint } from '../types/analytics';
+import { PanelErrorBoundary } from './PanelErrorBoundary';
+import { collectNodes, PanelCard, PanelStateOrChart } from './PanelShell';
/** Some Harper builds split active-session telemetry across two metrics:
* `mqtt-connections` (active MQTT sessions) and `ws-connections` (active
@@ -27,7 +27,7 @@ export function ConnectionsPanel() {
}
function ConnectionsPanelInner() {
- const { timeRange, bucketMs, theme, instanceParams } = useAnalyticsContext();
+ const { timeRange, bucketMs, instanceParams } = useAnalyticsContext();
const mqtt = useAnalyticsRecords({
metric: 'mqtt-connections',
@@ -64,7 +64,6 @@ function ConnectionsPanelInner() {
records={merged}
timeRange={timeRange}
nodes={nodes}
- theme={theme}
fillParent={opts.fillParent}
/>
);
diff --git a/src/features/instance/status/analytics/tabs/DatabaseTab.tsx b/src/features/instance/status/analytics/tabs/DatabaseTab.tsx
index a9524661b..6f89c4a73 100644
--- a/src/features/instance/status/analytics/tabs/DatabaseTab.tsx
+++ b/src/features/instance/status/analytics/tabs/DatabaseTab.tsx
@@ -1,4 +1,4 @@
-import { MetricPanel } from './MetricPanel.tsx';
+import { MetricPanel } from './MetricPanel';
const METRICS = ['db-read', 'db-write', 'db-message'] as const;
diff --git a/src/features/instance/status/analytics/tabs/HealthTab.tsx b/src/features/instance/status/analytics/tabs/HealthTab.tsx
index a4e976bd0..645c6b483 100644
--- a/src/features/instance/status/analytics/tabs/HealthTab.tsx
+++ b/src/features/instance/status/analytics/tabs/HealthTab.tsx
@@ -1,4 +1,4 @@
-import { MetricPanel } from './MetricPanel.tsx';
+import { MetricPanel } from './MetricPanel';
const METRICS = [
'resource-usage',
diff --git a/src/features/instance/status/analytics/tabs/MetricPanel.tsx b/src/features/instance/status/analytics/tabs/MetricPanel.tsx
index ac5af7c07..5bcd2550e 100644
--- a/src/features/instance/status/analytics/tabs/MetricPanel.tsx
+++ b/src/features/instance/status/analytics/tabs/MetricPanel.tsx
@@ -1,12 +1,12 @@
import { useMemo } from 'react';
-import { useAnalyticsContext } from '../context/AnalyticsContext.tsx';
-import { useAnalyticsRecords } from '../hooks/useAnalyticsRecords.ts';
-import { getSpecRequiredFields } from '../lib/specRequiredFields.ts';
-import { derivedRegistry } from '../pipeline/derived/index.ts';
-import { specRegistry } from '../pipeline/index.ts';
-import { MetricRenderer } from '../primitives/MetricRenderer.tsx';
-import { PanelErrorBoundary } from './PanelErrorBoundary.tsx';
-import { collectNodes, PanelCard, PanelStateOrChart } from './PanelShell.tsx';
+import { useAnalyticsContext } from '../context/AnalyticsContext';
+import { useAnalyticsRecords } from '../hooks/useAnalyticsRecords';
+import { getSpecRequiredFields } from '../lib/specRequiredFields';
+import { derivedRegistry } from '../pipeline/derived/index';
+import { specRegistry } from '../pipeline/index';
+import { MetricRenderer } from '../primitives/MetricRenderer';
+import { PanelErrorBoundary } from './PanelErrorBoundary';
+import { collectNodes, PanelCard, PanelStateOrChart } from './PanelShell';
interface Props {
metric: string;
diff --git a/src/features/instance/status/analytics/tabs/OverviewTab.tsx b/src/features/instance/status/analytics/tabs/OverviewTab.tsx
index 8d3fec2b4..877b9977c 100644
--- a/src/features/instance/status/analytics/tabs/OverviewTab.tsx
+++ b/src/features/instance/status/analytics/tabs/OverviewTab.tsx
@@ -1,13 +1,13 @@
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Card, CardContent } from '@/components/ui/card';
-import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig.ts';
+import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig';
import { getStatusQueryOptions } from '@/integrations/api/instance/status/getStatus';
import { getSystemInformationQueryOptions } from '@/integrations/api/instance/status/getSystemInformation';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Suspense, useMemo } from 'react';
-import { useAnalyticsContext } from '../context/AnalyticsContext.tsx';
-import { crawlData, hasTitle } from './crawlData.ts';
-import { PanelErrorBoundary } from './PanelErrorBoundary.tsx';
+import { useAnalyticsContext } from '../context/AnalyticsContext';
+import { crawlData, hasTitle } from './crawlData';
+import { PanelErrorBoundary } from './PanelErrorBoundary';
interface Props {
instanceParams: InstanceClientIdConfig & InstanceTypeConfig;
diff --git a/src/features/instance/status/analytics/tabs/PanelShell.tsx b/src/features/instance/status/analytics/tabs/PanelShell.tsx
index 3f11e8c5e..cfd83dfa0 100644
--- a/src/features/instance/status/analytics/tabs/PanelShell.tsx
+++ b/src/features/instance/status/analytics/tabs/PanelShell.tsx
@@ -2,9 +2,9 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { errorText } from '@/lib/errorText';
import { type ReactNode, useRef } from 'react';
-import { ChartCopyButton } from '../components/ChartCopyButton.tsx';
-import { ChartExpandButton } from '../components/ChartExpandButton.tsx';
-import { ChartExportButton } from '../components/ChartExportButton.tsx';
+import { ChartCopyButton } from '../components/ChartCopyButton';
+import { ChartExpandButton } from '../components/ChartExpandButton';
+import { ChartExportButton } from '../components/ChartExportButton';
interface PanelCardProps {
title: string;
diff --git a/src/features/instance/status/analytics/tabs/ReplicationTab.tsx b/src/features/instance/status/analytics/tabs/ReplicationTab.tsx
index f60d15189..88105fb0a 100644
--- a/src/features/instance/status/analytics/tabs/ReplicationTab.tsx
+++ b/src/features/instance/status/analytics/tabs/ReplicationTab.tsx
@@ -1,4 +1,4 @@
-import { MetricPanel } from './MetricPanel.tsx';
+import { MetricPanel } from './MetricPanel';
export function ReplicationTab() {
return (
diff --git a/src/features/instance/status/analytics/tabs/RequestsTab.tsx b/src/features/instance/status/analytics/tabs/RequestsTab.tsx
index f57bb93f5..68c77c505 100644
--- a/src/features/instance/status/analytics/tabs/RequestsTab.tsx
+++ b/src/features/instance/status/analytics/tabs/RequestsTab.tsx
@@ -1,4 +1,4 @@
-import { MetricPanel } from './MetricPanel.tsx';
+import { MetricPanel } from './MetricPanel';
const METRICS = [
'request-rate',
diff --git a/src/features/instance/status/analytics/tabs/StorageTab.tsx b/src/features/instance/status/analytics/tabs/StorageTab.tsx
index eed93e96d..928c7b1bd 100644
--- a/src/features/instance/status/analytics/tabs/StorageTab.tsx
+++ b/src/features/instance/status/analytics/tabs/StorageTab.tsx
@@ -1,16 +1,16 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useMemo, useRef, useState } from 'react';
-import { TableSizeSnapshot } from '../charts/TableSizeSnapshot.tsx';
-import { TableSizeTrend } from '../charts/TableSizeTrend.tsx';
-import { ChartCopyButton } from '../components/ChartCopyButton.tsx';
-import { ChartExpandButton } from '../components/ChartExpandButton.tsx';
-import { ChartExportButton } from '../components/ChartExportButton.tsx';
-import { useAnalyticsContext } from '../context/AnalyticsContext.tsx';
-import { useAnalyticsRecords } from '../hooks/useAnalyticsRecords.ts';
-import { buildDerived, type RankBy, type TableSizeDerived } from '../lib/tableSize.ts';
-import type { TableSizeRecord } from '../types/analytics.ts';
-import { MetricPanel } from './MetricPanel.tsx';
-import { PanelErrorBoundary } from './PanelErrorBoundary.tsx';
+import { TableSizeSnapshot } from '../charts/TableSizeSnapshot';
+import { TableSizeTrend } from '../charts/TableSizeTrend';
+import { ChartCopyButton } from '../components/ChartCopyButton';
+import { ChartExpandButton } from '../components/ChartExpandButton';
+import { ChartExportButton } from '../components/ChartExportButton';
+import { useAnalyticsContext } from '../context/AnalyticsContext';
+import { useAnalyticsRecords } from '../hooks/useAnalyticsRecords';
+import { buildDerived, type RankBy, type TableSizeDerived } from '../lib/tableSize';
+import type { TableSizeRecord } from '../types/analytics';
+import { MetricPanel } from './MetricPanel';
+import { PanelErrorBoundary } from './PanelErrorBoundary';
function derivedClusterNodes(derived: TableSizeDerived): string[] {
return derived.snapshot.byNode.map((b) => b.node);
@@ -32,7 +32,7 @@ export function StorageTab() {
}
function TableSizePanels() {
- const { timeRange, bucketMs, theme, instanceParams } = useAnalyticsContext();
+ const { timeRange, bucketMs, instanceParams } = useAnalyticsContext();
const { data, isLoading, isError } = useAnalyticsRecords({
metric: 'table-size',
startTime: timeRange.startTime,
@@ -134,7 +134,6 @@ function TableSizePanels() {
({
useAnalyticsRecords: vi.fn(),
}));
-import { useAnalyticsRecords } from '../../hooks/useAnalyticsRecords.ts';
-import { ConnectionsPanel } from '../ConnectionsPanel.tsx';
-import { AnalyticsTestWrapper, makeRows } from './testHelpers.tsx';
+import { useAnalyticsRecords } from '../../hooks/useAnalyticsRecords';
+import { ConnectionsPanel } from '../ConnectionsPanel';
+import { AnalyticsTestWrapper, makeRows } from './testHelpers';
const mockHook = vi.mocked(useAnalyticsRecords);
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 c649ac2af..bcc97380a 100644
--- a/src/features/instance/status/analytics/tabs/__tests__/MetricPanel.test.tsx
+++ b/src/features/instance/status/analytics/tabs/__tests__/MetricPanel.test.tsx
@@ -6,9 +6,9 @@ vi.mock('../../hooks/useAnalyticsRecords.ts', () => ({
useAnalyticsRecords: vi.fn(),
}));
-import { useAnalyticsRecords } from '../../hooks/useAnalyticsRecords.ts';
-import { MetricPanel } from '../MetricPanel.tsx';
-import { AnalyticsTestWrapper, makeRows } from './testHelpers.tsx';
+import { useAnalyticsRecords } from '../../hooks/useAnalyticsRecords';
+import { MetricPanel } from '../MetricPanel';
+import { AnalyticsTestWrapper, makeRows } from './testHelpers';
const mockHook = vi.mocked(useAnalyticsRecords);
diff --git a/src/features/instance/status/analytics/tabs/__tests__/OverviewTab.test.tsx b/src/features/instance/status/analytics/tabs/__tests__/OverviewTab.test.tsx
index dbd37a06b..acebbac6a 100644
--- a/src/features/instance/status/analytics/tabs/__tests__/OverviewTab.test.tsx
+++ b/src/features/instance/status/analytics/tabs/__tests__/OverviewTab.test.tsx
@@ -31,8 +31,8 @@ vi.mock('@/integrations/api/instance/status/getStatus', () => ({
}),
}));
-import { OverviewTab } from '../OverviewTab.tsx';
-import { AnalyticsTestWrapper } from './testHelpers.tsx';
+import { OverviewTab } from '../OverviewTab';
+import { AnalyticsTestWrapper } from './testHelpers';
const instanceParams = {
instanceClient: { post: vi.fn() } as never,
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 372cc89f0..b718198dd 100644
--- a/src/features/instance/status/analytics/tabs/__tests__/StorageTab.test.tsx
+++ b/src/features/instance/status/analytics/tabs/__tests__/StorageTab.test.tsx
@@ -6,10 +6,10 @@ vi.mock('../../hooks/useAnalyticsRecords.ts', () => ({
useAnalyticsRecords: vi.fn(),
}));
-import { useAnalyticsRecords } from '../../hooks/useAnalyticsRecords.ts';
-import type { AnalyticsDataPoint } from '../../types/analytics.ts';
-import { StorageTab } from '../StorageTab.tsx';
-import { AnalyticsTestWrapper } from './testHelpers.tsx';
+import { useAnalyticsRecords } from '../../hooks/useAnalyticsRecords';
+import type { AnalyticsDataPoint } from '../../types/analytics';
+import { StorageTab } from '../StorageTab';
+import { AnalyticsTestWrapper } from './testHelpers';
const mockHook = vi.mocked(useAnalyticsRecords);
diff --git a/src/features/instance/status/analytics/tabs/__tests__/groupSections.test.ts b/src/features/instance/status/analytics/tabs/__tests__/groupSections.test.ts
index 61e204139..1f487763d 100644
--- a/src/features/instance/status/analytics/tabs/__tests__/groupSections.test.ts
+++ b/src/features/instance/status/analytics/tabs/__tests__/groupSections.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { groupSections } from '../OverviewTab.tsx';
+import { groupSections } from '../OverviewTab';
describe('groupSections', () => {
it('returns an empty list for empty input', () => {
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 9a43bc3c0..ccc62820f 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
@@ -6,14 +6,14 @@ vi.mock('../../hooks/useAnalyticsRecords.ts', () => ({
useAnalyticsRecords: vi.fn(),
}));
-import { useAnalyticsRecords } from '../../hooks/useAnalyticsRecords.ts';
-import { DatabaseTab } from '../DatabaseTab.tsx';
-import { HealthTab } from '../HealthTab.tsx';
-import { ReplicationTab } from '../ReplicationTab.tsx';
-import { RequestsTab } from '../RequestsTab.tsx';
-import { StorageTab } from '../StorageTab.tsx';
-import { TrafficTab } from '../TrafficTab.tsx';
-import { AnalyticsTestWrapper, makeRows } from './testHelpers.tsx';
+import { useAnalyticsRecords } from '../../hooks/useAnalyticsRecords';
+import { DatabaseTab } from '../DatabaseTab';
+import { HealthTab } from '../HealthTab';
+import { ReplicationTab } from '../ReplicationTab';
+import { RequestsTab } from '../RequestsTab';
+import { StorageTab } from '../StorageTab';
+import { TrafficTab } from '../TrafficTab';
+import { AnalyticsTestWrapper, makeRows } from './testHelpers';
const mockHook = vi.mocked(useAnalyticsRecords);
diff --git a/src/features/instance/status/analytics/tabs/__tests__/testHelpers.tsx b/src/features/instance/status/analytics/tabs/__tests__/testHelpers.tsx
index ef791fb96..5c6fa3a01 100644
--- a/src/features/instance/status/analytics/tabs/__tests__/testHelpers.tsx
+++ b/src/features/instance/status/analytics/tabs/__tests__/testHelpers.tsx
@@ -1,9 +1,9 @@
// @vitest-environment jsdom
-import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig.ts';
+import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
-import { type AnalyticsContextValue, AnalyticsProvider } from '../../context/AnalyticsContext.tsx';
-import type { AnalyticsDataPoint } from '../../types/analytics.ts';
+import { type AnalyticsContextValue, AnalyticsProvider } from '../../context/AnalyticsContext';
+import type { AnalyticsDataPoint } from '../../types/analytics';
export function makeInstanceParams(): InstanceClientIdConfig & InstanceTypeConfig {
return {
@@ -17,7 +17,6 @@ export function makeContextValue(overrides: Partial = {})
return {
timeRange: { startTime: 0, endTime: 60_000 },
bucketMs: 60_000,
- theme: 'light',
instanceParams: makeInstanceParams(),
...overrides,
};
diff --git a/src/features/instance/status/analytics/types/analytics.ts b/src/features/instance/status/analytics/types/analytics.ts
index 9cbd06242..a3bd8f3f4 100644
--- a/src/features/instance/status/analytics/types/analytics.ts
+++ b/src/features/instance/status/analytics/types/analytics.ts
@@ -295,7 +295,6 @@ export interface SpecRegistryRendererProps {
/** Renamed from `window` to avoid shadowing the DOM `window`. */
timeRange: TimeRange;
nodes: string[];
- theme: 'light' | 'dark';
viewMode?: 'per-node' | 'aggregate';
/** When true, charts fill parent vertical space (expand dialog). */
fillParent?: boolean;
diff --git a/src/features/instance/status/index.tsx b/src/features/instance/status/index.tsx
index 5c61db2d8..8e1f998c2 100644
--- a/src/features/instance/status/index.tsx
+++ b/src/features/instance/status/index.tsx
@@ -1,6 +1,6 @@
import { isLocalStudio } from '@/config/constants';
-import { useInstanceClientIdParams } from '@/config/useInstanceClient.tsx';
-import { StatusTabs } from '@/features/instance/status/analytics/StatusTabs.tsx';
+import { useInstanceClientIdParams } from '@/config/useInstanceClient';
+import { StatusTabs } from '@/features/instance/status/analytics/StatusTabs';
export function StatusIndex() {
const instanceParams = useInstanceClientIdParams();
diff --git a/src/features/instance/status/routes.ts b/src/features/instance/status/routes.ts
index d3d208472..ac15b7f40 100644
--- a/src/features/instance/status/routes.ts
+++ b/src/features/instance/status/routes.ts
@@ -1,5 +1,5 @@
import { createInstanceLayoutRoute } from '@/features/instance/instanceLayoutRoute';
-import { STATUS_SEARCH_DEFAULTS, validateStatusSearch } from '@/features/instance/status/statusSearch.ts';
+import { STATUS_SEARCH_DEFAULTS, validateStatusSearch } from '@/features/instance/status/statusSearch';
import { createRoute, lazyRouteComponent, stripSearchParams } from '@tanstack/react-router';
export function createStatusRouteTree(instanceLayoutRoute: ReturnType) {
diff --git a/src/features/instance/status/statusSearch.test.ts b/src/features/instance/status/statusSearch.test.ts
index 69bcffe9e..22924838c 100644
--- a/src/features/instance/status/statusSearch.test.ts
+++ b/src/features/instance/status/statusSearch.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { STATUS_SEARCH_DEFAULTS, validateStatusSearch } from './statusSearch.ts';
+import { STATUS_SEARCH_DEFAULTS, validateStatusSearch } from './statusSearch';
describe('validateStatusSearch', () => {
it('passes through valid values', () => {
diff --git a/src/features/instance/status/statusSearch.ts b/src/features/instance/status/statusSearch.ts
index dcb6d010c..19ff11350 100644
--- a/src/features/instance/status/statusSearch.ts
+++ b/src/features/instance/status/statusSearch.ts
@@ -4,7 +4,7 @@ import {
REFRESH_OPTIONS,
TIME_PRESETS,
type TimePresetId,
-} from './analytics/context/timePresets.ts';
+} from './analytics/context/timePresets';
/** Search-param schema for the instance Status route (`?tab&range&refresh`).
* Lives outside analytics/ so the route definition can validate search
diff --git a/src/integrations/api/instance/status/getAnalytics.ts b/src/integrations/api/instance/status/getAnalytics.ts
index 4f62f9908..a2122e65f 100644
--- a/src/integrations/api/instance/status/getAnalytics.ts
+++ b/src/integrations/api/instance/status/getAnalytics.ts
@@ -1,5 +1,5 @@
-import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig.ts';
-import type { AnalyticsDataPoint } from '@/features/instance/status/analytics/types/analytics.ts';
+import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig';
+import type { AnalyticsDataPoint } from '@/features/instance/status/analytics/types/analytics';
import { queryOptions } from '@tanstack/react-query';
// Analytics query feeding the spec-driven pipeline. Returns rows verbatim
diff --git a/src/lib/humanFileSize.ts b/src/lib/humanFileSize.ts
index 66427a3d5..300d7d288 100644
--- a/src/lib/humanFileSize.ts
+++ b/src/lib/humanFileSize.ts
@@ -1,4 +1,4 @@
-import { determineUnits, scaleValueToUnits } from '@/lib/units.ts';
+import { determineUnits, scaleValueToUnits } from '@/lib/units';
export function humanFileSize(input: number, multiplierFromBytes: number = 1) {
const initialValue = input * multiplierFromBytes;
diff --git a/src/lib/units.test.ts b/src/lib/units.test.ts
index fa0260539..a84fa24e4 100644
--- a/src/lib/units.test.ts
+++ b/src/lib/units.test.ts
@@ -1,4 +1,4 @@
-import { determineUnits, scaleValueToUnits } from '@/lib/units.ts';
+import { determineUnits, scaleValueToUnits } from '@/lib/units';
import { describe, expect, it } from 'vitest';
describe('determineUnits', () => {
From 5995ceac087ead38e1cc77a5bb77edf905507a75 Mon Sep 17 00:00:00 2001
From: Kyle Bernhardy
Date: Mon, 13 Jul 2026 15:36:53 -0600
Subject: [PATCH 02/92] refactor(status): align the info banner on the accent
token
Co-Authored-By: Claude Fable 5
---
.../instance/status/analytics/pipeline/replication-latency.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/features/instance/status/analytics/pipeline/replication-latency.tsx b/src/features/instance/status/analytics/pipeline/replication-latency.tsx
index 8b9c2090b..b71715aa7 100644
--- a/src/features/instance/status/analytics/pipeline/replication-latency.tsx
+++ b/src/features/instance/status/analytics/pipeline/replication-latency.tsx
@@ -408,7 +408,7 @@ export function ReplicationLatencyRenderer(props: SpecRegistryRendererProps): JS
marginBottom: 8,
padding: '4px 8px',
fontSize: 12,
- borderLeft: '3px solid var(--color-info, #3b82f6)',
+ borderLeft: '3px solid var(--color-accent, #3b82f6)',
background: 'color-mix(in srgb, var(--color-accent, #3b82f6) 10%, transparent)',
color: 'currentColor',
}}
From f521c672dbb7fe8889eff58e4131e1563e9f1d16 Mon Sep 17 00:00:00 2001
From: Kyle Bernhardy
Date: Mon, 13 Jul 2026 21:18:05 -0600
Subject: [PATCH 03/92] refactor(status): code-review follow-ups for the
conventions sweep
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rolls up nine verified findings from a post-merge review of the sweep:
- new --color-info token (maps to the existing --blue) restores the
replication info banner, which rendered near-invisible gray after
being pointed at --color-accent — a neutral surface token whose
defined value beat the blue fallback (runtime-verified fixed)
- shared primitives/bannerStyle.ts replaces four diverging inline
banner style copies, with fallbacks inside color-mix so banners
survive contexts where tokens don't resolve
- 14 extensionful vi.mock/vi.importActual specifier strings made
extensionless across 8 test files — empirically, a .tsx→.ts rename
silently detaches such mocks (the test passes against the real
module)
- allowImportingTsExtensions removed from both tsconfigs: with zero
extensionful imports left, tsc (TS5097) now enforces the convention
the sweep established
- 16 dead thin re-export shim files deleted; their ~15 test/panel
importers retargeted to wrapperMetrics / mqtt-traffic directly
- type Theme un-exported (zero external importers remained)
- the Monaco user-code docstring example restored to '@/counter.ts'
(end-user convention, deliberately unlike repo convention) and the
FallbackRenderer dev hints now name both spec file forms
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV
---
.../hooks/useApplicationTypeIntelligence.ts | 2 +-
.../StatusTabs.capability-gating.test.tsx | 6 ++--
.../StatusTabs.sliding-window.test.tsx | 4 +--
.../__tests__/StatusTabs.url-sync.test.tsx | 4 +--
.../pipeline/bytes-received-pipeline.test.ts | 3 +-
.../pipeline/bytes-sent-tolerance.test.ts | 5 +--
.../pipeline/cache-hit-pipeline.test.ts | 3 +-
.../cache-resolution-pipeline.test.ts | 3 +-
.../pipeline/connections-pipeline.test.ts | 3 +-
.../pipeline/cpu-usage-pipeline.test.ts | 3 +-
.../pipeline/database-size-pipeline.test.ts | 3 +-
.../__tests__/pipeline/db-pipelines.test.ts | 7 ++--
.../pipeline/duration-pipeline.test.ts | 3 +-
.../pipeline/response-200-pipeline.test.ts | 3 +-
.../pipeline/success-pipeline.test.ts | 3 +-
.../pipeline/transfer-pipeline.test.ts | 3 +-
.../components/TimeRangePicker.test.tsx | 4 +--
.../instance/status/analytics/lib/theme.ts | 2 +-
.../analytics/pipeline/bytes-received.ts | 6 ----
.../status/analytics/pipeline/bytes-sent.ts | 6 ----
.../status/analytics/pipeline/cache-hit.ts | 6 ----
.../analytics/pipeline/cache-resolution.ts | 6 ----
.../status/analytics/pipeline/connections.ts | 6 ----
.../status/analytics/pipeline/cpu-usage.ts | 6 ----
.../analytics/pipeline/database-size.ts | 6 ----
.../status/analytics/pipeline/db-message.ts | 6 ----
.../status/analytics/pipeline/db-read.ts | 6 ----
.../status/analytics/pipeline/db-write.ts | 6 ----
.../pipeline/derived/mqtt-traffic-received.ts | 3 --
.../pipeline/derived/mqtt-traffic-sent.ts | 3 --
.../status/analytics/pipeline/duration.ts | 6 ----
.../pipeline/replication-latency.tsx | 34 +++----------------
.../status/analytics/pipeline/response-200.ts | 6 ----
.../status/analytics/pipeline/success.ts | 6 ----
.../status/analytics/pipeline/transfer.ts | 6 ----
.../analytics/primitives/FallbackRenderer.tsx | 4 +--
.../analytics/primitives/HeatmapMatrix.tsx | 10 ++----
.../analytics/primitives/bannerStyle.ts | 20 +++++++++++
.../analytics/tabs/ConnectionsPanel.tsx | 3 +-
.../tabs/__tests__/ConnectionsPanel.test.tsx | 2 +-
.../tabs/__tests__/MetricPanel.test.tsx | 2 +-
.../tabs/__tests__/StorageTab.test.tsx | 2 +-
.../tabs/__tests__/tabs.smoke.test.tsx | 2 +-
src/index.css | 1 +
tsconfig.app.json | 1 -
tsconfig.node.json | 1 -
46 files changed, 74 insertions(+), 162 deletions(-)
delete mode 100644 src/features/instance/status/analytics/pipeline/bytes-received.ts
delete mode 100644 src/features/instance/status/analytics/pipeline/bytes-sent.ts
delete mode 100644 src/features/instance/status/analytics/pipeline/cache-hit.ts
delete mode 100644 src/features/instance/status/analytics/pipeline/cache-resolution.ts
delete mode 100644 src/features/instance/status/analytics/pipeline/connections.ts
delete mode 100644 src/features/instance/status/analytics/pipeline/cpu-usage.ts
delete mode 100644 src/features/instance/status/analytics/pipeline/database-size.ts
delete mode 100644 src/features/instance/status/analytics/pipeline/db-message.ts
delete mode 100644 src/features/instance/status/analytics/pipeline/db-read.ts
delete mode 100644 src/features/instance/status/analytics/pipeline/db-write.ts
delete mode 100644 src/features/instance/status/analytics/pipeline/derived/mqtt-traffic-received.ts
delete mode 100644 src/features/instance/status/analytics/pipeline/derived/mqtt-traffic-sent.ts
delete mode 100644 src/features/instance/status/analytics/pipeline/duration.ts
delete mode 100644 src/features/instance/status/analytics/pipeline/response-200.ts
delete mode 100644 src/features/instance/status/analytics/pipeline/success.ts
delete mode 100644 src/features/instance/status/analytics/pipeline/transfer.ts
create mode 100644 src/features/instance/status/analytics/primitives/bannerStyle.ts
diff --git a/src/features/instance/applications/hooks/useApplicationTypeIntelligence.ts b/src/features/instance/applications/hooks/useApplicationTypeIntelligence.ts
index 5fa1cab22..c4f89432d 100644
--- a/src/features/instance/applications/hooks/useApplicationTypeIntelligence.ts
+++ b/src/features/instance/applications/hooks/useApplicationTypeIntelligence.ts
@@ -4,7 +4,7 @@
* instead of erroring with "cannot find module".
*
* The worker only sees the models that exist. Out of the box that is a single
- * model — the open file — so `import { increment } from '@/counter'` has
+ * model — the open file — so `import { increment } from '@/counter.ts'` has
* nothing to resolve against. While a file is open this hook:
*
* 1. fetches the application's other source files and registers each as a
diff --git a/src/features/instance/status/analytics/__tests__/StatusTabs.capability-gating.test.tsx b/src/features/instance/status/analytics/__tests__/StatusTabs.capability-gating.test.tsx
index 2757430a0..fae997316 100644
--- a/src/features/instance/status/analytics/__tests__/StatusTabs.capability-gating.test.tsx
+++ b/src/features/instance/status/analytics/__tests__/StatusTabs.capability-gating.test.tsx
@@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// render, the error notice carries a Retry button, and 401/403 get an
// auth-flavored message instead of "analytics unavailable".
-vi.mock('../hooks/useAnalyticsRecords.ts', () => ({
+vi.mock('../hooks/useAnalyticsRecords', () => ({
useAnalyticsRecords: () => ({
data: [],
isLoading: false,
@@ -33,13 +33,13 @@ interface MockCapability {
retry: () => void;
}
let mockCapability: MockCapability;
-vi.mock('../hooks/useAnalyticsCapability.ts', () => ({
+vi.mock('../hooks/useAnalyticsCapability', () => ({
useAnalyticsCapability: () => mockCapability,
}));
// Overview's real body suspends on get_status; stub it so these tests assert
// "Overview renders" without plumbing status fixtures.
-vi.mock('../tabs/OverviewTab.tsx', () => ({
+vi.mock('../tabs/OverviewTab', () => ({
OverviewTab: () =>
overview content
,
}));
diff --git a/src/features/instance/status/analytics/__tests__/StatusTabs.sliding-window.test.tsx b/src/features/instance/status/analytics/__tests__/StatusTabs.sliding-window.test.tsx
index 87ca74516..9018125fa 100644
--- a/src/features/instance/status/analytics/__tests__/StatusTabs.sliding-window.test.tsx
+++ b/src/features/instance/status/analytics/__tests__/StatusTabs.sliding-window.test.tsx
@@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// hook and assert the clock in StatusTabsInner advances them.
const seenWindows: { startTime: number; endTime: number }[] = [];
-vi.mock('../hooks/useAnalyticsRecords.ts', () => ({
+vi.mock('../hooks/useAnalyticsRecords', () => ({
useAnalyticsRecords: (args: { startTime: number; endTime: number }) => {
seenWindows.push({ startTime: args.startTime, endTime: args.endTime });
return {
@@ -26,7 +26,7 @@ vi.mock('../hooks/useAnalyticsRecords.ts', () => ({
},
}));
-vi.mock('../hooks/useAnalyticsCapability.ts', () => ({
+vi.mock('../hooks/useAnalyticsCapability', () => ({
useAnalyticsCapability: () => ({
supported: true,
isLoading: false,
diff --git a/src/features/instance/status/analytics/__tests__/StatusTabs.url-sync.test.tsx b/src/features/instance/status/analytics/__tests__/StatusTabs.url-sync.test.tsx
index 362671110..edbfce7a5 100644
--- a/src/features/instance/status/analytics/__tests__/StatusTabs.url-sync.test.tsx
+++ b/src/features/instance/status/analytics/__tests__/StatusTabs.url-sync.test.tsx
@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock useAnalyticsRecords so we don't need a real Harper. Each tab still
// mounts; we're testing the URL-sync layer, not chart rendering.
-vi.mock('../hooks/useAnalyticsRecords.ts', () => ({
+vi.mock('../hooks/useAnalyticsRecords', () => ({
useAnalyticsRecords: () => ({
data: [],
isLoading: false,
@@ -20,7 +20,7 @@ vi.mock('../hooks/useAnalyticsRecords.ts', () => ({
// Capability probe must succeed so the inner StatusTabs renders (vs. the
// fallback path).
-vi.mock('../hooks/useAnalyticsCapability.ts', () => ({
+vi.mock('../hooks/useAnalyticsCapability', () => ({
useAnalyticsCapability: () => ({
supported: true,
isLoading: false,
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/bytes-received-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/bytes-received-pipeline.test.ts
index 53e2e44ea..2ce56c7e2 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/bytes-received-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/bytes-received-pipeline.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { bytesReceivedSpec } from '../../pipeline/bytes-received';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+const bytesReceivedSpec = wrapperMetrics['bytes-received'].spec;
import { runPipeline } from '../../pipeline/pipeline';
import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/bytes-sent-tolerance.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/bytes-sent-tolerance.test.ts
index e3db59fe6..5b573d6eb 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/bytes-sent-tolerance.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/bytes-sent-tolerance.test.ts
@@ -1,8 +1,9 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
-import { bytesSentSpec } from '../../pipeline/bytes-sent';
-import { mqttTrafficSentDerived } from '../../pipeline/derived/mqtt-traffic-sent';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+const bytesSentSpec = wrapperMetrics['bytes-sent'].spec;
+import { mqttTrafficSentDerived } from '../../pipeline/derived/mqtt-traffic';
import { runPipeline } from '../../pipeline/pipeline';
describe('bytes-sent tolerance', () => {
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/cache-hit-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/cache-hit-pipeline.test.ts
index b50f369de..f86f57416 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/cache-hit-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/cache-hit-pipeline.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { cacheHitSpec } from '../../pipeline/cache-hit';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+const cacheHitSpec = wrapperMetrics['cache-hit'].spec;
import { runPipeline } from '../../pipeline/pipeline';
import type { AnalyticsDataPoint } from '../../types/analytics';
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/cache-resolution-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/cache-resolution-pipeline.test.ts
index 7018341ba..fdea8016b 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/cache-resolution-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/cache-resolution-pipeline.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { cacheResolutionSpec } from '../../pipeline/cache-resolution';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+const cacheResolutionSpec = wrapperMetrics['cache-resolution'].spec;
import { runPipeline } from '../../pipeline/pipeline';
import type { AnalyticsDataPoint } from '../../types/analytics';
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/connections-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/connections-pipeline.test.ts
index 72faa49ee..4f5d05cd0 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/connections-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/connections-pipeline.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { connectionsSpec } from '../../pipeline/connections';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+const connectionsSpec = wrapperMetrics['connections'].spec;
import { runPipeline } from '../../pipeline/pipeline';
describe('connections pipeline', () => {
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/cpu-usage-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/cpu-usage-pipeline.test.ts
index 51a6a1c09..8a551a519 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/cpu-usage-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/cpu-usage-pipeline.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { cpuUsageSpec } from '../../pipeline/cpu-usage';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+const cpuUsageSpec = wrapperMetrics['cpu-usage'].spec;
import { runPipeline } from '../../pipeline/pipeline';
import { QUANTILE_FIELDS } from '../../pipeline/quantileFields';
import type { AnalyticsDataPoint, MetricSpec, TimeRange } from '../../types/analytics';
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/database-size-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/database-size-pipeline.test.ts
index 190d14519..47c48633b 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/database-size-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/database-size-pipeline.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { databaseSizeSpec } from '../../pipeline/database-size';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+const databaseSizeSpec = wrapperMetrics['database-size'].spec;
import { runPipeline } from '../../pipeline/pipeline';
import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/db-pipelines.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/db-pipelines.test.ts
index c95d0f4b1..a208ae1a5 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/db-pipelines.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/db-pipelines.test.ts
@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest';
-import { dbMessageSpec } from '../../pipeline/db-message';
-import { dbReadSpec } from '../../pipeline/db-read';
-import { dbWriteSpec } from '../../pipeline/db-write';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+const dbMessageSpec = wrapperMetrics['db-message'].spec;
+const dbReadSpec = wrapperMetrics['db-read'].spec;
+const dbWriteSpec = wrapperMetrics['db-write'].spec;
import { runPipeline } from '../../pipeline/pipeline';
import type { AnalyticsDataPoint, MetricSpec, TimeRange } from '../../types/analytics';
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/duration-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/duration-pipeline.test.ts
index 6b5005a82..598733a24 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/duration-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/duration-pipeline.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { labelWithApprox } from '../../pipeline/approxLabel';
-import { durationSpec } from '../../pipeline/duration';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+const durationSpec = wrapperMetrics['duration'].spec;
import { runPipeline } from '../../pipeline/pipeline';
import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/response-200-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/response-200-pipeline.test.ts
index 10563882d..062b0041f 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/response-200-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/response-200-pipeline.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { runPipeline } from '../../pipeline/pipeline';
-import { response200Spec } from '../../pipeline/response-200';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+const response200Spec = wrapperMetrics['response_200'].spec;
import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
const window: TimeRange = { startTime: 0, endTime: 1_000_000 };
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/success-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/success-pipeline.test.ts
index 72a627083..57df7c8c5 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/success-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/success-pipeline.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { runPipeline } from '../../pipeline/pipeline';
-import { successSpec } from '../../pipeline/success';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+const successSpec = wrapperMetrics['success'].spec;
import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
const window: TimeRange = { startTime: 0, endTime: 1_000_000 };
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/transfer-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/transfer-pipeline.test.ts
index 28102d773..6efc17b76 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/transfer-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/transfer-pipeline.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { runPipeline } from '../../pipeline/pipeline';
-import { transferSpec } from '../../pipeline/transfer';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+const transferSpec = wrapperMetrics['transfer'].spec;
import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
const window: TimeRange = { startTime: 0, endTime: 1_000_000 };
diff --git a/src/features/instance/status/analytics/components/TimeRangePicker.test.tsx b/src/features/instance/status/analytics/components/TimeRangePicker.test.tsx
index eada6fa4b..8ecc4dd5b 100644
--- a/src/features/instance/status/analytics/components/TimeRangePicker.test.tsx
+++ b/src/features/instance/status/analytics/components/TimeRangePicker.test.tsx
@@ -2,7 +2,7 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
-vi.mock('../hooks/useAnalyticsFreshness.ts', () => ({
+vi.mock('../hooks/useAnalyticsFreshness', () => ({
useAnalyticsFreshness: () => ({ isFetching: false, lastFetchedAt: null, now: 0 }),
formatRelativeUpdate: () => null,
}));
@@ -64,7 +64,7 @@ describe('TimeRangePicker', () => {
it('disables the refresh button while a fetch is in flight', async () => {
// Re-mock to return isFetching=true for this test only.
const { TimeRangePicker: PickerWithFetching } = await vi.importActual(
- './TimeRangePicker.tsx',
+ './TimeRangePicker',
);
// Use the same component but assert via aria-busy — the mock above is
// already returning isFetching=false, so for this test we verify the
diff --git a/src/features/instance/status/analytics/lib/theme.ts b/src/features/instance/status/analytics/lib/theme.ts
index b61c1ad79..5697eb6d1 100644
--- a/src/features/instance/status/analytics/lib/theme.ts
+++ b/src/features/instance/status/analytics/lib/theme.ts
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
-export type Theme = 'light' | 'dark';
+type Theme = 'light' | 'dark';
/** Resolved app theme ('light' | 'dark') for the few chart internals that
* genuinely branch in JS (heatmap color-stop interpolation, area fill
diff --git a/src/features/instance/status/analytics/pipeline/bytes-received.ts b/src/features/instance/status/analytics/pipeline/bytes-received.ts
deleted file mode 100644
index 6ab1e8067..000000000
--- a/src/features/instance/status/analytics/pipeline/bytes-received.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-// Thin re-export — this metric is defined in the `wrapperMetrics` factory
-// table (wrapperMetrics.tsx). Kept so existing import paths stay stable.
-import { wrapperMetrics } from './wrapperMetrics';
-
-export const bytesReceivedSpec = wrapperMetrics['bytes-received'].spec;
-export const BytesReceivedRenderer = wrapperMetrics['bytes-received'].Renderer;
diff --git a/src/features/instance/status/analytics/pipeline/bytes-sent.ts b/src/features/instance/status/analytics/pipeline/bytes-sent.ts
deleted file mode 100644
index a86930af2..000000000
--- a/src/features/instance/status/analytics/pipeline/bytes-sent.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-// Thin re-export — this metric is defined in the `wrapperMetrics` factory
-// table (wrapperMetrics.tsx). Kept so existing import paths stay stable.
-import { wrapperMetrics } from './wrapperMetrics';
-
-export const bytesSentSpec = wrapperMetrics['bytes-sent'].spec;
-export const BytesSentRenderer = wrapperMetrics['bytes-sent'].Renderer;
diff --git a/src/features/instance/status/analytics/pipeline/cache-hit.ts b/src/features/instance/status/analytics/pipeline/cache-hit.ts
deleted file mode 100644
index 5fc78a00c..000000000
--- a/src/features/instance/status/analytics/pipeline/cache-hit.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-// Thin re-export — this metric is defined in the `wrapperMetrics` factory
-// table (wrapperMetrics.tsx). Kept so existing import paths stay stable.
-import { wrapperMetrics } from './wrapperMetrics';
-
-export const cacheHitSpec = wrapperMetrics['cache-hit'].spec;
-export const CacheHitRenderer = wrapperMetrics['cache-hit'].Renderer;
diff --git a/src/features/instance/status/analytics/pipeline/cache-resolution.ts b/src/features/instance/status/analytics/pipeline/cache-resolution.ts
deleted file mode 100644
index b171372eb..000000000
--- a/src/features/instance/status/analytics/pipeline/cache-resolution.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-// Thin re-export — this metric is defined in the `wrapperMetrics` factory
-// table (wrapperMetrics.tsx). Kept so existing import paths stay stable.
-import { wrapperMetrics } from './wrapperMetrics';
-
-export const cacheResolutionSpec = wrapperMetrics['cache-resolution'].spec;
-export const CacheResolutionRenderer = wrapperMetrics['cache-resolution'].Renderer;
diff --git a/src/features/instance/status/analytics/pipeline/connections.ts b/src/features/instance/status/analytics/pipeline/connections.ts
deleted file mode 100644
index b5a9c2ffa..000000000
--- a/src/features/instance/status/analytics/pipeline/connections.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-// Thin re-export — this metric is defined in the `wrapperMetrics` factory
-// table (wrapperMetrics.tsx). Kept so existing import paths stay stable.
-import { wrapperMetrics } from './wrapperMetrics';
-
-export const connectionsSpec = wrapperMetrics['connections'].spec;
-export const ConnectionsRenderer = wrapperMetrics['connections'].Renderer;
diff --git a/src/features/instance/status/analytics/pipeline/cpu-usage.ts b/src/features/instance/status/analytics/pipeline/cpu-usage.ts
deleted file mode 100644
index 12443c37e..000000000
--- a/src/features/instance/status/analytics/pipeline/cpu-usage.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-// Thin re-export — this metric is defined in the `wrapperMetrics` factory
-// table (wrapperMetrics.tsx). Kept so existing import paths stay stable.
-import { wrapperMetrics } from './wrapperMetrics';
-
-export const cpuUsageSpec = wrapperMetrics['cpu-usage'].spec;
-export const CpuUsageRenderer = wrapperMetrics['cpu-usage'].Renderer;
diff --git a/src/features/instance/status/analytics/pipeline/database-size.ts b/src/features/instance/status/analytics/pipeline/database-size.ts
deleted file mode 100644
index 3c722e3fe..000000000
--- a/src/features/instance/status/analytics/pipeline/database-size.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-// Thin re-export — this metric is defined in the `wrapperMetrics` factory
-// table (wrapperMetrics.tsx). Kept so existing import paths stay stable.
-import { wrapperMetrics } from './wrapperMetrics';
-
-export const databaseSizeSpec = wrapperMetrics['database-size'].spec;
-export const DatabaseSizeRenderer = wrapperMetrics['database-size'].Renderer;
diff --git a/src/features/instance/status/analytics/pipeline/db-message.ts b/src/features/instance/status/analytics/pipeline/db-message.ts
deleted file mode 100644
index a2382b29e..000000000
--- a/src/features/instance/status/analytics/pipeline/db-message.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-// Thin re-export — this metric is defined in the `wrapperMetrics` factory
-// table (wrapperMetrics.tsx). Kept so existing import paths stay stable.
-import { wrapperMetrics } from './wrapperMetrics';
-
-export const dbMessageSpec = wrapperMetrics['db-message'].spec;
-export const DbMessageRenderer = wrapperMetrics['db-message'].Renderer;
diff --git a/src/features/instance/status/analytics/pipeline/db-read.ts b/src/features/instance/status/analytics/pipeline/db-read.ts
deleted file mode 100644
index a51fadc4f..000000000
--- a/src/features/instance/status/analytics/pipeline/db-read.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-// Thin re-export — this metric is defined in the `wrapperMetrics` factory
-// table (wrapperMetrics.tsx). Kept so existing import paths stay stable.
-import { wrapperMetrics } from './wrapperMetrics';
-
-export const dbReadSpec = wrapperMetrics['db-read'].spec;
-export const DbReadRenderer = wrapperMetrics['db-read'].Renderer;
diff --git a/src/features/instance/status/analytics/pipeline/db-write.ts b/src/features/instance/status/analytics/pipeline/db-write.ts
deleted file mode 100644
index 89c1df10d..000000000
--- a/src/features/instance/status/analytics/pipeline/db-write.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-// Thin re-export — this metric is defined in the `wrapperMetrics` factory
-// table (wrapperMetrics.tsx). Kept so existing import paths stay stable.
-import { wrapperMetrics } from './wrapperMetrics';
-
-export const dbWriteSpec = wrapperMetrics['db-write'].spec;
-export const DbWriteRenderer = wrapperMetrics['db-write'].Renderer;
diff --git a/src/features/instance/status/analytics/pipeline/derived/mqtt-traffic-received.ts b/src/features/instance/status/analytics/pipeline/derived/mqtt-traffic-received.ts
deleted file mode 100644
index 39e2a25cb..000000000
--- a/src/features/instance/status/analytics/pipeline/derived/mqtt-traffic-received.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-// Thin re-export — defined with its sent-side twin in mqtt-traffic.tsx.
-// Kept so existing import paths stay stable.
-export { mqttTrafficReceivedDerived } from './mqtt-traffic';
diff --git a/src/features/instance/status/analytics/pipeline/derived/mqtt-traffic-sent.ts b/src/features/instance/status/analytics/pipeline/derived/mqtt-traffic-sent.ts
deleted file mode 100644
index eef63a463..000000000
--- a/src/features/instance/status/analytics/pipeline/derived/mqtt-traffic-sent.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-// Thin re-export — defined with its received-side twin in mqtt-traffic.tsx.
-// Kept so existing import paths stay stable.
-export { mqttTrafficSentDerived } from './mqtt-traffic';
diff --git a/src/features/instance/status/analytics/pipeline/duration.ts b/src/features/instance/status/analytics/pipeline/duration.ts
deleted file mode 100644
index ac995f081..000000000
--- a/src/features/instance/status/analytics/pipeline/duration.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-// Thin re-export — this metric is defined in the `wrapperMetrics` factory
-// table (wrapperMetrics.tsx). Kept so existing import paths stay stable.
-import { wrapperMetrics } from './wrapperMetrics';
-
-export const durationSpec = wrapperMetrics['duration'].spec;
-export const DurationRenderer = wrapperMetrics['duration'].Renderer;
diff --git a/src/features/instance/status/analytics/pipeline/replication-latency.tsx b/src/features/instance/status/analytics/pipeline/replication-latency.tsx
index b71715aa7..b8edb2903 100644
--- a/src/features/instance/status/analytics/pipeline/replication-latency.tsx
+++ b/src/features/instance/status/analytics/pipeline/replication-latency.tsx
@@ -1,5 +1,6 @@
import { type JSX, useMemo, useState } from 'react';
import { useRovingRadioGroup } from '../hooks/useRovingRadioGroup';
+import { infoBannerStyle, warningBannerStyle } from '../primitives/bannerStyle';
import { HeatmapMatrix } from '../primitives/HeatmapMatrix';
import { LineChart } from '../primitives/LineChart';
import type {
@@ -359,15 +360,6 @@ export function ReplicationLatencyRenderer(props: SpecRegistryRendererProps): JS
const allDropped = seriesData.series.length === 0 && omittedPairsCount > 0;
const noData = seriesData.series.length === 0 && omittedPairsCount === 0;
- const warningStyle = {
- marginBottom: 8,
- padding: '4px 8px',
- fontSize: 12,
- borderLeft: '3px solid var(--color-warning, #f59e0b)',
- background: 'color-mix(in srgb, var(--color-warning) 12%, transparent)',
- color: 'currentColor',
- } as const;
-
// Banners stack at the top in normal document flow; the chart sits
// below with explicit vertical space. No flex height-juggling — the
// fixed-height LineChart was clipping/overlapping when it competed
@@ -395,7 +387,7 @@ export function ReplicationLatencyRenderer(props: SpecRegistryRendererProps): JS
key={omittedPairsCount}
role="status"
aria-atomic="true"
- style={warningStyle}
+ style={warningBannerStyle}
>
{`${omittedPairsCount} source-destination ${
pluralize(omittedPairsCount, 'pair', 'pairs')
@@ -403,16 +395,7 @@ export function ReplicationLatencyRenderer(props: SpecRegistryRendererProps): JS
)
: null}
-
+
{message}
{allDropped
@@ -420,7 +403,7 @@ export function ReplicationLatencyRenderer(props: SpecRegistryRendererProps): JS
diff --git a/src/features/instance/status/analytics/pipeline/response-200.ts b/src/features/instance/status/analytics/pipeline/response-200.ts
deleted file mode 100644
index 392e33772..000000000
--- a/src/features/instance/status/analytics/pipeline/response-200.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-// Thin re-export — this metric is defined in the `wrapperMetrics` factory
-// table (wrapperMetrics.tsx). Kept so existing import paths stay stable.
-import { wrapperMetrics } from './wrapperMetrics';
-
-export const response200Spec = wrapperMetrics['response_200'].spec;
-export const Response200Renderer = wrapperMetrics['response_200'].Renderer;
diff --git a/src/features/instance/status/analytics/pipeline/success.ts b/src/features/instance/status/analytics/pipeline/success.ts
deleted file mode 100644
index 5acb90467..000000000
--- a/src/features/instance/status/analytics/pipeline/success.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-// Thin re-export — this metric is defined in the `wrapperMetrics` factory
-// table (wrapperMetrics.tsx). Kept so existing import paths stay stable.
-import { wrapperMetrics } from './wrapperMetrics';
-
-export const successSpec = wrapperMetrics['success'].spec;
-export const SuccessRenderer = wrapperMetrics['success'].Renderer;
diff --git a/src/features/instance/status/analytics/pipeline/transfer.ts b/src/features/instance/status/analytics/pipeline/transfer.ts
deleted file mode 100644
index 2179e0453..000000000
--- a/src/features/instance/status/analytics/pipeline/transfer.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-// Thin re-export — this metric is defined in the `wrapperMetrics` factory
-// table (wrapperMetrics.tsx). Kept so existing import paths stay stable.
-import { wrapperMetrics } from './wrapperMetrics';
-
-export const transferSpec = wrapperMetrics['transfer'].spec;
-export const TransferRenderer = wrapperMetrics['transfer'].Renderer;
diff --git a/src/features/instance/status/analytics/primitives/FallbackRenderer.tsx b/src/features/instance/status/analytics/primitives/FallbackRenderer.tsx
index 01725c372..2474678c7 100644
--- a/src/features/instance/status/analytics/primitives/FallbackRenderer.tsx
+++ b/src/features/instance/status/analytics/primitives/FallbackRenderer.tsx
@@ -82,7 +82,7 @@ export function FallbackRenderer({ metric, records, hint }: Props) {
const kebab = metric.replace(/_/g, '-');
const banner = isDev
- ? `Unspecced metric "${metric}" — add a spec at src/features/instance/status/analytics/pipeline/${kebab}.tsx for a tailored view.`
+ ? `Unspecced metric "${metric}" — add a spec — a wrapperMetrics.tsx table entry, or pipeline/${kebab}.tsx (.ts if renderer-less) — for a tailored view.`
: null;
return (
@@ -122,7 +122,7 @@ export function FallbackRenderer({ metric, records, hint }: Props) {
{overflow > 0 && (
- {`… and ${overflow} more fields not shown. Add a spec at src/features/instance/status/analytics/pipeline/${kebab}.tsx to customize.`}
+ {`… and ${overflow} more fields not shown. Add a spec (wrapperMetrics.tsx entry, or pipeline/${kebab}.tsx / .ts) to customize.`}
)}
diff --git a/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx b/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx
index 5c1f3c8fb..25040812a 100644
--- a/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx
+++ b/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useSta
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
import { useResolvedTheme } from '../lib/theme';
import type { AxisSpec, HeatmapCell, HeatmapData } from '../types/analytics';
+import { warningBannerStyle } from './bannerStyle';
import { computeCellSize } from './computeCellSize';
export { computeCellSize } from './computeCellSize';
@@ -385,14 +386,7 @@ export function HeatmapMatrix({ data, title, height }: Props) {
key={data.skippedRecordsCount}
role="status"
aria-atomic="true"
- style={{
- marginBottom: 8,
- padding: '4px 8px',
- fontSize: 12,
- borderLeft: '3px solid var(--color-warning)',
- background: 'color-mix(in srgb, var(--color-warning) 12%, transparent)',
- color: 'currentColor',
- }}
+ style={warningBannerStyle}
>
{`${data.skippedRecordsCount} record(s) omitted — source node unrecognized (cluster node list may be outdated).`}
diff --git a/src/features/instance/status/analytics/primitives/bannerStyle.ts b/src/features/instance/status/analytics/primitives/bannerStyle.ts
new file mode 100644
index 000000000..139d754a8
--- /dev/null
+++ b/src/features/instance/status/analytics/primitives/bannerStyle.ts
@@ -0,0 +1,20 @@
+// The one tinted status-banner surface for status analytics — a colored
+// left border over a translucent tint of the same token. Shared like
+// tooltipStyle.ts so the copies can't drift (they did, in PR #1497).
+// Fallbacks matter: these banners can render outside index.css scope
+// (chart-export capture, isolated test DOMs), where an unresolvable var()
+// inside color-mix invalidates the whole background declaration.
+
+function bannerStyle(tokenVar: string, fallback: string, mixPercent: number) {
+ return {
+ marginBottom: 8,
+ padding: '4px 8px',
+ fontSize: 12,
+ borderLeft: `3px solid var(${tokenVar}, ${fallback})`,
+ background: `color-mix(in srgb, var(${tokenVar}, ${fallback}) ${mixPercent}%, transparent)`,
+ color: 'currentColor',
+ } as const;
+}
+
+export const warningBannerStyle = bannerStyle('--color-warning', '#f59e0b', 12);
+export const infoBannerStyle = bannerStyle('--color-info', '#3b82f6', 10);
diff --git a/src/features/instance/status/analytics/tabs/ConnectionsPanel.tsx b/src/features/instance/status/analytics/tabs/ConnectionsPanel.tsx
index 9bc9689f8..4770171cc 100644
--- a/src/features/instance/status/analytics/tabs/ConnectionsPanel.tsx
+++ b/src/features/instance/status/analytics/tabs/ConnectionsPanel.tsx
@@ -1,7 +1,8 @@
import { useMemo } from 'react';
import { useAnalyticsContext } from '../context/AnalyticsContext';
import { useAnalyticsRecords } from '../hooks/useAnalyticsRecords';
-import { ConnectionsRenderer } from '../pipeline/connections';
+import { wrapperMetrics } from '../pipeline/wrapperMetrics';
+const ConnectionsRenderer = wrapperMetrics['connections'].Renderer;
import type { AnalyticsDataPoint } from '../types/analytics';
import { PanelErrorBoundary } from './PanelErrorBoundary';
import { collectNodes, PanelCard, PanelStateOrChart } from './PanelShell';
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 007a50129..a840c9f94 100644
--- a/src/features/instance/status/analytics/tabs/__tests__/ConnectionsPanel.test.tsx
+++ b/src/features/instance/status/analytics/tabs/__tests__/ConnectionsPanel.test.tsx
@@ -2,7 +2,7 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-vi.mock('../../hooks/useAnalyticsRecords.ts', () => ({
+vi.mock('../../hooks/useAnalyticsRecords', () => ({
useAnalyticsRecords: vi.fn(),
}));
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 bcc97380a..889a5d684 100644
--- a/src/features/instance/status/analytics/tabs/__tests__/MetricPanel.test.tsx
+++ b/src/features/instance/status/analytics/tabs/__tests__/MetricPanel.test.tsx
@@ -2,7 +2,7 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-vi.mock('../../hooks/useAnalyticsRecords.ts', () => ({
+vi.mock('../../hooks/useAnalyticsRecords', () => ({
useAnalyticsRecords: vi.fn(),
}));
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 b718198dd..d30d55676 100644
--- a/src/features/instance/status/analytics/tabs/__tests__/StorageTab.test.tsx
+++ b/src/features/instance/status/analytics/tabs/__tests__/StorageTab.test.tsx
@@ -2,7 +2,7 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-vi.mock('../../hooks/useAnalyticsRecords.ts', () => ({
+vi.mock('../../hooks/useAnalyticsRecords', () => ({
useAnalyticsRecords: vi.fn(),
}));
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 ccc62820f..bcd1b9944 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
@@ -2,7 +2,7 @@
import { cleanup, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-vi.mock('../../hooks/useAnalyticsRecords.ts', () => ({
+vi.mock('../../hooks/useAnalyticsRecords', () => ({
useAnalyticsRecords: vi.fn(),
}));
diff --git a/src/index.css b/src/index.css
index 0f071985d..113dcdd23 100644
--- a/src/index.css
+++ b/src/index.css
@@ -274,6 +274,7 @@
--color-error: var(--destructive);
--color-success: var(--green);
--color-warning: var(--yellow);
+ --color-info: var(--blue);
--shadow-deep: 0 2px 12px 0 rgba(0,0,0,0.06), 0 1px 3px 0 rgba(0,0,0,0.04);
--color-sidebar-ring: var(--sidebar-ring);
diff --git a/tsconfig.app.json b/tsconfig.app.json
index cd05ded00..3b2292c96 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -17,7 +17,6 @@
],
/* Bundler mode */
"moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
diff --git a/tsconfig.node.json b/tsconfig.node.json
index 8335873fc..b0cf4fa87 100644
--- a/tsconfig.node.json
+++ b/tsconfig.node.json
@@ -14,7 +14,6 @@
],
/* Bundler mode */
"moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
From c7ea1a38cf505d9794c72d54058dd66042b4967d Mon Sep 17 00:00:00 2001
From: Kyle Bernhardy
Date: Mon, 13 Jul 2026 21:26:32 -0600
Subject: [PATCH 04/92] refactor(status): group imports above spec consts; type
bannerStyle as CSSProperties
Applied across all 13 retargeted files, not just the three the bot flagged.
Co-Authored-By: Claude Fable 5
---
.../__tests__/pipeline/bytes-received-pipeline.test.ts | 5 +++--
.../__tests__/pipeline/bytes-sent-tolerance.test.ts | 5 +++--
.../analytics/__tests__/pipeline/cache-hit-pipeline.test.ts | 5 +++--
.../__tests__/pipeline/cache-resolution-pipeline.test.ts | 5 +++--
.../__tests__/pipeline/connections-pipeline.test.ts | 3 ++-
.../analytics/__tests__/pipeline/cpu-usage-pipeline.test.ts | 5 +++--
.../__tests__/pipeline/database-size-pipeline.test.ts | 5 +++--
.../analytics/__tests__/pipeline/db-pipelines.test.ts | 5 +++--
.../analytics/__tests__/pipeline/duration-pipeline.test.ts | 5 +++--
.../__tests__/pipeline/response-200-pipeline.test.ts | 3 ++-
.../analytics/__tests__/pipeline/success-pipeline.test.ts | 3 ++-
.../analytics/__tests__/pipeline/transfer-pipeline.test.ts | 3 ++-
.../instance/status/analytics/primitives/bannerStyle.ts | 6 ++++--
.../instance/status/analytics/tabs/ConnectionsPanel.tsx | 3 ++-
14 files changed, 38 insertions(+), 23 deletions(-)
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/bytes-received-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/bytes-received-pipeline.test.ts
index 2ce56c7e2..7df33d2f7 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/bytes-received-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/bytes-received-pipeline.test.ts
@@ -1,9 +1,10 @@
import { describe, expect, it } from 'vitest';
-import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
-const bytesReceivedSpec = wrapperMetrics['bytes-received'].spec;
import { runPipeline } from '../../pipeline/pipeline';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
+const bytesReceivedSpec = wrapperMetrics['bytes-received'].spec;
+
const window: TimeRange = { startTime: 0, endTime: 1_000_000 };
// Mirrors bytes-sent-tolerance.test.ts but uses synthetic records to exercise
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/bytes-sent-tolerance.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/bytes-sent-tolerance.test.ts
index 5b573d6eb..3b60232f7 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/bytes-sent-tolerance.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/bytes-sent-tolerance.test.ts
@@ -1,10 +1,11 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
-import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
-const bytesSentSpec = wrapperMetrics['bytes-sent'].spec;
import { mqttTrafficSentDerived } from '../../pipeline/derived/mqtt-traffic';
import { runPipeline } from '../../pipeline/pipeline';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+
+const bytesSentSpec = wrapperMetrics['bytes-sent'].spec;
describe('bytes-sent tolerance', () => {
it('total bytes/sec ≈ Σ type-rates within 0.5% (global sum)', () => {
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/cache-hit-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/cache-hit-pipeline.test.ts
index f86f57416..0d5cbbe76 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/cache-hit-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/cache-hit-pipeline.test.ts
@@ -1,9 +1,10 @@
import { describe, expect, it } from 'vitest';
-import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
-const cacheHitSpec = wrapperMetrics['cache-hit'].spec;
import { runPipeline } from '../../pipeline/pipeline';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
import type { AnalyticsDataPoint } from '../../types/analytics';
+const cacheHitSpec = wrapperMetrics['cache-hit'].spec;
+
// Synthetic rows shaped like the real Harper response:
// { time, node, path, period, count, total, ratio }
// `ratio` is `total / count` — Harper precomputes it; we plot it directly.
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/cache-resolution-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/cache-resolution-pipeline.test.ts
index fdea8016b..9a3139cce 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/cache-resolution-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/cache-resolution-pipeline.test.ts
@@ -1,9 +1,10 @@
import { describe, expect, it } from 'vitest';
-import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
-const cacheResolutionSpec = wrapperMetrics['cache-resolution'].spec;
import { runPipeline } from '../../pipeline/pipeline';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
import type { AnalyticsDataPoint } from '../../types/analytics';
+const cacheResolutionSpec = wrapperMetrics['cache-resolution'].spec;
+
// Synthetic rows shaped like the real Harper response. The real schema
// carries a per-bucket count-weighted distribution for each path; the
// pipeline plots p95 by default and the user can swap quantiles via the
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/connections-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/connections-pipeline.test.ts
index 4f5d05cd0..309bf1404 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/connections-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/connections-pipeline.test.ts
@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest';
+import { runPipeline } from '../../pipeline/pipeline';
import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+
const connectionsSpec = wrapperMetrics['connections'].spec;
-import { runPipeline } from '../../pipeline/pipeline';
describe('connections pipeline', () => {
// connections is a multi-source merge (mqtt-connections + ws-connections)
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/cpu-usage-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/cpu-usage-pipeline.test.ts
index 8a551a519..43ae7c1e7 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/cpu-usage-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/cpu-usage-pipeline.test.ts
@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest';
-import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
-const cpuUsageSpec = wrapperMetrics['cpu-usage'].spec;
import { runPipeline } from '../../pipeline/pipeline';
import { QUANTILE_FIELDS } from '../../pipeline/quantileFields';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
import type { AnalyticsDataPoint, MetricSpec, TimeRange } from '../../types/analytics';
+const cpuUsageSpec = wrapperMetrics['cpu-usage'].spec;
+
const window: TimeRange = { startTime: 0, endTime: 1_000_000 };
// cpu-usage moved from the prior 3-panel small-multiples shape to a
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/database-size-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/database-size-pipeline.test.ts
index 47c48633b..aea5c8564 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/database-size-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/database-size-pipeline.test.ts
@@ -1,9 +1,10 @@
import { describe, expect, it } from 'vitest';
-import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
-const databaseSizeSpec = wrapperMetrics['database-size'].spec;
import { runPipeline } from '../../pipeline/pipeline';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
+const databaseSizeSpec = wrapperMetrics['database-size'].spec;
+
const window: TimeRange = { startTime: 0, endTime: 10_000_000 };
describe('database-size spec', () => {
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/db-pipelines.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/db-pipelines.test.ts
index a208ae1a5..11f0d4cc9 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/db-pipelines.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/db-pipelines.test.ts
@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest';
+import { runPipeline } from '../../pipeline/pipeline';
import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
+import type { AnalyticsDataPoint, MetricSpec, TimeRange } from '../../types/analytics';
+
const dbMessageSpec = wrapperMetrics['db-message'].spec;
const dbReadSpec = wrapperMetrics['db-read'].spec;
const dbWriteSpec = wrapperMetrics['db-write'].spec;
-import { runPipeline } from '../../pipeline/pipeline';
-import type { AnalyticsDataPoint, MetricSpec, TimeRange } from '../../types/analytics';
const window: TimeRange = { startTime: 0, endTime: 1_000_000 };
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/duration-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/duration-pipeline.test.ts
index 598733a24..ad1330f0f 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/duration-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/duration-pipeline.test.ts
@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest';
import { labelWithApprox } from '../../pipeline/approxLabel';
-import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
-const durationSpec = wrapperMetrics['duration'].spec;
import { runPipeline } from '../../pipeline/pipeline';
+import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
+const durationSpec = wrapperMetrics['duration'].spec;
+
const window: TimeRange = { startTime: 0, endTime: 1_000_000 };
// 11 distinct paths → top 10 + Other bucket (when otherBucket: true).
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/response-200-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/response-200-pipeline.test.ts
index 062b0041f..39385096e 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/response-200-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/response-200-pipeline.test.ts
@@ -1,9 +1,10 @@
import { describe, expect, it } from 'vitest';
import { runPipeline } from '../../pipeline/pipeline';
import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
-const response200Spec = wrapperMetrics['response_200'].spec;
import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
+const response200Spec = wrapperMetrics['response_200'].spec;
+
const window: TimeRange = { startTime: 0, endTime: 1_000_000 };
describe('response_200 spec — runPipeline', () => {
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/success-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/success-pipeline.test.ts
index 57df7c8c5..36cc9f4de 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/success-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/success-pipeline.test.ts
@@ -1,9 +1,10 @@
import { describe, expect, it } from 'vitest';
import { runPipeline } from '../../pipeline/pipeline';
import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
-const successSpec = wrapperMetrics['success'].spec;
import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
+const successSpec = wrapperMetrics['success'].spec;
+
const window: TimeRange = { startTime: 0, endTime: 1_000_000 };
describe('success spec — runPipeline', () => {
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/transfer-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/transfer-pipeline.test.ts
index 6efc17b76..3bb909072 100644
--- a/src/features/instance/status/analytics/__tests__/pipeline/transfer-pipeline.test.ts
+++ b/src/features/instance/status/analytics/__tests__/pipeline/transfer-pipeline.test.ts
@@ -1,9 +1,10 @@
import { describe, expect, it } from 'vitest';
import { runPipeline } from '../../pipeline/pipeline';
import { wrapperMetrics } from '../../pipeline/wrapperMetrics';
-const transferSpec = wrapperMetrics['transfer'].spec;
import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
+const transferSpec = wrapperMetrics['transfer'].spec;
+
const window: TimeRange = { startTime: 0, endTime: 1_000_000 };
describe('transfer spec — runPipeline', () => {
diff --git a/src/features/instance/status/analytics/primitives/bannerStyle.ts b/src/features/instance/status/analytics/primitives/bannerStyle.ts
index 139d754a8..0841a9f02 100644
--- a/src/features/instance/status/analytics/primitives/bannerStyle.ts
+++ b/src/features/instance/status/analytics/primitives/bannerStyle.ts
@@ -1,3 +1,5 @@
+import type { CSSProperties } from 'react';
+
// The one tinted status-banner surface for status analytics — a colored
// left border over a translucent tint of the same token. Shared like
// tooltipStyle.ts so the copies can't drift (they did, in PR #1497).
@@ -5,7 +7,7 @@
// (chart-export capture, isolated test DOMs), where an unresolvable var()
// inside color-mix invalidates the whole background declaration.
-function bannerStyle(tokenVar: string, fallback: string, mixPercent: number) {
+function bannerStyle(tokenVar: string, fallback: string, mixPercent: number): CSSProperties {
return {
marginBottom: 8,
padding: '4px 8px',
@@ -13,7 +15,7 @@ function bannerStyle(tokenVar: string, fallback: string, mixPercent: number) {
borderLeft: `3px solid var(${tokenVar}, ${fallback})`,
background: `color-mix(in srgb, var(${tokenVar}, ${fallback}) ${mixPercent}%, transparent)`,
color: 'currentColor',
- } as const;
+ };
}
export const warningBannerStyle = bannerStyle('--color-warning', '#f59e0b', 12);
diff --git a/src/features/instance/status/analytics/tabs/ConnectionsPanel.tsx b/src/features/instance/status/analytics/tabs/ConnectionsPanel.tsx
index 4770171cc..7b9110c6d 100644
--- a/src/features/instance/status/analytics/tabs/ConnectionsPanel.tsx
+++ b/src/features/instance/status/analytics/tabs/ConnectionsPanel.tsx
@@ -2,11 +2,12 @@ import { useMemo } from 'react';
import { useAnalyticsContext } from '../context/AnalyticsContext';
import { useAnalyticsRecords } from '../hooks/useAnalyticsRecords';
import { wrapperMetrics } from '../pipeline/wrapperMetrics';
-const ConnectionsRenderer = wrapperMetrics['connections'].Renderer;
import type { AnalyticsDataPoint } from '../types/analytics';
import { PanelErrorBoundary } from './PanelErrorBoundary';
import { collectNodes, PanelCard, PanelStateOrChart } from './PanelShell';
+const ConnectionsRenderer = wrapperMetrics['connections'].Renderer;
+
/** Some Harper builds split active-session telemetry across two metrics:
* `mqtt-connections` (active MQTT sessions) and `ws-connections` (active
* WebSocket sessions). Each row carries a `connections` field but no
From 0600a356bc765a72936cada6aa80144f16f1e6f8 Mon Sep 17 00:00:00 2001
From: Kyle Bernhardy
Date: Tue, 14 Jul 2026 14:05:07 -0600
Subject: [PATCH 05/92] refactor(theme): move useResolvedTheme to src/hooks
beside the app theme hooks
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review: the hook is not analytics-specific — it resolves the ThemeProvider's
.dark class for any JS code that must branch on effective theme. Chart-token
lookup (getChartColors) stays feature-local in analytics/lib/theme.ts.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV
---
.../instance/status/analytics/lib/theme.ts | 30 -------------------
.../analytics/primitives/HeatmapMatrix.tsx | 2 +-
.../analytics/primitives/StackedAreaChart.tsx | 3 +-
src/hooks/useResolvedTheme.ts | 29 ++++++++++++++++++
4 files changed, 32 insertions(+), 32 deletions(-)
create mode 100644 src/hooks/useResolvedTheme.ts
diff --git a/src/features/instance/status/analytics/lib/theme.ts b/src/features/instance/status/analytics/lib/theme.ts
index 5697eb6d1..7d86e7a41 100644
--- a/src/features/instance/status/analytics/lib/theme.ts
+++ b/src/features/instance/status/analytics/lib/theme.ts
@@ -1,33 +1,3 @@
-import { useEffect, useState } from 'react';
-
-type Theme = 'light' | 'dark';
-
-/** Resolved app theme ('light' | 'dark') for the few chart internals that
- * genuinely branch in JS (heatmap color-stop interpolation, area fill
- * opacity). The app's ThemeProvider (src/hooks/useTheme) already resolves
- * the user's explicit choice vs. OS preference by toggling the `.dark`
- * class on — reading that class keeps charts in lockstep with every
- * other themed surface without re-deriving preference + media-query state.
- * Everything color-related that CAN be a CSS token should use the
- * `--chart-*` vars instead and never touch this hook. */
-export function useResolvedTheme(): Theme {
- const [dark, setDark] = useState(() =>
- typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
- );
- useEffect(() => {
- const root = document.documentElement;
- // Re-sync on mount: a theme toggle between the lazy initializer and
- // the observer attaching would otherwise be missed.
- setDark(root.classList.contains('dark'));
- const observer = new MutationObserver(() => {
- setDark(root.classList.contains('dark'));
- });
- observer.observe(root, { attributes: true, attributeFilter: ['class'] });
- return () => observer.disconnect();
- }, []);
- return dark ? 'dark' : 'light';
-}
-
/** Reads the studio chart-surface CSS tokens defined in src/index.css.
* All charts render inside a `Card`, so axis/grid colors resolve against
* `--card`, not the brand-purple `--background`. The hex defaults here are
diff --git a/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx b/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx
index 25040812a..1e33aec1c 100644
--- a/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx
+++ b/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx
@@ -1,7 +1,7 @@
+import { useResolvedTheme } from '@/hooks/useResolvedTheme';
import { formatValue } from '@/lib/formatValue';
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
-import { useResolvedTheme } from '../lib/theme';
import type { AxisSpec, HeatmapCell, HeatmapData } from '../types/analytics';
import { warningBannerStyle } from './bannerStyle';
import { computeCellSize } from './computeCellSize';
diff --git a/src/features/instance/status/analytics/primitives/StackedAreaChart.tsx b/src/features/instance/status/analytics/primitives/StackedAreaChart.tsx
index b83f9f9bb..087113ff7 100644
--- a/src/features/instance/status/analytics/primitives/StackedAreaChart.tsx
+++ b/src/features/instance/status/analytics/primitives/StackedAreaChart.tsx
@@ -1,8 +1,9 @@
+import { useResolvedTheme } from '@/hooks/useResolvedTheme';
import { formatValue } from '@/lib/formatValue';
import { useMemo } from 'react';
import { Area, AreaChart, CartesianGrid, Legend, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { NODE_PALETTE } from '../lib/nodeColors';
-import { getChartColors, useResolvedTheme } from '../lib/theme';
+import { getChartColors } from '../lib/theme';
import { formatAxisTick, formatTooltipTime } from '../lib/time';
import type { AxisFormatter, AxisSpec, SeriesData } from '../types/analytics';
import { sortByMagnitude } from './sortByMagnitude';
diff --git a/src/hooks/useResolvedTheme.ts b/src/hooks/useResolvedTheme.ts
new file mode 100644
index 000000000..eb0796750
--- /dev/null
+++ b/src/hooks/useResolvedTheme.ts
@@ -0,0 +1,29 @@
+import { useEffect, useState } from 'react';
+
+type ResolvedTheme = 'light' | 'dark';
+
+/** Resolved app theme ('light' | 'dark') for the few code paths that
+ * genuinely branch in JS (e.g. chart color-stop interpolation, area fill
+ * opacity). The ThemeProvider (src/hooks/useTheme) already resolves the
+ * user's explicit choice vs. OS preference by toggling the `.dark` class
+ * on — reading that class keeps consumers in lockstep with every
+ * other themed surface without re-deriving preference + media-query state.
+ * Everything color-related that CAN be a CSS token should use CSS vars
+ * instead and never touch this hook. */
+export function useResolvedTheme(): ResolvedTheme {
+ const [dark, setDark] = useState(() =>
+ typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
+ );
+ useEffect(() => {
+ const root = document.documentElement;
+ // Re-sync on mount: a theme toggle between the lazy initializer and
+ // the observer attaching would otherwise be missed.
+ setDark(root.classList.contains('dark'));
+ const observer = new MutationObserver(() => {
+ setDark(root.classList.contains('dark'));
+ });
+ observer.observe(root, { attributes: true, attributeFilter: ['class'] });
+ return () => observer.disconnect();
+ }, []);
+ return dark ? 'dark' : 'light';
+}
From 4011813338d8240c65550141f553e619f8d6f32c Mon Sep 17 00:00:00 2001
From: Dawson Toth
Date: Tue, 14 Jul 2026 10:45:48 -0400
Subject: [PATCH 06/92] fix(applications): bound type-acquisition extra libs so
the Monaco worker can't OOM
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Automatic Type Acquisition streamed every `@types` declaration into the
language worker via `addExtraLib` with no size cap, no aggregate budget, and a
module-level dedupe set that never resets. With `setEagerModelSync(true)` those
extra libs are structured-cloned to the worker just like models, but the model
guards (`MAX_WORKER_MODEL_CHARS`, `MAX_APPLICATION_MODEL_CHARS_TOTAL`, the
150-model ceiling) don't see them — extra libs aren't `getModels()` entries and
are never swept on project switch — so they accumulate monotonically across
every project opened in a session until the clone buffer overflows with
"DataCloneError: Data cannot be cloned, out of memory." followed by "FAILED to
post message to worker", flooding the session and wedging the worker.
RUM caught this recurring in prod on 2026-07-13 (1,560 combined occurrences,
~400 per affected session), a regression of #1370 through the one worker-clone
path its fixes never covered.
Add `MAX_EXTRA_LIB_CHARS_TOTAL` (8 MB) and `canAdmitExtraLib`, and enforce them
in `receivedFile`: skip any single declaration over the per-file limit and stop
admitting once the session total is spent. A rejected lib degrades that package
to "cannot find module" — the same tradeoff `selectFilesWithinModelBudget`
already makes for skipped sibling models — instead of crashing the tab.
Closes #1499
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../harper-language/typeAcquisition.ts | 19 ++++++++++++
src/lib/monaco/workerLimits.test.ts | 28 +++++++++++++++++
src/lib/monaco/workerLimits.ts | 31 +++++++++++++++++++
3 files changed, 78 insertions(+)
create mode 100644 src/lib/monaco/workerLimits.test.ts
diff --git a/src/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition.ts b/src/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition.ts
index a62fef921..6683c066a 100644
--- a/src/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition.ts
+++ b/src/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition.ts
@@ -16,6 +16,7 @@
* blocked CDN, unknown package — is swallowed so it can never break editing.
*/
import { typescript } from '@/lib/monaco/languageServices';
+import { canAdmitExtraLib } from '@/lib/monaco/workerLimits';
/** node builtins are not on npm; their types come from `@types/node` (not acquired here). */
const NODE_BUILTINS = new Set([
@@ -107,6 +108,14 @@ function createImportScannerShim(): unknown {
let runnerPromise: Promise<(source: string) => Promise> | undefined;
const acquiredPaths = new Set();
+/**
+ * Running total of chars handed to the worker as extra libs. Session-lifetime
+ * and monotonic — it mirrors the extra libs actually live on the shared
+ * `typescriptDefaults`/`javascriptDefaults` singletons, which are never removed
+ * — so once the budget is spent it stays spent, which is exactly the backstop
+ * against unbounded cross-project accumulation (HarperFast/studio#1499).
+ */
+let acquiredChars = 0;
function getRunner(): Promise<(source: string) => Promise> {
if (!runnerPromise) {
@@ -120,7 +129,17 @@ function getRunner(): Promise<(source: string) => Promise> {
if (acquiredPaths.has(path)) {
return;
}
+ // Mark it seen even when rejected, so a skipped lib isn't
+ // reconsidered on every subsequent acquisition pass.
acquiredPaths.add(path);
+ // Extra libs are eagerly cloned to the language worker
+ // (setEagerModelSync) and never swept; bound the total so a
+ // large or deep dependency graph — or a long multi-project
+ // session — can't OOM the worker's clone buffer (#1499).
+ if (!canAdmitExtraLib(acquiredChars, code.length)) {
+ return;
+ }
+ acquiredChars += code.length;
const uri = `file://${path}`;
typescriptDefaults.addExtraLib(code, uri);
javascriptDefaults.addExtraLib(code, uri);
diff --git a/src/lib/monaco/workerLimits.test.ts b/src/lib/monaco/workerLimits.test.ts
new file mode 100644
index 000000000..d91f0ca78
--- /dev/null
+++ b/src/lib/monaco/workerLimits.test.ts
@@ -0,0 +1,28 @@
+import { canAdmitExtraLib, MAX_EXTRA_LIB_CHARS_TOTAL, MAX_WORKER_MODEL_CHARS } from '@/lib/monaco/workerLimits';
+import { describe, expect, it } from 'vitest';
+
+describe('canAdmitExtraLib (HarperFast/studio#1499)', () => {
+ it('admits a normal declaration when the budget is empty', () => {
+ expect(canAdmitExtraLib(0, 10_000)).toBe(true);
+ });
+
+ it('admits a declaration that exactly fills the remaining budget', () => {
+ expect(canAdmitExtraLib(MAX_EXTRA_LIB_CHARS_TOTAL - 100, 100)).toBe(true);
+ });
+
+ it('rejects a single oversized declaration even into an empty budget', () => {
+ // A lib past the per-file worker limit would itself risk the clone crash.
+ expect(canAdmitExtraLib(0, MAX_WORKER_MODEL_CHARS + 1)).toBe(false);
+ });
+
+ it('rejects a declaration that would push the running total past the budget', () => {
+ // The accumulation case: each file is individually fine, but the session
+ // total has reached the cap — the next one must be turned away rather than
+ // cloned to the worker.
+ expect(canAdmitExtraLib(MAX_EXTRA_LIB_CHARS_TOTAL - 50, 51)).toBe(false);
+ });
+
+ it('rejects everything once the budget is already spent', () => {
+ expect(canAdmitExtraLib(MAX_EXTRA_LIB_CHARS_TOTAL, 1)).toBe(false);
+ });
+});
diff --git a/src/lib/monaco/workerLimits.ts b/src/lib/monaco/workerLimits.ts
index ee1d84164..0df1e3bb1 100644
--- a/src/lib/monaco/workerLimits.ts
+++ b/src/lib/monaco/workerLimits.ts
@@ -16,3 +16,34 @@
* payloads that add nothing to highlighting or IntelliSense.
*/
export const MAX_WORKER_MODEL_CHARS = 512 * 1024;
+
+/**
+ * Total-character budget for automatically-acquired `@types` declarations
+ * (`addExtraLib`), across the whole session.
+ *
+ * `setEagerModelSync(true)` clones *both* models *and* every extra lib to the
+ * language worker over `postMessage`. The model budget
+ * (`MAX_APPLICATION_MODEL_CHARS_TOTAL`) only sees `file:///` models — extra libs
+ * are invisible to it — and, unlike models, extra libs are never swept when the
+ * open project changes, so Automatic Type Acquisition accumulates them
+ * monotonically across every project opened in a session. Left unbounded, that
+ * volume eventually overflows the clone buffer and crashes the worker with
+ * "DataCloneError: Data cannot be cloned, out of memory." (HarperFast/studio#1499,
+ * a recurrence of #1370 through the extra-lib path the model guards don't cover).
+ *
+ * 8 MB comfortably fits a typical app's real dependency `@types` (react,
+ * react-dom, and friends) while still capping the pathological accumulation.
+ */
+export const MAX_EXTRA_LIB_CHARS_TOTAL = 8 * 1024 * 1024;
+
+/**
+ * Whether an acquired declaration file may be handed to the worker as an extra
+ * lib, given the total chars already admitted this session. Rejects a single
+ * oversized declaration and anything that would push the running total past the
+ * budget. A rejected lib degrades that package to "cannot find module" — the
+ * same acceptable degradation `selectFilesWithinModelBudget` chose for skipped
+ * sibling models — which beats crashing the worker for the whole tab.
+ */
+export function canAdmitExtraLib(currentTotalChars: number, incomingChars: number): boolean {
+ return incomingChars <= MAX_WORKER_MODEL_CHARS && currentTotalChars + incomingChars <= MAX_EXTRA_LIB_CHARS_TOTAL;
+}
From fc7acd255877d0c51fbc0b100342ec95e7173dab Mon Sep 17 00:00:00 2001
From: Dawson Toth
Date: Tue, 14 Jul 2026 10:51:04 -0400
Subject: [PATCH 07/92] perf(applications): skip type acquisition once the
extra-lib budget is spent
Short-circuit `acquireApplicationTypes` when `acquiredChars` has already reached
`MAX_EXTRA_LIB_CHARS_TOTAL`: the budget is monotonic and never reclaimed, so the
acquisition engine would otherwise walk the CDN and parse declarations only for
`receivedFile` to discard every one. Avoids the wasted network/CPU once the cap
is hit (per PR review).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../TextEditorView/harper-language/typeAcquisition.ts | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/src/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition.ts b/src/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition.ts
index 6683c066a..8061296a0 100644
--- a/src/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition.ts
+++ b/src/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition.ts
@@ -16,7 +16,7 @@
* blocked CDN, unknown package — is swallowed so it can never break editing.
*/
import { typescript } from '@/lib/monaco/languageServices';
-import { canAdmitExtraLib } from '@/lib/monaco/workerLimits';
+import { canAdmitExtraLib, MAX_EXTRA_LIB_CHARS_TOTAL } from '@/lib/monaco/workerLimits';
/** node builtins are not on npm; their types come from `@types/node` (not acquired here). */
const NODE_BUILTINS = new Set([
@@ -159,6 +159,13 @@ export async function acquireApplicationTypes(sources: string[]): Promise
if (sources.length === 0) {
return;
}
+ // The budget is monotonic and never reclaimed, so once it is spent the
+ // acquisition engine would walk the CDN and parse declarations only for
+ // `receivedFile` to discard every one. Skip the whole pass — including its
+ // network requests — rather than pay for work that can't land.
+ if (acquiredChars >= MAX_EXTRA_LIB_CHARS_TOTAL) {
+ return;
+ }
try {
const run = await getRunner();
// One pass: the shim scans the combined source for every import at once.
From c72fdd75ffbf746080c1532251bcb474d9d1f0c4 Mon Sep 17 00:00:00 2001
From: Dawson Toth
Date: Tue, 14 Jul 2026 14:53:22 -0400
Subject: [PATCH 08/92] refactor(applications): seal the extra-lib budget on
aggregate exhaustion, log drops
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Address PR review (kriszyp):
- The full-pass early-return was effectively dead: `acquiredChars` only advanced
on an *admitted* file, so `acquiredChars >= MAX` was reachable only by an
exact-fit landing and the network-churn skip practically never engaged. Move
the accounting into an `ExtraLibBudget` that *seals* (`isSpent`) on the first
aggregate overflow — a state that persists — so the early-return actually
fires in the degraded long-session case it was written for.
- Silent drops now leave a breadcrumb: a one-time `console.warn` on the
exhaustion transition and a `console.debug` per oversized declaration, so
"why did IntelliSense stop resolving this package" is diagnosable without a
repro.
- The stateful path is now tested: `ExtraLibBudget` is Monaco-free and unit
tests cover accumulation, exact-fit-without-sealing, per-file oversize
(which does not seal), the sealing overflow, and rejection-once-sealed.
No behavior change to the OOM invariant: `addExtraLib` remains hard-gated by
`admit()`, so the session total still can't exceed the cap.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../harper-language/typeAcquisition.ts | 31 ++++++---
src/lib/monaco/workerLimits.test.ts | 69 ++++++++++++++-----
src/lib/monaco/workerLimits.ts | 66 ++++++++++++++++--
3 files changed, 132 insertions(+), 34 deletions(-)
diff --git a/src/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition.ts b/src/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition.ts
index 8061296a0..330249102 100644
--- a/src/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition.ts
+++ b/src/features/instance/applications/components/TextEditorView/harper-language/typeAcquisition.ts
@@ -16,7 +16,7 @@
* blocked CDN, unknown package — is swallowed so it can never break editing.
*/
import { typescript } from '@/lib/monaco/languageServices';
-import { canAdmitExtraLib, MAX_EXTRA_LIB_CHARS_TOTAL } from '@/lib/monaco/workerLimits';
+import { ExtraLibBudget } from '@/lib/monaco/workerLimits';
/** node builtins are not on npm; their types come from `@types/node` (not acquired here). */
const NODE_BUILTINS = new Set([
@@ -109,13 +109,11 @@ function createImportScannerShim(): unknown {
let runnerPromise: Promise<(source: string) => Promise> | undefined;
const acquiredPaths = new Set();
/**
- * Running total of chars handed to the worker as extra libs. Session-lifetime
- * and monotonic — it mirrors the extra libs actually live on the shared
- * `typescriptDefaults`/`javascriptDefaults` singletons, which are never removed
- * — so once the budget is spent it stays spent, which is exactly the backstop
- * against unbounded cross-project accumulation (HarperFast/studio#1499).
+ * Bounds the chars handed to the worker as extra libs. Session-lifetime and
+ * never reclaimed — the backstop against unbounded cross-project accumulation
+ * that would otherwise OOM the language worker (HarperFast/studio#1499).
*/
-let acquiredChars = 0;
+const extraLibBudget = new ExtraLibBudget();
function getRunner(): Promise<(source: string) => Promise> {
if (!runnerPromise) {
@@ -136,10 +134,21 @@ function getRunner(): Promise<(source: string) => Promise> {
// (setEagerModelSync) and never swept; bound the total so a
// large or deep dependency graph — or a long multi-project
// session — can't OOM the worker's clone buffer (#1499).
- if (!canAdmitExtraLib(acquiredChars, code.length)) {
+ const { admitted, justExhausted, oversize } = extraLibBudget.admit(code.length);
+ if (!admitted) {
+ // Leave a breadcrumb: without one, a user whose IntelliSense
+ // quietly stops resolving a package has no signal why.
+ if (justExhausted) {
+ console.warn(
+ '[harper] type acquisition: extra-lib budget exhausted; further packages will report "cannot find module" in this tab',
+ );
+ } else if (oversize) {
+ console.debug(
+ `[harper] type acquisition: skipped oversized declaration ${path} (${code.length} chars)`,
+ );
+ }
return;
}
- acquiredChars += code.length;
const uri = `file://${path}`;
typescriptDefaults.addExtraLib(code, uri);
javascriptDefaults.addExtraLib(code, uri);
@@ -159,11 +168,11 @@ export async function acquireApplicationTypes(sources: string[]): Promise
if (sources.length === 0) {
return;
}
- // The budget is monotonic and never reclaimed, so once it is spent the
+ // Once the aggregate budget is spent it is never reclaimed, so the
// acquisition engine would walk the CDN and parse declarations only for
// `receivedFile` to discard every one. Skip the whole pass — including its
// network requests — rather than pay for work that can't land.
- if (acquiredChars >= MAX_EXTRA_LIB_CHARS_TOTAL) {
+ if (extraLibBudget.isSpent) {
return;
}
try {
diff --git a/src/lib/monaco/workerLimits.test.ts b/src/lib/monaco/workerLimits.test.ts
index d91f0ca78..33ae328b1 100644
--- a/src/lib/monaco/workerLimits.test.ts
+++ b/src/lib/monaco/workerLimits.test.ts
@@ -1,28 +1,65 @@
-import { canAdmitExtraLib, MAX_EXTRA_LIB_CHARS_TOTAL, MAX_WORKER_MODEL_CHARS } from '@/lib/monaco/workerLimits';
+import { ExtraLibBudget, MAX_EXTRA_LIB_CHARS_TOTAL, MAX_WORKER_MODEL_CHARS } from '@/lib/monaco/workerLimits';
import { describe, expect, it } from 'vitest';
-describe('canAdmitExtraLib (HarperFast/studio#1499)', () => {
- it('admits a normal declaration when the budget is empty', () => {
- expect(canAdmitExtraLib(0, 10_000)).toBe(true);
+/**
+ * Admit declarations up to exactly the aggregate cap, using the largest single
+ * admissible file (`MAX_WORKER_MODEL_CHARS`) so it takes as few calls as
+ * possible. Asserts every one lands and returns the budget at the brink — one
+ * more char will overflow it.
+ */
+function fillToCap(budget: ExtraLibBudget): void {
+ let total = 0;
+ while (total + MAX_WORKER_MODEL_CHARS <= MAX_EXTRA_LIB_CHARS_TOTAL) {
+ expect(budget.admit(MAX_WORKER_MODEL_CHARS).admitted).toBe(true);
+ total += MAX_WORKER_MODEL_CHARS;
+ }
+ const remainder = MAX_EXTRA_LIB_CHARS_TOTAL - total;
+ if (remainder > 0) {
+ expect(budget.admit(remainder).admitted).toBe(true);
+ }
+}
+
+describe('ExtraLibBudget (HarperFast/studio#1499)', () => {
+ it('admits normal declarations and accumulates their size without sealing', () => {
+ const budget = new ExtraLibBudget();
+ expect(budget.admit(10_000)).toEqual({ admitted: true, justExhausted: false, oversize: false });
+ expect(budget.admit(10_000)).toEqual({ admitted: true, justExhausted: false, oversize: false });
+ expect(budget.isSpent).toBe(false);
});
- it('admits a declaration that exactly fills the remaining budget', () => {
- expect(canAdmitExtraLib(MAX_EXTRA_LIB_CHARS_TOTAL - 100, 100)).toBe(true);
+ it('admits declarations up to exactly the aggregate cap without sealing', () => {
+ const budget = new ExtraLibBudget();
+ fillToCap(budget);
+ // The cap is a ceiling that can be reached, not crossed — reaching it exactly
+ // does not spend the budget; only an overflow does.
+ expect(budget.isSpent).toBe(false);
});
- it('rejects a single oversized declaration even into an empty budget', () => {
- // A lib past the per-file worker limit would itself risk the clone crash.
- expect(canAdmitExtraLib(0, MAX_WORKER_MODEL_CHARS + 1)).toBe(false);
+ it('rejects a single oversized declaration without spending the budget', () => {
+ const budget = new ExtraLibBudget();
+ expect(budget.admit(MAX_WORKER_MODEL_CHARS + 1)).toEqual({
+ admitted: false,
+ justExhausted: false,
+ oversize: true,
+ });
+ expect(budget.isSpent).toBe(false);
+ // A file within the per-file limit still fits — an oversize file is not the aggregate cap.
+ expect(budget.admit(10_000).admitted).toBe(true);
});
- it('rejects a declaration that would push the running total past the budget', () => {
- // The accumulation case: each file is individually fine, but the session
- // total has reached the cap — the next one must be turned away rather than
- // cloned to the worker.
- expect(canAdmitExtraLib(MAX_EXTRA_LIB_CHARS_TOTAL - 50, 51)).toBe(false);
+ it('seals the budget on the first aggregate overflow and flags the transition once', () => {
+ const budget = new ExtraLibBudget();
+ fillToCap(budget);
+ expect(budget.admit(1)).toEqual({ admitted: false, justExhausted: true, oversize: false });
+ expect(budget.isSpent).toBe(true);
});
- it('rejects everything once the budget is already spent', () => {
- expect(canAdmitExtraLib(MAX_EXTRA_LIB_CHARS_TOTAL, 1)).toBe(false);
+ it('rejects everything once sealed — even a declaration that would have fit — without re-flagging', () => {
+ const budget = new ExtraLibBudget();
+ fillToCap(budget);
+ budget.admit(1); // overflows and seals (justExhausted: true)
+ // A tiny file is now turned away and the exhaustion transition is not re-reported.
+ expect(budget.admit(1)).toEqual({ admitted: false, justExhausted: false, oversize: false });
+ expect(budget.isSpent).toBe(true);
});
});
diff --git a/src/lib/monaco/workerLimits.ts b/src/lib/monaco/workerLimits.ts
index 0df1e3bb1..c70016768 100644
--- a/src/lib/monaco/workerLimits.ts
+++ b/src/lib/monaco/workerLimits.ts
@@ -36,14 +36,66 @@ export const MAX_WORKER_MODEL_CHARS = 512 * 1024;
*/
export const MAX_EXTRA_LIB_CHARS_TOTAL = 8 * 1024 * 1024;
+/** The outcome of offering one acquired declaration to {@link ExtraLibBudget}. */
+export interface ExtraLibAdmission {
+ /** Whether the declaration was admitted (and the running total advanced). */
+ admitted: boolean;
+ /**
+ * Set only on the single call that first exhausts the aggregate budget, so
+ * the caller can log the transition once and short-circuit later passes.
+ */
+ justExhausted: boolean;
+ /**
+ * Set when the declaration was rejected solely because it exceeds the
+ * per-file limit — smaller declarations can still be admitted afterward, so
+ * this does not spend the budget.
+ */
+ oversize: boolean;
+}
+
/**
- * Whether an acquired declaration file may be handed to the worker as an extra
- * lib, given the total chars already admitted this session. Rejects a single
- * oversized declaration and anything that would push the running total past the
- * budget. A rejected lib degrades that package to "cannot find module" — the
- * same acceptable degradation `selectFilesWithinModelBudget` chose for skipped
+ * Session-lifetime accounting for the `@types` declarations handed to the
+ * language worker as extra libs (`addExtraLib`). `setEagerModelSync(true)`
+ * clones every extra lib to the worker, the model budget can't see them, and
+ * they are never swept on project switch — so this is the sole bound on their
+ * unbounded, cross-project accumulation (HarperFast/studio#1499).
+ *
+ * Kept free of any Monaco dependency so the accumulation logic is unit-testable
+ * on its own. A rejected declaration degrades its package to "cannot find
+ * module" — the same tradeoff `selectFilesWithinModelBudget` makes for skipped
* sibling models — which beats crashing the worker for the whole tab.
*/
-export function canAdmitExtraLib(currentTotalChars: number, incomingChars: number): boolean {
- return incomingChars <= MAX_WORKER_MODEL_CHARS && currentTotalChars + incomingChars <= MAX_EXTRA_LIB_CHARS_TOTAL;
+export class ExtraLibBudget {
+ private totalChars = 0;
+ private spent = false;
+
+ /**
+ * Whether the aggregate budget is exhausted. Once true it stays true: the
+ * budget is never reclaimed, so a whole acquisition pass can be skipped
+ * rather than woken to walk the CDN only for every file to be rejected.
+ */
+ get isSpent(): boolean {
+ return this.spent;
+ }
+
+ /**
+ * Offer a declaration of `chars` characters. Admits it (advancing the running
+ * total) when it fits both the per-file and aggregate limits; otherwise
+ * reports why it was turned away. The first aggregate overflow seals the
+ * budget so every later offer is rejected without further arithmetic.
+ */
+ admit(chars: number): ExtraLibAdmission {
+ if (this.spent) {
+ return { admitted: false, justExhausted: false, oversize: false };
+ }
+ if (chars > MAX_WORKER_MODEL_CHARS) {
+ return { admitted: false, justExhausted: false, oversize: true };
+ }
+ if (this.totalChars + chars > MAX_EXTRA_LIB_CHARS_TOTAL) {
+ this.spent = true;
+ return { admitted: false, justExhausted: true, oversize: false };
+ }
+ this.totalChars += chars;
+ return { admitted: true, justExhausted: false, oversize: false };
+ }
}
From 6abfb7bb18bc3f98b2aeb7ee996a00588ce32a6f Mon Sep 17 00:00:00 2001
From: Dawson Toth
Date: Tue, 14 Jul 2026 12:10:44 -0400
Subject: [PATCH 09/92] fix(preview): make the worktree preview launcher work
from a linked worktree
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The "studio (worktree)" launch helper (.claude/preview-worktree.sh) failed to
start when run from a linked worktree:
- node_modules/.env.local were symlinked from $root (CLAUDE_PROJECT_DIR), which
is the worktree itself — so the link pointed a worktree at its own node_modules
and pnpm hit ELOOP. Derive the real main checkout from
`git rev-parse --git-common-dir` instead.
- The launcher can inherit an older default Node than pnpm 11 requires (>=22.13);
prepend the repo's pinned .nvmrc Node (or newest installed >=22) to PATH when
the current node is too old.
- pnpm's pre-run deps check tried to reinstall/purge the symlinked node_modules
(workspace-state mismatch), which would mutate the shared main checkout; skip it
with --config.verify-deps-before-run=false.
Co-Authored-By: Claude Opus 4.8
---
.claude/preview-worktree.sh | 37 +++++++++++++++++++++++++++++++++----
1 file changed, 33 insertions(+), 4 deletions(-)
diff --git a/.claude/preview-worktree.sh b/.claude/preview-worktree.sh
index 667782552..6b8edaada 100755
--- a/.claude/preview-worktree.sh
+++ b/.claude/preview-worktree.sh
@@ -17,6 +17,12 @@ set -e
root="${CLAUDE_PROJECT_DIR:-$PWD}"
cd "$root"
+# The main checkout is the source of the shared node_modules / .env.local. CLAUDE_PROJECT_DIR is
+# the *current* checkout, which is the worktree itself when we're launched from one — so derive
+# the main checkout from git rather than assuming $root is it. (Assuming $root pointed the
+# symlinks below at a worktree's own node_modules → a self-referential link → ELOOP on start.)
+main_checkout="$(cd "$(dirname "$(git rev-parse --git-common-dir)")" && pwd)"
+
# Read the target worktree from .claude/preview-cwd (repo-relative). `read` trims surrounding
# whitespace and tolerates a missing trailing newline; fall back to the main checkout when the
# file is absent or empty.
@@ -35,13 +41,36 @@ fi
# Guard with both -e and -L so an existing real dir/file AND a dangling symlink are left alone
# (a broken symlink passes -e but `ln -s` over it would fail with "File exists").
if [ ! -e node_modules ] && [ ! -L node_modules ]; then
- ln -s "$root/node_modules" node_modules
+ ln -s "$main_checkout/node_modules" node_modules
echo "[preview] Symlinked node_modules from the main checkout." >&2
fi
-if [ ! -e .env.local ] && [ ! -L .env.local ] && [ -f "$root/.env.local" ]; then
- ln -s "$root/.env.local" .env.local
+if [ ! -e .env.local ] && [ ! -L .env.local ] && [ -f "$main_checkout/.env.local" ]; then
+ ln -s "$main_checkout/.env.local" .env.local
echo "[preview] Symlinked .env.local from the main checkout." >&2
fi
+# The preview launcher may inherit an older default Node than pnpm 11 needs (>=22.13). Prefer the
+# repo's pinned .nvmrc version; otherwise fall back to the newest installed nvm node >=22.
+current_major="$(node -v 2>/dev/null | sed 's/^v//; s/\..*//')"
+if [ -z "$current_major" ] || [ "$current_major" -lt 22 ]; then
+ want=""
+ [ -f "$root/.nvmrc" ] && want="$(tr -d '[:space:]' < "$root/.nvmrc")"
+ if [ -n "$want" ] && [ -d "$HOME/.nvm/versions/node/v${want}/bin" ]; then
+ PATH="$HOME/.nvm/versions/node/v${want}/bin:$PATH"
+ elif [ -d "$HOME/.nvm/versions/node" ]; then
+ for d in $(ls -1 "$HOME/.nvm/versions/node" | sort -Vr); do
+ if [ "$(echo "$d" | sed 's/^v//; s/\..*//')" -ge 22 ]; then
+ PATH="$HOME/.nvm/versions/node/$d/bin:$PATH"
+ break
+ fi
+ done
+ fi
+ export PATH
+ echo "[preview] Adjusted PATH to Node $(node -v 2>/dev/null) for pnpm." >&2
+fi
+
echo "[preview] Serving $(pwd) on port 5173" >&2
-exec pnpm run dev
+# node_modules is symlinked from the main checkout; skip pnpm's pre-run deps check so it can't
+# try to reinstall/purge it (the workspace state won't match, and a purge would mutate the
+# shared checkout). We just want to run the dev script against the already-installed modules.
+exec pnpm --config.verify-deps-before-run=false run dev
From c0df19c3976f38a0de7650099d3c615fb9f66b62 Mon Sep 17 00:00:00 2001
From: Dawson Toth
Date: Tue, 14 Jul 2026 12:25:39 -0400
Subject: [PATCH 10/92] fix(preview): portable node-version sort + strip
leading v from .nvmrc
Addresses review feedback on #1503:
- Replace `sort -Vr` (a GNU / newer-BSD extension that stock `sort` on older
macOS lacks) with a portable numeric sort, so the newest-installed-Node
fallback can't crash under `set -e`.
- Strip a leading `v` from the parsed `.nvmrc` value (`${want#v}`), so a
`vX.Y.Z`-style pin resolves to the correct nvm directory.
Co-Authored-By: Claude Opus 4.8
---
.claude/preview-worktree.sh | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/.claude/preview-worktree.sh b/.claude/preview-worktree.sh
index 6b8edaada..d4662b3df 100755
--- a/.claude/preview-worktree.sh
+++ b/.claude/preview-worktree.sh
@@ -55,12 +55,15 @@ current_major="$(node -v 2>/dev/null | sed 's/^v//; s/\..*//')"
if [ -z "$current_major" ] || [ "$current_major" -lt 22 ]; then
want=""
[ -f "$root/.nvmrc" ] && want="$(tr -d '[:space:]' < "$root/.nvmrc")"
+ want="${want#v}" # .nvmrc may write the version with or without a leading `v`.
if [ -n "$want" ] && [ -d "$HOME/.nvm/versions/node/v${want}/bin" ]; then
PATH="$HOME/.nvm/versions/node/v${want}/bin:$PATH"
elif [ -d "$HOME/.nvm/versions/node" ]; then
- for d in $(ls -1 "$HOME/.nvm/versions/node" | sort -Vr); do
- if [ "$(echo "$d" | sed 's/^v//; s/\..*//')" -ge 22 ]; then
- PATH="$HOME/.nvm/versions/node/$d/bin:$PATH"
+ # Newest installed >=22. Portable numeric sort (descending) instead of `sort -V`, which is a
+ # GNU/newer-BSD extension missing from older `sort` (e.g. stock macOS).
+ for v in $(ls -1 "$HOME/.nvm/versions/node" | sed 's/^v//' | sort -t. -k1,1rn -k2,2rn -k3,3rn); do
+ if [ "${v%%.*}" -ge 22 ]; then
+ PATH="$HOME/.nvm/versions/node/v$v/bin:$PATH"
break
fi
done
From 15aac8cd6df1960aa7672e71f0a636757273ae25 Mon Sep 17 00:00:00 2001
From: Dawson Toth
Date: Tue, 14 Jul 2026 14:08:22 -0400
Subject: [PATCH 11/92] fix(preview): only report a Node PATH adjustment when
one actually happened
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Addresses review nit on #1503: the "Adjusted PATH to Node …" message printed
unconditionally inside the <22 block, even when neither the .nvmrc dir nor the
nvm fallback matched — falsely claiming success in the one case an operator
most needs accurate output. Track whether PATH actually changed (`adjusted`)
and gate the message on it, warning clearly otherwise.
Co-Authored-By: Claude Opus 4.8
---
.claude/preview-worktree.sh | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/.claude/preview-worktree.sh b/.claude/preview-worktree.sh
index d4662b3df..db3b7bd36 100755
--- a/.claude/preview-worktree.sh
+++ b/.claude/preview-worktree.sh
@@ -56,20 +56,28 @@ if [ -z "$current_major" ] || [ "$current_major" -lt 22 ]; then
want=""
[ -f "$root/.nvmrc" ] && want="$(tr -d '[:space:]' < "$root/.nvmrc")"
want="${want#v}" # .nvmrc may write the version with or without a leading `v`.
+ adjusted=""
if [ -n "$want" ] && [ -d "$HOME/.nvm/versions/node/v${want}/bin" ]; then
PATH="$HOME/.nvm/versions/node/v${want}/bin:$PATH"
+ adjusted=1
elif [ -d "$HOME/.nvm/versions/node" ]; then
# Newest installed >=22. Portable numeric sort (descending) instead of `sort -V`, which is a
# GNU/newer-BSD extension missing from older `sort` (e.g. stock macOS).
for v in $(ls -1 "$HOME/.nvm/versions/node" | sed 's/^v//' | sort -t. -k1,1rn -k2,2rn -k3,3rn); do
if [ "${v%%.*}" -ge 22 ]; then
PATH="$HOME/.nvm/versions/node/v$v/bin:$PATH"
+ adjusted=1
break
fi
done
fi
- export PATH
- echo "[preview] Adjusted PATH to Node $(node -v 2>/dev/null) for pnpm." >&2
+ # Only claim an adjustment when PATH actually changed; otherwise warn (pnpm will likely fail).
+ if [ -n "$adjusted" ]; then
+ export PATH
+ echo "[preview] Adjusted PATH to Node $(node -v 2>/dev/null) for pnpm." >&2
+ else
+ echo "[preview] Warning: Node $(node -v 2>/dev/null) is older than pnpm 11 needs (>=22.13) and no nvm Node >=22 was found — pnpm may fail." >&2
+ fi
fi
echo "[preview] Serving $(pwd) on port 5173" >&2
From 728c312ef43ac618edb42bc2fde013f1907c7e0d Mon Sep 17 00:00:00 2001
From: Kyle Bernhardy
Date: Tue, 14 Jul 2026 20:54:45 -0600
Subject: [PATCH 12/92] feat(status): sync chart crosshairs across panels on a
Status tab
Hovering any time-series panel on a Status tab now shows a synchronized
crosshair/tooltip on every other time-series panel on that tab, via
Recharts' native syncId with syncMethod="value" (index-based sync
mis-aligns sparse series that don't share point counts).
- StatusTabs puts syncId = `${entityId}:${tab}` into AnalyticsContext,
so sync never crosses tabs or two instances' Status pages.
- LineChart / StackedAreaChart primitives read it through a new
non-throwing useAnalyticsSyncId() hook; charts rendered outside the
provider get no syncId.
- The expand-to-fullscreen dialog (the only fillParent caller) gets its
own `${syncId}:expanded` scope: it never drives the panels behind the
overlay, while a SmallMultiples panel expanded into the dialog keeps
intra-dialog sync (a single-chart dialog is a sync group of one).
- TableSizeTrend (Storage tab, raw Recharts) gets the same treatment via
an inExpandDialog prop threaded from StorageTab's renderTrend opts.
Known limitation: its bucket grid is anchored at range.startTime while
metric pipelines snap to epoch-aligned period boundaries, so exact
value matches with sibling panels are rare until that grid is aligned
(follow-up); mismatches degrade gracefully (tooltip hides).
- Heatmap and table-size snapshot panels are unaffected (not cartesian
time-series wrappers).
Fixes #1454
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV
---
.../instance/status/analytics/StatusTabs.tsx | 5 +-
.../__tests__/StatusTabs.sync-id.test.tsx | 113 +++++++++++++
.../__tests__/primitives/sync-id.test.tsx | 158 ++++++++++++++++++
.../analytics/charts/TableSizeTrend.tsx | 17 +-
.../analytics/context/AnalyticsContext.tsx | 15 ++
.../status/analytics/primitives/LineChart.tsx | 17 +-
.../analytics/primitives/StackedAreaChart.tsx | 10 +-
.../status/analytics/tabs/StorageTab.tsx | 12 +-
8 files changed, 339 insertions(+), 8 deletions(-)
create mode 100644 src/features/instance/status/analytics/__tests__/StatusTabs.sync-id.test.tsx
create mode 100644 src/features/instance/status/analytics/__tests__/primitives/sync-id.test.tsx
diff --git a/src/features/instance/status/analytics/StatusTabs.tsx b/src/features/instance/status/analytics/StatusTabs.tsx
index df0d48868..932597ffa 100644
--- a/src/features/instance/status/analytics/StatusTabs.tsx
+++ b/src/features/instance/status/analytics/StatusTabs.tsx
@@ -195,8 +195,11 @@ function StatusTabsInner({ instanceParams, isLocalStudio, capability }: InnerPro
timeRange: { startTime, endTime },
bucketMs: preset.bucketMs,
instanceParams,
+ // Crosshair/tooltip sync is scoped to one instance's current tab —
+ // panels on a tab share an x-domain, other tabs/instances don't.
+ syncId: `${instanceParams.entityId}:${tab}`,
};
- }, [presetId, instanceParams, tick]);
+ }, [presetId, instanceParams, tick, tab]);
const showTimePicker = tab !== 'overview';
const picker = showTimePicker
diff --git a/src/features/instance/status/analytics/__tests__/StatusTabs.sync-id.test.tsx b/src/features/instance/status/analytics/__tests__/StatusTabs.sync-id.test.tsx
new file mode 100644
index 000000000..ff9207025
--- /dev/null
+++ b/src/features/instance/status/analytics/__tests__/StatusTabs.sync-id.test.tsx
@@ -0,0 +1,113 @@
+// @vitest-environment happy-dom
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { cleanup, render } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// Asserts StatusTabs threads a per-instance, per-tab Recharts syncId through
+// AnalyticsContext (`${entityId}:${tab}`), so crosshair sync stays scoped to
+// one tab of one instance's Status page (#1454).
+
+// Probe the context from inside a tab body: mock the chart tabs to a
+// component that records what useAnalyticsSyncId() resolves to.
+const seenSyncIds: (string | undefined)[] = [];
+vi.mock('../tabs/HealthTab', async () => {
+ const { useAnalyticsSyncId } = await import('../context/AnalyticsContext');
+ function Probe() {
+ seenSyncIds.push(useAnalyticsSyncId());
+ return null;
+ }
+ return { HealthTab: Probe };
+});
+vi.mock('../tabs/TrafficTab', async () => {
+ const { useAnalyticsSyncId } = await import('../context/AnalyticsContext');
+ function Probe() {
+ seenSyncIds.push(useAnalyticsSyncId());
+ return null;
+ }
+ return { TrafficTab: Probe };
+});
+
+vi.mock('../hooks/useAnalyticsCapability', () => ({
+ useAnalyticsCapability: () => ({
+ supported: true,
+ isLoading: false,
+ isFetching: false,
+ isAuthError: false,
+ retry: vi.fn(),
+ }),
+}));
+
+let currentSearch: Record = {};
+const navigateMock = vi.fn(async () => {});
+vi.mock('@tanstack/react-router', () => ({
+ useSearch: () => currentSearch,
+ useNavigate: () => navigateMock,
+}));
+
+import { StatusTabs } from '../StatusTabs';
+
+function makeInstanceParams(entityId: string) {
+ return {
+ instanceClient: { post: vi.fn(async () => ({ data: [] })) } as never,
+ entityId: entityId as never,
+ entityType: 'instance' as const,
+ };
+}
+
+function mount(entityId: string) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: 0 } },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ currentSearch = {};
+ seenSyncIds.length = 0;
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ configurable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+});
+
+afterEach(() => cleanup());
+
+describe('StatusTabs syncId threading', () => {
+ it('keys the syncId by instance and tab (default health tab)', () => {
+ mount('inst-A');
+ expect(seenSyncIds.length).toBeGreaterThan(0);
+ expect(seenSyncIds.at(-1)).toBe('inst-A:health');
+ });
+
+ it('changes the syncId when the tab changes', () => {
+ currentSearch = { tab: 'traffic' };
+ mount('inst-A');
+ expect(seenSyncIds.at(-1)).toBe('inst-A:traffic');
+ });
+
+ it('differs per instance so two Status pages never cross-sync', () => {
+ mount('inst-A');
+ const a = seenSyncIds.at(-1);
+ cleanup();
+ seenSyncIds.length = 0;
+ mount('inst-B');
+ const b = seenSyncIds.at(-1);
+ expect(a).toBe('inst-A:health');
+ expect(b).toBe('inst-B:health');
+ expect(a).not.toBe(b);
+ });
+});
diff --git a/src/features/instance/status/analytics/__tests__/primitives/sync-id.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/sync-id.test.tsx
new file mode 100644
index 000000000..cc1477a08
--- /dev/null
+++ b/src/features/instance/status/analytics/__tests__/primitives/sync-id.test.tsx
@@ -0,0 +1,158 @@
+// @vitest-environment happy-dom
+import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig';
+import { cleanup, render } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { type AnalyticsContextValue, AnalyticsProvider } from '../../context/AnalyticsContext';
+import type { SeriesData } from '../../types/analytics';
+
+// Capture the props the primitives hand to the Recharts wrapper components.
+// Rendering real Recharts gives no DOM signal for syncId (it lives in
+// Recharts' internal store), so swap the two wrappers for prop recorders.
+const lineChartCalls: Record[] = [];
+const areaChartCalls: Record[] = [];
+
+vi.mock('recharts', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ LineChart: (props: Record) => {
+ lineChartCalls.push(props);
+ return null;
+ },
+ AreaChart: (props: Record) => {
+ areaChartCalls.push(props);
+ return null;
+ },
+ };
+});
+
+// Import after the mock so the primitives bind to the stubbed wrappers.
+const { LineChart } = await import('../../primitives/LineChart');
+const { StackedAreaChart } = await import('../../primitives/StackedAreaChart');
+const { TableSizeTrend } = await import('../../charts/TableSizeTrend');
+type TableSizeDerived = import('../../lib/tableSize').TableSizeDerived;
+
+const data: SeriesData = {
+ series: [{ key: 's1', label: 'Series One', points: [{ x: 1, y: 10 }, { x: 2, y: 20 }] }],
+};
+
+function makeContextValue(syncId?: string): AnalyticsContextValue {
+ return {
+ timeRange: { startTime: 0, endTime: 60_000 },
+ bucketMs: 60_000,
+ instanceParams: {
+ instanceClient: { post: async () => ({ data: [] }) } as never,
+ entityId: 'test-instance' as never,
+ entityType: 'instance',
+ } satisfies InstanceClientIdConfig & InstanceTypeConfig,
+ syncId,
+ };
+}
+
+function withProvider(children: ReactNode, syncId?: string) {
+ return {children};
+}
+
+describe('tab-scoped chart crosshair sync (syncId)', () => {
+ beforeEach(() => {
+ lineChartCalls.length = 0;
+ areaChartCalls.length = 0;
+ });
+ afterEach(() => cleanup());
+
+ it('LineChart passes the context syncId and syncMethod="value" to Recharts', () => {
+ render(withProvider(, 'test-instance:health'));
+ expect(lineChartCalls.length).toBeGreaterThan(0);
+ expect(lineChartCalls[0].syncId).toBe('test-instance:health');
+ expect(lineChartCalls[0].syncMethod).toBe('value');
+ });
+
+ it('StackedAreaChart passes the context syncId and syncMethod="value" to Recharts', () => {
+ render(withProvider(, 'test-instance:traffic'));
+ expect(areaChartCalls.length).toBeGreaterThan(0);
+ expect(areaChartCalls[0].syncId).toBe('test-instance:traffic');
+ expect(areaChartCalls[0].syncMethod).toBe('value');
+ });
+
+ // The expand dialog (the only fillParent caller) gets a separate sync
+ // scope: never the tab's id, so it can't drive the panels behind the
+ // overlay, but SmallMultiples dialogs keep intra-dialog sync.
+ it('LineChart with fillParent (expand dialog) uses the dialog scope, not the tab scope', () => {
+ render(withProvider(, 'test-instance:health'));
+ expect(lineChartCalls.length).toBeGreaterThan(0);
+ expect(lineChartCalls[0].syncId).toBe('test-instance:health:expanded');
+ expect(lineChartCalls[0].syncMethod).toBe('value');
+ });
+
+ it('StackedAreaChart with fillParent (expand dialog) uses the dialog scope, not the tab scope', () => {
+ render(withProvider(, 'test-instance:traffic'));
+ expect(areaChartCalls.length).toBeGreaterThan(0);
+ expect(areaChartCalls[0].syncId).toBe('test-instance:traffic:expanded');
+ expect(areaChartCalls[0].syncMethod).toBe('value');
+ });
+
+ it('LineChart outside any AnalyticsProvider gets no syncId', () => {
+ render();
+ expect(lineChartCalls.length).toBeGreaterThan(0);
+ expect(lineChartCalls[0].syncId).toBeUndefined();
+ expect(lineChartCalls[0].syncMethod).toBeUndefined();
+ });
+
+ it('StackedAreaChart outside any AnalyticsProvider gets no syncId', () => {
+ render();
+ expect(areaChartCalls.length).toBeGreaterThan(0);
+ expect(areaChartCalls[0].syncId).toBeUndefined();
+ expect(areaChartCalls[0].syncMethod).toBeUndefined();
+ });
+
+ it('a provider without syncId (e.g. legacy context value) does not sync charts', () => {
+ render(withProvider());
+ expect(lineChartCalls.length).toBeGreaterThan(0);
+ expect(lineChartCalls[0].syncId).toBeUndefined();
+ });
+
+ // TableSizeTrend (Storage tab) builds its chart from raw Recharts rather
+ // than the primitives, so it gets the same treatment separately.
+ const derived: TableSizeDerived = {
+ snapshot: { byNode: [], tableSet: ['db.t1'], hasOther: false, otherMembers: [] },
+ trend: () => [
+ { time: 1_000, values: { n1: 100 } },
+ { time: 2_000, values: { n1: 200 } },
+ ],
+ defaultSelection: () => 'db.t1',
+ emptyCause: null,
+ signature: 'sig',
+ };
+ const trendProps = {
+ derived,
+ viewMode: 'per-node' as const,
+ selectedTable: 'db.t1',
+ onChipSelect: () => {},
+ manualSelection: true,
+ range: { startTime: 0, endTime: 60_000 },
+ clusterNodeIds: ['n1'],
+ rankBy: 'bytes' as const,
+ onRankChange: () => {},
+ };
+
+ it('TableSizeTrend passes the context syncId and syncMethod="value" to Recharts', () => {
+ render(withProvider(, 'test-instance:storage'));
+ expect(lineChartCalls.length).toBeGreaterThan(0);
+ expect(lineChartCalls[0].syncId).toBe('test-instance:storage');
+ expect(lineChartCalls[0].syncMethod).toBe('value');
+ });
+
+ it('TableSizeTrend in the expand dialog uses the dialog scope, not the tab scope', () => {
+ render(withProvider(, 'test-instance:storage'));
+ expect(lineChartCalls.length).toBeGreaterThan(0);
+ expect(lineChartCalls[0].syncId).toBe('test-instance:storage:expanded');
+ expect(lineChartCalls[0].syncMethod).toBe('value');
+ });
+
+ it('TableSizeTrend outside any AnalyticsProvider gets no syncId', () => {
+ render();
+ expect(lineChartCalls.length).toBeGreaterThan(0);
+ expect(lineChartCalls[0].syncId).toBeUndefined();
+ });
+});
diff --git a/src/features/instance/status/analytics/charts/TableSizeTrend.tsx b/src/features/instance/status/analytics/charts/TableSizeTrend.tsx
index 0a517c5f7..7d8896520 100644
--- a/src/features/instance/status/analytics/charts/TableSizeTrend.tsx
+++ b/src/features/instance/status/analytics/charts/TableSizeTrend.tsx
@@ -1,6 +1,7 @@
import { formatValue } from '@/lib/formatValue';
import { useMemo } from 'react';
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
+import { useAnalyticsSyncId } from '../context/AnalyticsContext';
import { useNodeSelection } from '../hooks/useNodeSelection';
import { getNodeColor } from '../lib/nodeColors';
import { computeGrowthAnnotation, type RankBy, type TableSizeDerived } from '../lib/tableSize';
@@ -31,6 +32,11 @@ interface Props {
rankBy: RankBy;
/** User toggled the rank. Dashboard persists to localStorage. */
onRankChange: (r: RankBy) => void;
+ /** True when rendered inside the expand-to-fullscreen dialog, which gets
+ * its own crosshair-sync scope instead of the tab-wide one
+ * (TableSizeTrend doesn't implement fillParent sizing — this flag only
+ * scopes syncing). */
+ inExpandDialog?: boolean;
}
export function TableSizeTrend({
@@ -43,8 +49,17 @@ export function TableSizeTrend({
clusterNodeIds,
rankBy,
onRankChange,
+ inExpandDialog,
}: Props) {
const colors = getChartColors();
+ // Tab-scoped crosshair/tooltip sync (same treatment as the LineChart /
+ // StackedAreaChart primitives) — the trend shares the tab's x-domain.
+ // The expand dialog gets its own scope so it never drives the panels
+ // behind the overlay.
+ const tabSyncId = useAnalyticsSyncId();
+ const syncProps = tabSyncId !== undefined
+ ? { syncId: inExpandDialog ? `${tabSyncId}:expanded` : tabSyncId, syncMethod: 'value' as const }
+ : {};
const points = useMemo(
() => (selectedTable ? derived.trend(selectedTable) : []),
@@ -147,7 +162,7 @@ export function TableSizeTrend({
-
+ (null);
@@ -21,6 +27,7 @@ export function AnalyticsProvider({ value, children }: ProviderProps) {
value.timeRange.endTime,
value.bucketMs,
value.instanceParams.entityId,
+ value.syncId,
]);
return {children};
}
@@ -30,3 +37,11 @@ export function useAnalyticsContext(): AnalyticsContextValue {
if (!v) { throw new Error('useAnalyticsContext must be used inside '); }
return v;
}
+
+/** Tab-scoped Recharts syncId, or undefined when rendering outside an
+ * (or one that doesn't set it). Non-throwing on purpose:
+ * the chart primitives are also rendered standalone (tests, future reuse)
+ * and must not require the provider just to skip syncing. */
+export function useAnalyticsSyncId(): string | undefined {
+ return useContext(Ctx)?.syncId;
+}
diff --git a/src/features/instance/status/analytics/primitives/LineChart.tsx b/src/features/instance/status/analytics/primitives/LineChart.tsx
index c2a592a26..b38d29b1b 100644
--- a/src/features/instance/status/analytics/primitives/LineChart.tsx
+++ b/src/features/instance/status/analytics/primitives/LineChart.tsx
@@ -10,6 +10,7 @@ import {
XAxis,
YAxis,
} from 'recharts';
+import { useAnalyticsSyncId } from '../context/AnalyticsContext';
import { NODE_PALETTE } from '../lib/nodeColors';
import { getChartColors } from '../lib/theme';
import { formatAxisTick, formatTooltipTime } from '../lib/time';
@@ -66,6 +67,20 @@ function composeAriaLabel(data: SeriesData): string {
export function LineChart(
{ data, yAxis, height = 240, ariaLabel, hideLegend, xDomain, fillParent }: Props,
) {
+ // Sync crosshairs/tooltips across every cartesian chart on the current
+ // Status tab. `fillParent` is only ever set by the expand-to-fullscreen
+ // dialog (ChartExpandButton); syncing dialog charts with the tab would
+ // ghost-drive the panels behind the overlay, so the dialog gets its own
+ // scope instead — a SmallMultiples panel expands to several mini-charts
+ // that keep syncing with each other, while a single-chart dialog is a
+ // harmless sync group of one. syncMethod="value" matches by x value; the
+ // default index-based sync mis-aligns sparse series that don't share
+ // point counts.
+ const tabSyncId = useAnalyticsSyncId();
+ const syncProps = tabSyncId !== undefined
+ ? { syncId: fillParent ? `${tabSyncId}:expanded` : tabSyncId, syncMethod: 'value' as const }
+ : {};
+
if (data.series.length === 0) {
return (
@@ -98,7 +113,7 @@ export function LineChart(
}
-
+
-
+ (
);
- const renderTrend = (_opts?: { fillParent: boolean }) => (
+ const renderTrend = (opts?: { fillParent: boolean }) => (
effectiveSelection
? (
)
: (
From 8b5b419842a4eb953b6cfd451ba1be69d68e751e Mon Sep 17 00:00:00 2001
From: Kyle Bernhardy
Date: Tue, 14 Jul 2026 22:10:17 -0600
Subject: [PATCH 13/92] fix(status): keep the analytics window stable across
tab switches; share chart sync props
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two review findings on the crosshair-sync PR:
1. Tab-switch refetch regression: adding `tab` to the ctxValue memo deps
made every tab switch recompute Date.now() and mint a new [start, end]
window — the window is baked into every panel's query key, so each
flip re-keyed every query and defeated useAnalyticsRecords'
staleTime-Infinity tab-flip cache. The window (and preset/bucketMs
lookup) now lives in its own memo keyed only on [presetId, tick];
ctxValue composes the stable timeRange with the tab-scoped syncId, so
tab switches change only the syncId. Regression-tested: the new
window-stability test fails on the previous ctxValue memo.
2. The `syncId`/`syncMethod` props expression was copy-pasted in
LineChart, StackedAreaChart, and TableSizeTrend, with TableSizeTrend
inventing an `inExpandDialog` alias for the primitives' `fillParent`.
Extracted a useChartSyncProps(inDialog) hook next to
useAnalyticsSyncId and renamed the alias to `fillParent` (still
sync-scoping only; fillParent sizing remains unimplemented there).
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV
---
.../instance/status/analytics/StatusTabs.tsx | 28 +++++---
.../__tests__/StatusTabs.sync-id.test.tsx | 67 ++++++++++++++++---
.../__tests__/primitives/sync-id.test.tsx | 4 +-
.../analytics/charts/TableSizeTrend.tsx | 20 +++---
.../analytics/context/AnalyticsContext.tsx | 17 +++++
.../status/analytics/primitives/LineChart.tsx | 16 ++---
.../analytics/primitives/StackedAreaChart.tsx | 9 +--
.../status/analytics/tabs/StorageTab.tsx | 5 +-
8 files changed, 115 insertions(+), 51 deletions(-)
diff --git a/src/features/instance/status/analytics/StatusTabs.tsx b/src/features/instance/status/analytics/StatusTabs.tsx
index 932597ffa..4a9c35800 100644
--- a/src/features/instance/status/analytics/StatusTabs.tsx
+++ b/src/features/instance/status/analytics/StatusTabs.tsx
@@ -184,22 +184,30 @@ function StatusTabsInner({ instanceParams, isLocalStudio, capability }: InnerPro
}
}, [capability.error, tab, navigate, presetId, refreshMs]);
- const ctxValue = useMemo(() => {
+ // The window snapshot lives in its own memo keyed ONLY on the preset and
+ // the refresh tick. Nothing else may mint a new window: it is baked into
+ // every panel's query key, so an extra dep (e.g. `tab`) would re-key every
+ // query on that dep's changes and defeat useAnalyticsRecords'
+ // staleTime-Infinity cache that makes tab flips instant.
+ const { timeRange, bucketMs } = useMemo(() => {
const preset = getPreset(presetId);
const endTime = Date.now();
const startTime = endTime - preset.durationMs;
// `tick` participates in memo deps so every refresh (interval or manual)
// produces a fresh window even if the user did not change presets.
void tick;
- return {
- timeRange: { startTime, endTime },
- bucketMs: preset.bucketMs,
- instanceParams,
- // Crosshair/tooltip sync is scoped to one instance's current tab —
- // panels on a tab share an x-domain, other tabs/instances don't.
- syncId: `${instanceParams.entityId}:${tab}`,
- };
- }, [presetId, instanceParams, tick, tab]);
+ return { timeRange: { startTime, endTime }, bucketMs: preset.bucketMs };
+ }, [presetId, tick]);
+
+ const ctxValue = useMemo(() => ({
+ timeRange,
+ bucketMs,
+ instanceParams,
+ // Crosshair/tooltip sync is scoped to one instance's current tab —
+ // panels on a tab share an x-domain, other tabs/instances don't.
+ // Tab switches change only this syncId; the window above stays put.
+ syncId: `${instanceParams.entityId}:${tab}`,
+ }), [timeRange, bucketMs, instanceParams, tab]);
const showTimePicker = tab !== 'overview';
const picker = showTimePicker
diff --git a/src/features/instance/status/analytics/__tests__/StatusTabs.sync-id.test.tsx b/src/features/instance/status/analytics/__tests__/StatusTabs.sync-id.test.tsx
index ff9207025..eddcb2f61 100644
--- a/src/features/instance/status/analytics/__tests__/StatusTabs.sync-id.test.tsx
+++ b/src/features/instance/status/analytics/__tests__/StatusTabs.sync-id.test.tsx
@@ -1,27 +1,34 @@
// @vitest-environment happy-dom
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { cleanup, render } from '@testing-library/react';
+import { act, cleanup, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { AnalyticsContextValue } from '../context/AnalyticsContext';
// Asserts StatusTabs threads a per-instance, per-tab Recharts syncId through
// AnalyticsContext (`${entityId}:${tab}`), so crosshair sync stays scoped to
-// one tab of one instance's Status page (#1454).
+// one tab of one instance's Status page (#1454) — and that switching tabs
+// changes ONLY the syncId, never the analytics window (which is baked into
+// every panel's query key).
// Probe the context from inside a tab body: mock the chart tabs to a
-// component that records what useAnalyticsSyncId() resolves to.
+// component that records what useAnalyticsSyncId() / useAnalyticsContext()
+// resolve to.
const seenSyncIds: (string | undefined)[] = [];
+const seenContexts: AnalyticsContextValue[] = [];
vi.mock('../tabs/HealthTab', async () => {
- const { useAnalyticsSyncId } = await import('../context/AnalyticsContext');
+ const { useAnalyticsContext, useAnalyticsSyncId } = await import('../context/AnalyticsContext');
function Probe() {
seenSyncIds.push(useAnalyticsSyncId());
+ seenContexts.push(useAnalyticsContext());
return null;
}
return { HealthTab: Probe };
});
vi.mock('../tabs/TrafficTab', async () => {
- const { useAnalyticsSyncId } = await import('../context/AnalyticsContext');
+ const { useAnalyticsContext, useAnalyticsSyncId } = await import('../context/AnalyticsContext');
function Probe() {
seenSyncIds.push(useAnalyticsSyncId());
+ seenContexts.push(useAnalyticsContext());
return null;
}
return { TrafficTab: Probe };
@@ -44,6 +51,7 @@ vi.mock('@tanstack/react-router', () => ({
useNavigate: () => navigateMock,
}));
+import { DEFAULT_REFRESH_MS } from '../context/timePresets';
import { StatusTabs } from '../StatusTabs';
function makeInstanceParams(entityId: string) {
@@ -58,16 +66,22 @@ function mount(entityId: string) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
- return render(
+ const instanceParams = makeInstanceParams(entityId);
+ const ui = () => (
-
- ,
+
+
);
+ const view = render(ui());
+ // Fresh element, same stable props — re-renders the tree so the mocked
+ // useSearch is re-read after mutating `currentSearch`.
+ return { ...view, rerenderSame: () => view.rerender(ui()) };
}
beforeEach(() => {
currentSearch = {};
seenSyncIds.length = 0;
+ seenContexts.length = 0;
Object.defineProperty(window, 'matchMedia', {
writable: true,
configurable: true,
@@ -111,3 +125,40 @@ describe('StatusTabs syncId threading', () => {
expect(a).not.toBe(b);
});
});
+
+// Regression coverage for the tab-switch refetch bug: the window is baked
+// into every panel's query key, so if switching tabs minted a new
+// [start, end] snapshot, every query would re-key and defeat
+// useAnalyticsRecords' staleTime-Infinity cache that makes tab flips instant.
+describe('StatusTabs window stability across tab switches', () => {
+ it('keeps the timeRange stable when only the tab changes', () => {
+ const { rerenderSame } = mount('inst-A');
+ const healthCtx = seenContexts.at(-1)!;
+ expect(healthCtx.syncId).toBe('inst-A:health');
+
+ currentSearch = { tab: 'traffic' };
+ rerenderSame();
+ const trafficCtx = seenContexts.at(-1)!;
+ expect(trafficCtx.syncId).toBe('inst-A:traffic');
+ // Same window object, not just equal values — referential stability is
+ // what keeps downstream memos and query keys unchanged.
+ expect(trafficCtx.timeRange).toBe(healthCtx.timeRange);
+ expect(trafficCtx.bucketMs).toBe(healthCtx.bucketMs);
+ });
+
+ it('a refresh tick DOES mint a new window (same tab)', async () => {
+ vi.useFakeTimers();
+ try {
+ mount('inst-A');
+ const before = seenContexts.at(-1)!;
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(DEFAULT_REFRESH_MS + 5);
+ });
+ const after = seenContexts.at(-1)!;
+ expect(after.timeRange).not.toBe(before.timeRange);
+ expect(after.timeRange.endTime).toBeGreaterThanOrEqual(before.timeRange.endTime + DEFAULT_REFRESH_MS);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/src/features/instance/status/analytics/__tests__/primitives/sync-id.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/sync-id.test.tsx
index cc1477a08..12bdce131 100644
--- a/src/features/instance/status/analytics/__tests__/primitives/sync-id.test.tsx
+++ b/src/features/instance/status/analytics/__tests__/primitives/sync-id.test.tsx
@@ -9,6 +9,8 @@ import type { SeriesData } from '../../types/analytics';
// Capture the props the primitives hand to the Recharts wrapper components.
// Rendering real Recharts gives no DOM signal for syncId (it lives in
// Recharts' internal store), so swap the two wrappers for prop recorders.
+// All three charts route through useChartSyncProps (AnalyticsContext.tsx);
+// exercising them here covers the hook's tab/dialog/no-provider branches.
const lineChartCalls: Record[] = [];
const areaChartCalls: Record[] = [];
@@ -144,7 +146,7 @@ describe('tab-scoped chart crosshair sync (syncId)', () => {
});
it('TableSizeTrend in the expand dialog uses the dialog scope, not the tab scope', () => {
- render(withProvider(, 'test-instance:storage'));
+ render(withProvider(, 'test-instance:storage'));
expect(lineChartCalls.length).toBeGreaterThan(0);
expect(lineChartCalls[0].syncId).toBe('test-instance:storage:expanded');
expect(lineChartCalls[0].syncMethod).toBe('value');
diff --git a/src/features/instance/status/analytics/charts/TableSizeTrend.tsx b/src/features/instance/status/analytics/charts/TableSizeTrend.tsx
index 7d8896520..ff99bfa34 100644
--- a/src/features/instance/status/analytics/charts/TableSizeTrend.tsx
+++ b/src/features/instance/status/analytics/charts/TableSizeTrend.tsx
@@ -1,7 +1,7 @@
import { formatValue } from '@/lib/formatValue';
import { useMemo } from 'react';
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
-import { useAnalyticsSyncId } from '../context/AnalyticsContext';
+import { useChartSyncProps } from '../context/AnalyticsContext';
import { useNodeSelection } from '../hooks/useNodeSelection';
import { getNodeColor } from '../lib/nodeColors';
import { computeGrowthAnnotation, type RankBy, type TableSizeDerived } from '../lib/tableSize';
@@ -33,10 +33,10 @@ interface Props {
/** User toggled the rank. Dashboard persists to localStorage. */
onRankChange: (r: RankBy) => void;
/** True when rendered inside the expand-to-fullscreen dialog, which gets
- * its own crosshair-sync scope instead of the tab-wide one
- * (TableSizeTrend doesn't implement fillParent sizing — this flag only
- * scopes syncing). */
- inExpandDialog?: boolean;
+ * its own crosshair-sync scope instead of the tab-wide one. Named to
+ * match the chart primitives, but TableSizeTrend doesn't implement
+ * fillParent *sizing* yet — this flag only scopes syncing. */
+ fillParent?: boolean;
}
export function TableSizeTrend({
@@ -49,17 +49,13 @@ export function TableSizeTrend({
clusterNodeIds,
rankBy,
onRankChange,
- inExpandDialog,
+ fillParent,
}: Props) {
const colors = getChartColors();
// Tab-scoped crosshair/tooltip sync (same treatment as the LineChart /
// StackedAreaChart primitives) — the trend shares the tab's x-domain.
- // The expand dialog gets its own scope so it never drives the panels
- // behind the overlay.
- const tabSyncId = useAnalyticsSyncId();
- const syncProps = tabSyncId !== undefined
- ? { syncId: inExpandDialog ? `${tabSyncId}:expanded` : tabSyncId, syncMethod: 'value' as const }
- : {};
+ // See useChartSyncProps for the dialog-scope rationale.
+ const syncProps = useChartSyncProps(!!fillParent);
const points = useMemo(
() => (selectedTable ? derived.trend(selectedTable) : []),
diff --git a/src/features/instance/status/analytics/context/AnalyticsContext.tsx b/src/features/instance/status/analytics/context/AnalyticsContext.tsx
index cdaa3a072..63c043290 100644
--- a/src/features/instance/status/analytics/context/AnalyticsContext.tsx
+++ b/src/features/instance/status/analytics/context/AnalyticsContext.tsx
@@ -45,3 +45,20 @@ export function useAnalyticsContext(): AnalyticsContextValue {
export function useAnalyticsSyncId(): string | undefined {
return useContext(Ctx)?.syncId;
}
+
+/** Recharts sync props for a cartesian chart on a Status tab: the tab-scoped
+ * syncId plus syncMethod="value" (matches crosshairs by x value; the default
+ * index-based sync mis-aligns sparse series that don't share point counts).
+ * Pass `inDialog: true` from the expand-to-fullscreen dialog — syncing dialog
+ * charts with the tab would ghost-drive the panels behind the overlay, so
+ * the dialog gets its own `:expanded` scope instead: a SmallMultiples panel
+ * expands to several mini-charts that keep syncing with each other, while a
+ * single-chart dialog is a harmless sync group of one. Returns an empty
+ * object when no syncId is in context so charts rendered standalone (tests,
+ * future reuse) simply don't sync. */
+export function useChartSyncProps(inDialog: boolean): { syncId?: string; syncMethod?: 'value' } {
+ const tabSyncId = useAnalyticsSyncId();
+ return tabSyncId !== undefined
+ ? { syncId: inDialog ? `${tabSyncId}:expanded` : tabSyncId, syncMethod: 'value' }
+ : {};
+}
diff --git a/src/features/instance/status/analytics/primitives/LineChart.tsx b/src/features/instance/status/analytics/primitives/LineChart.tsx
index b38d29b1b..14b82dc9e 100644
--- a/src/features/instance/status/analytics/primitives/LineChart.tsx
+++ b/src/features/instance/status/analytics/primitives/LineChart.tsx
@@ -10,7 +10,7 @@ import {
XAxis,
YAxis,
} from 'recharts';
-import { useAnalyticsSyncId } from '../context/AnalyticsContext';
+import { useChartSyncProps } from '../context/AnalyticsContext';
import { NODE_PALETTE } from '../lib/nodeColors';
import { getChartColors } from '../lib/theme';
import { formatAxisTick, formatTooltipTime } from '../lib/time';
@@ -69,17 +69,9 @@ export function LineChart(
) {
// Sync crosshairs/tooltips across every cartesian chart on the current
// Status tab. `fillParent` is only ever set by the expand-to-fullscreen
- // dialog (ChartExpandButton); syncing dialog charts with the tab would
- // ghost-drive the panels behind the overlay, so the dialog gets its own
- // scope instead — a SmallMultiples panel expands to several mini-charts
- // that keep syncing with each other, while a single-chart dialog is a
- // harmless sync group of one. syncMethod="value" matches by x value; the
- // default index-based sync mis-aligns sparse series that don't share
- // point counts.
- const tabSyncId = useAnalyticsSyncId();
- const syncProps = tabSyncId !== undefined
- ? { syncId: fillParent ? `${tabSyncId}:expanded` : tabSyncId, syncMethod: 'value' as const }
- : {};
+ // dialog (ChartExpandButton), which gets its own sync scope — see
+ // useChartSyncProps for the full rationale.
+ const syncProps = useChartSyncProps(!!fillParent);
if (data.series.length === 0) {
return (
diff --git a/src/features/instance/status/analytics/primitives/StackedAreaChart.tsx b/src/features/instance/status/analytics/primitives/StackedAreaChart.tsx
index 33b8bbcb7..9cae7fd06 100644
--- a/src/features/instance/status/analytics/primitives/StackedAreaChart.tsx
+++ b/src/features/instance/status/analytics/primitives/StackedAreaChart.tsx
@@ -2,7 +2,7 @@ import { useResolvedTheme } from '@/hooks/useResolvedTheme';
import { formatValue } from '@/lib/formatValue';
import { useMemo } from 'react';
import { Area, AreaChart, CartesianGrid, Legend, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
-import { useAnalyticsSyncId } from '../context/AnalyticsContext';
+import { useChartSyncProps } from '../context/AnalyticsContext';
import { NODE_PALETTE } from '../lib/nodeColors';
import { getChartColors } from '../lib/theme';
import { formatAxisTick, formatTooltipTime } from '../lib/time';
@@ -101,11 +101,8 @@ export function StackedAreaChart(
const theme = useResolvedTheme();
// Tab-scoped crosshair/tooltip sync; the expand dialog (the only
// `fillParent` caller) gets its own scope so it never drives the panels
- // behind the overlay. See LineChart for the full rationale.
- const tabSyncId = useAnalyticsSyncId();
- const syncProps = tabSyncId !== undefined
- ? { syncId: fillParent ? `${tabSyncId}:expanded` : tabSyncId, syncMethod: 'value' as const }
- : {};
+ // behind the overlay. See useChartSyncProps for the full rationale.
+ const syncProps = useChartSyncProps(!!fillParent);
if (data.series.length === 0) {
return (
diff --git a/src/features/instance/status/analytics/tabs/StorageTab.tsx b/src/features/instance/status/analytics/tabs/StorageTab.tsx
index a368981ad..92343a86e 100644
--- a/src/features/instance/status/analytics/tabs/StorageTab.tsx
+++ b/src/features/instance/status/analytics/tabs/StorageTab.tsx
@@ -155,8 +155,9 @@ function TableSizePanels() {
rankBy={rankBy}
onRankChange={setRankBy}
// fillParent here means "rendered in the expand dialog" — the
- // trend only uses it to scope crosshair sync to the dialog.
- inExpandDialog={opts?.fillParent ?? false}
+ // trend doesn't implement fillParent sizing yet; it only uses
+ // the flag to scope crosshair sync to the dialog.
+ fillParent={opts?.fillParent ?? false}
/>
)
: (
From 1fc976dc9b73f6a2810a820634523917be56bd26 Mon Sep 17 00:00:00 2001
From: Kyle Bernhardy
Date: Tue, 14 Jul 2026 22:24:25 -0600
Subject: [PATCH 14/92] fix(status): include instanceClient in the
AnalyticsProvider memo deps (review)
Inert today (the client is memoized per route mount) but a client rebuild
with an unchanged entityId now propagates instead of serving a stale one.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV
---
.../instance/status/analytics/context/AnalyticsContext.tsx | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/features/instance/status/analytics/context/AnalyticsContext.tsx b/src/features/instance/status/analytics/context/AnalyticsContext.tsx
index 63c043290..541284ff9 100644
--- a/src/features/instance/status/analytics/context/AnalyticsContext.tsx
+++ b/src/features/instance/status/analytics/context/AnalyticsContext.tsx
@@ -27,6 +27,10 @@ export function AnalyticsProvider({ value, children }: ProviderProps) {
value.timeRange.endTime,
value.bucketMs,
value.instanceParams.entityId,
+ // The client is memoized per route mount (useInstanceClientIdParams),
+ // so this dep is inert today — it exists so a future client rebuild
+ // with the same entityId propagates instead of serving a stale one.
+ value.instanceParams.instanceClient,
value.syncId,
]);
return {children};
From 36a349e0404fd6947ad29126a0e679a18d089ac0 Mon Sep 17 00:00:00 2001
From: Kyle Bernhardy
Date: Tue, 14 Jul 2026 20:54:32 -0600
Subject: [PATCH 15/92] feat(status): add KPI stat strip to the Health tab
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds a row of five stat tiles above the Health tab's panel grid — CPU
(p95, harper+user scopes summed), heap used, main-thread utilization,
error rate, and request p95 — so "is anything wrong right now?" reads at
a glance. Each tile shows the latest-bucket cluster value, a delta vs
the previous window of equal length (arrow + %, up-is-bad coloring), and
an axis-less inline-SVG sparkline of the current window; em-dash + no
delta when data is absent, skeletons while loading.
Tiles reuse useAnalyticsRecords + runPipeline: the current-window fetch
passes exactly the panels' arguments so it lands on their existing query
key (react-query dedupes to one POST); the shifted previous-window query
is the only new key — at most one extra get_analytics POST per metric
per window slide, and it shares the key prefix StatusTabs' in-flight
refresh guard scans. The error-rate tile projects 1 − total/count per
record under count-weighted-mean, which collapses algebraically to the
Σ-correct (Σcount − Σtotal)/Σcount — same arithmetic as the derived
error-rate panel.
Fixes #1457
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV
---
.../status/analytics/tabs/HealthTab.tsx | 8 +-
.../analytics/tabs/kpi/KpiSparkline.tsx | 72 ++++++
.../status/analytics/tabs/kpi/KpiStrip.tsx | 12 +
.../analytics/tabs/kpi/KpiTile.test.tsx | 216 ++++++++++++++++++
.../status/analytics/tabs/kpi/KpiTile.tsx | 65 ++++++
.../status/analytics/tabs/kpi/kpiMath.test.ts | 116 ++++++++++
.../status/analytics/tabs/kpi/kpiMath.ts | 93 ++++++++
.../analytics/tabs/kpi/kpiTiles.test.ts | 79 +++++++
.../status/analytics/tabs/kpi/kpiTiles.ts | 148 ++++++++++++
.../analytics/tabs/kpi/useKpiTileData.ts | 82 +++++++
10 files changed, 889 insertions(+), 2 deletions(-)
create mode 100644 src/features/instance/status/analytics/tabs/kpi/KpiSparkline.tsx
create mode 100644 src/features/instance/status/analytics/tabs/kpi/KpiStrip.tsx
create mode 100644 src/features/instance/status/analytics/tabs/kpi/KpiTile.test.tsx
create mode 100644 src/features/instance/status/analytics/tabs/kpi/KpiTile.tsx
create mode 100644 src/features/instance/status/analytics/tabs/kpi/kpiMath.test.ts
create mode 100644 src/features/instance/status/analytics/tabs/kpi/kpiMath.ts
create mode 100644 src/features/instance/status/analytics/tabs/kpi/kpiTiles.test.ts
create mode 100644 src/features/instance/status/analytics/tabs/kpi/kpiTiles.ts
create mode 100644 src/features/instance/status/analytics/tabs/kpi/useKpiTileData.ts
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/kpi/KpiSparkline.tsx b/src/features/instance/status/analytics/tabs/kpi/KpiSparkline.tsx
new file mode 100644
index 000000000..82238228a
--- /dev/null
+++ b/src/features/instance/status/analytics/tabs/kpi/KpiSparkline.tsx
@@ -0,0 +1,72 @@
+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 ; }
+
+ const [x0, x1] = xDomain;
+ const xSpan = x1 - x0 || 1;
+ let yMin = Infinity;
+ let yMax = -Infinity;
+ for (const p of points) {
+ if (p.y < yMin) { yMin = p.y; }
+ if (p.y > yMax) { yMax = p.y; }
+ }
+ // Flat series: center the line rather than dividing by zero.
+ const ySpan = yMax - yMin || 1;
+
+ const toXY = (p: KpiPoint): [number, number] => [
+ ((p.x - x0) / xSpan) * VIEW_W,
+ yMax === yMin
+ ? VIEW_H / 2
+ : PAD_Y + (1 - (p.y - yMin) / ySpan) * (VIEW_H - 2 * PAD_Y),
+ ];
+
+ const coords = points.map(toXY);
+ const toPath = (cs: [number, number][]): string =>
+ cs.map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`).join(' ');
+
+ return (
+
+ );
+}
diff --git a/src/features/instance/status/analytics/tabs/kpi/KpiStrip.tsx b/src/features/instance/status/analytics/tabs/kpi/KpiStrip.tsx
new file mode 100644
index 000000000..06494642d
--- /dev/null
+++ b/src/features/instance/status/analytics/tabs/kpi/KpiStrip.tsx
@@ -0,0 +1,12 @@
+import { KpiTile } from './KpiTile';
+import { KPI_TILES } from './kpiTiles';
+
+/** The Health tab's at-a-glance vitals row: five stat tiles above the panel
+ * grid (see kpiTiles.ts for the metric definitions). */
+export function KpiStrip() {
+ return (
+
+ {KPI_TILES.map((def) => )}
+
+ );
+}
diff --git a/src/features/instance/status/analytics/tabs/kpi/KpiTile.test.tsx b/src/features/instance/status/analytics/tabs/kpi/KpiTile.test.tsx
new file mode 100644
index 000000000..f4e1efa4e
--- /dev/null
+++ b/src/features/instance/status/analytics/tabs/kpi/KpiTile.test.tsx
@@ -0,0 +1,216 @@
+/** @vitest-environment jsdom */
+import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig';
+import { getRawAnalyticsQueryOptions } from '@/integrations/api/instance/status/getAnalytics';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { render, screen, waitFor } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { AnalyticsProvider } from '../../context/AnalyticsContext';
+import { useAnalyticsRecords } from '../../hooks/useAnalyticsRecords';
+import { KpiSparkline } from './KpiSparkline';
+import { KpiStrip } from './KpiStrip';
+import { KpiTile } from './KpiTile';
+import { KPI_TILES } from './kpiTiles';
+
+// Window under test: [240s, 360s], so the previous window is [120s, 240s]
+// and the label math yields "2m".
+const START = 240_000;
+const END = 360_000;
+const BUCKET = 60_000;
+
+const durationTile = KPI_TILES.find((t) => t.id === 'p95-duration')!;
+
+interface PostBody {
+ metric: string;
+ start_time: number;
+ end_time: number;
+}
+
+function makeInstanceParams(
+ handler: (body: PostBody) => unknown[] | Promise,
+): InstanceClientIdConfig & InstanceTypeConfig {
+ const post = vi.fn(async (_url: string, body: PostBody) => ({ data: await handler(body) }));
+ return {
+ instanceClient: { post } as never,
+ entityId: 'inst-A' as never,
+ entityType: 'instance',
+ };
+}
+
+function makeWrapper(instanceParams: InstanceClientIdConfig & InstanceTypeConfig): {
+ Wrapper: ({ children }: { children: ReactNode }) => ReactNode;
+ client: QueryClient;
+} {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const Wrapper = ({ children }: { children: ReactNode }) => (
+
+
+ {children}
+
+
+ );
+ return { Wrapper, client };
+}
+
+const CUR_ROWS = [
+ { time: 300_000, node: 'n1', period: 60_000, path: '/a', count: 100, p95: 10 },
+ { time: 360_000, node: 'n1', period: 60_000, path: '/a', count: 100, p95: 20 },
+];
+const PREV_ROWS = [
+ { time: 180_000, node: 'n1', period: 60_000, path: '/a', count: 100, p95: 10 },
+];
+
+/** Current window → CUR_ROWS, previous window → PREV_ROWS. */
+function twoWindowHandler(body: PostBody): unknown[] {
+ return body.start_time === START ? CUR_ROWS : PREV_ROWS;
+}
+
+describe('KpiTile', () => {
+ it('renders the latest-bucket value and the delta vs the previous window', async () => {
+ const params = makeInstanceParams(twoWindowHandler);
+ const { Wrapper } = makeWrapper(params);
+ render(, { wrapper: Wrapper });
+
+ // Latest bucket p95 = 20 → "20.0 ms"; window means 15 vs 10 → +50%.
+ await waitFor(() => expect(screen.getByText('20.0 ms')).toBeTruthy());
+ expect(screen.getByLabelText('up +50.0% vs previous 2m')).toBeTruthy();
+ expect(screen.getByText('+50.0%')).toBeTruthy();
+ });
+
+ it('shows an em-dash and no delta when data is absent', async () => {
+ const params = makeInstanceParams(() => []);
+ const { Wrapper } = makeWrapper(params);
+ render(, { wrapper: Wrapper });
+
+ await waitFor(() => expect(screen.getByText('—')).toBeTruthy());
+ expect(screen.queryByText(/vs prev/)).toBeNull();
+ });
+
+ it('shows the value but no delta when only the previous window is empty', async () => {
+ const params = makeInstanceParams((body) => (body.start_time === START ? CUR_ROWS : []));
+ const { Wrapper } = makeWrapper(params);
+ render(, { wrapper: Wrapper });
+
+ await waitFor(() => expect(screen.getByText('20.0 ms')).toBeTruthy());
+ expect(screen.queryByText(/vs prev/)).toBeNull();
+ });
+
+ it('renders a labeled loading skeleton while the current window is in flight', () => {
+ const params = makeInstanceParams(() => new Promise(() => {}));
+ const { Wrapper } = makeWrapper(params);
+ render(, { wrapper: Wrapper });
+
+ const status = screen.getByRole('status');
+ expect(status.getAttribute('aria-label')).toBe(`Loading ${durationTile.label}`);
+ });
+
+ it("keys the current window on the panels' query-key convention and the previous window on a shifted copy", async () => {
+ const params = makeInstanceParams(twoWindowHandler);
+ const { Wrapper, client } = makeWrapper(params);
+ render(, { wrapper: Wrapper });
+ await waitFor(() => expect(screen.getByText('20.0 ms')).toBeTruthy());
+
+ const keys = client.getQueryCache().getAll().map((q) => JSON.stringify(q.queryKey));
+ // Exactly what MetricPanel's useAnalyticsRecords would key for this
+ // metric + window — byte-identical, so react-query dedupes the POST.
+ const panelKey = JSON.stringify(
+ getRawAnalyticsQueryOptions({
+ metric: durationTile.metric,
+ startTime: START,
+ endTime: END,
+ instanceParams: params,
+ bucketMs: BUCKET,
+ }).queryKey,
+ );
+ const previousKey = JSON.stringify(
+ getRawAnalyticsQueryOptions({
+ metric: durationTile.metric,
+ startTime: START - (END - START),
+ endTime: START,
+ instanceParams: params,
+ bucketMs: BUCKET,
+ }).queryKey,
+ );
+ expect(keys).toContain(panelKey);
+ expect(keys).toContain(previousKey);
+ expect(panelKey).not.toBe(previousKey);
+ expect(keys).toHaveLength(2);
+ });
+
+ it('dedupes the current-window POST with a mounted panel hook — one extra POST total', async () => {
+ const params = makeInstanceParams(twoWindowHandler);
+ const { Wrapper } = makeWrapper(params);
+
+ // Stand-in for MetricPanel's fetch: same metric/window/bucket args
+ // (requiredFields does not participate in the query key).
+ function PanelProbe() {
+ useAnalyticsRecords({
+ metric: durationTile.metric,
+ startTime: START,
+ endTime: END,
+ instanceParams: params,
+ bucketMs: BUCKET,
+ requiredFields: ['p95'],
+ });
+ return null;
+ }
+
+ render(
+ <>
+
+
+ >,
+ { wrapper: Wrapper },
+ );
+ await waitFor(() => expect(screen.getByText('20.0 ms')).toBeTruthy());
+
+ const post = params.instanceClient.post as unknown as ReturnType;
+ const bodies = post.mock.calls.map((c) => c[1] as PostBody);
+ // One POST for the shared current window, one for the previous window.
+ expect(bodies.filter((b) => b.start_time === START && b.end_time === END)).toHaveLength(1);
+ expect(bodies).toHaveLength(2);
+ });
+});
+
+describe('KpiSparkline', () => {
+ it('centers a flat series instead of dividing by zero', () => {
+ const { container } = render(
+ ,
+ );
+ const paths = container.querySelectorAll('path');
+ expect(paths.length).toBe(2);
+ for (const d of [...paths].map((p) => p.getAttribute('d') ?? '')) {
+ // VIEW_H / 2 = 16 for every y coordinate.
+ expect(d.match(/,(\d+\.\d+)/g)?.every((m) => m === ',16.00')).toBe(true);
+ }
+ });
+
+ it('renders no svg with fewer than 2 points', () => {
+ const { container } = render();
+ expect(container.querySelector('svg')).toBeNull();
+ });
+});
+
+describe('KpiStrip', () => {
+ it('renders all five tiles with identical treatment (symmetry audit)', async () => {
+ const params = makeInstanceParams(() => []);
+ const { Wrapper } = makeWrapper(params);
+ render(, { wrapper: Wrapper });
+
+ // Every tile resolves to the same absent-data state: em-dash, no delta.
+ await waitFor(() => expect(screen.getAllByText('—')).toHaveLength(KPI_TILES.length));
+ for (const def of KPI_TILES) {
+ expect(screen.getByText(def.label)).toBeTruthy();
+ }
+ expect(screen.queryByText(/vs prev/)).toBeNull();
+ // 5 metrics × (current + previous window) POSTs, nothing more.
+ const post = params.instanceClient.post as unknown as ReturnType;
+ expect(post.mock.calls).toHaveLength(KPI_TILES.length * 2);
+ });
+});
diff --git a/src/features/instance/status/analytics/tabs/kpi/KpiTile.tsx b/src/features/instance/status/analytics/tabs/kpi/KpiTile.tsx
new file mode 100644
index 000000000..5935c1a7d
--- /dev/null
+++ b/src/features/instance/status/analytics/tabs/kpi/KpiTile.tsx
@@ -0,0 +1,65 @@
+import { Card } from '@/components/ui/card';
+import { Skeleton } from '@/components/ui/skeleton';
+import { formatValue } from '@/lib/formatValue';
+import { ArrowDown, ArrowUp, Minus } from 'lucide-react';
+import { formatWindowLabel, type KpiDelta } from './kpiMath';
+import { KpiSparkline } from './KpiSparkline';
+import type { KpiTileDef } from './kpiTiles';
+import { useKpiTileData } from './useKpiTileData';
+
+/** One stat tile: label, latest-bucket value, delta vs the previous window,
+ * current-window sparkline. Data absent → em-dash value, no delta, empty
+ * sparkline; loading → skeleton. */
+export function KpiTile({ def }: { def: KpiTileDef }) {
+ const { latest, delta, sparkPoints, isLoading, timeRange, windowMs } = useKpiTileData(def);
+
+ return (
+
+
+
{def.label}
+ {isLoading
+ ? (
+
+
+
+
+
+ )
+ : (
+ <>
+
+ {formatValue(latest, def.formatter)}
+
+
+
+ >
+ )}
+
+
+ );
+}
+
+/** Delta line under the value. All strip vitals are up-is-bad (see
+ * kpiTiles.ts), so up wears destructive and down wears green. Height is
+ * reserved when the delta is unavailable so the five tiles stay aligned. */
+function DeltaRow({ delta, windowMs }: { delta: KpiDelta | null; windowMs: number }) {
+ if (!delta) { return ; }
+ const windowLabel = formatWindowLabel(windowMs);
+ const pctText = `${delta.pct >= 0 ? '+' : ''}${delta.pct.toFixed(1)}%`;
+ const color = delta.direction === 'up'
+ ? 'text-destructive'
+ : delta.direction === 'down'
+ ? 'text-green'
+ : 'text-muted-foreground';
+ const Icon = delta.direction === 'up' ? ArrowUp : delta.direction === 'down' ? ArrowDown : Minus;
+ return (
+
+
+ {pctText}
+ vs prev {windowLabel}
+
+ );
+}
diff --git a/src/features/instance/status/analytics/tabs/kpi/kpiMath.test.ts b/src/features/instance/status/analytics/tabs/kpi/kpiMath.test.ts
new file mode 100644
index 000000000..a8e8e3c81
--- /dev/null
+++ b/src/features/instance/status/analytics/tabs/kpi/kpiMath.test.ts
@@ -0,0 +1,116 @@
+import { describe, expect, it } from 'vitest';
+import type { SeriesData } from '../../types/analytics';
+import { collapseSeries, computeDelta, formatWindowLabel, latestValue, windowMean } from './kpiMath';
+
+function seriesData(series: { key: string; points: { x: number; y: number | null }[] }[]): SeriesData {
+ return { series: series.map((s) => ({ ...s, label: s.key })) };
+}
+
+describe('collapseSeries', () => {
+ it('sums multiple series per bucket (CPU harper + user scopes)', () => {
+ const data = seriesData([
+ { key: 'harper', points: [{ x: 1, y: 0.2 }, { x: 2, y: 0.3 }] },
+ { key: 'user', points: [{ x: 1, y: 0.1 }, { x: 2, y: 0.2 }] },
+ ]);
+ expect(collapseSeries(data, 'sum')).toEqual([
+ { x: 1, y: expect.closeTo(0.3, 10) },
+ { x: 2, y: 0.5 },
+ ]);
+ });
+
+ it('means multiple series per bucket', () => {
+ const data = seriesData([
+ { key: 'a', points: [{ x: 1, y: 10 }] },
+ { key: 'b', points: [{ x: 1, y: 30 }] },
+ ]);
+ expect(collapseSeries(data, 'mean')).toEqual([{ x: 1, y: 20 }]);
+ });
+
+ it('drops null ys and omits buckets with no finite value', () => {
+ const data = seriesData([
+ { key: 'a', points: [{ x: 1, y: null }, { x: 2, y: 4 }, { x: 3, y: Number.NaN }] },
+ ]);
+ expect(collapseSeries(data, 'mean')).toEqual([{ x: 2, y: 4 }]);
+ });
+
+ it('sorts output by time even when series points interleave', () => {
+ const data = seriesData([
+ { key: 'a', points: [{ x: 3, y: 3 }, { x: 1, y: 1 }] },
+ { key: 'b', points: [{ x: 2, y: 2 }] },
+ ]);
+ expect(collapseSeries(data, 'mean').map((p) => p.x)).toEqual([1, 2, 3]);
+ });
+
+ it('returns [] for empty series data', () => {
+ expect(collapseSeries(seriesData([]), 'sum')).toEqual([]);
+ });
+});
+
+describe('latestValue / windowMean', () => {
+ it('latestValue reads the last (most recent) bucket', () => {
+ expect(latestValue([{ x: 1, y: 5 }, { x: 2, y: 7 }])).toBe(7);
+ });
+
+ it('both return null on an empty window', () => {
+ expect(latestValue([])).toBeNull();
+ expect(windowMean([])).toBeNull();
+ });
+
+ it('windowMean is the plain mean of bucket values', () => {
+ expect(windowMean([{ x: 1, y: 10 }, { x: 2, y: 20 }, { x: 3, y: 30 }])).toBe(20);
+ });
+});
+
+describe('computeDelta', () => {
+ it('is null when either window has no data', () => {
+ expect(computeDelta(null, 10)).toBeNull();
+ expect(computeDelta(10, null)).toBeNull();
+ expect(computeDelta(null, null)).toBeNull();
+ });
+
+ it('computes signed relative change with direction', () => {
+ expect(computeDelta(15, 10)).toEqual({ pct: 50, direction: 'up' });
+ expect(computeDelta(5, 10)).toEqual({ pct: -50, direction: 'down' });
+ });
+
+ it('previous=0, current=0 → flat 0% (a real "still zero" reading)', () => {
+ expect(computeDelta(0, 0)).toEqual({ pct: 0, direction: 'flat' });
+ });
+
+ it('previous=0 with nonzero current → null (relative change undefined)', () => {
+ expect(computeDelta(5, 0)).toBeNull();
+ });
+
+ it('uses |previous| so a negative baseline keeps the sign of the change', () => {
+ expect(computeDelta(-5, -10)).toEqual({ pct: 50, direction: 'up' });
+ });
+
+ it('treats sub-epsilon changes as flat', () => {
+ const d = computeDelta(10.000001, 10);
+ expect(d?.direction).toBe('flat');
+ });
+
+ it('rejects non-finite inputs', () => {
+ expect(computeDelta(Infinity, 10)).toBeNull();
+ expect(computeDelta(10, Number.NaN)).toBeNull();
+ });
+});
+
+describe('formatWindowLabel', () => {
+ const MIN = 60_000;
+ const HOUR = 60 * MIN;
+ const DAY = 24 * HOUR;
+
+ it('formats the shipped presets compactly', () => {
+ expect(formatWindowLabel(HOUR)).toBe('1h');
+ expect(formatWindowLabel(6 * HOUR)).toBe('6h');
+ expect(formatWindowLabel(DAY)).toBe('24h');
+ expect(formatWindowLabel(7 * DAY)).toBe('7d');
+ expect(formatWindowLabel(30 * DAY)).toBe('30d');
+ });
+
+ it('falls back to minutes for irregular windows', () => {
+ expect(formatWindowLabel(2 * MIN)).toBe('2m');
+ expect(formatWindowLabel(90 * MIN)).toBe('90m');
+ });
+});
diff --git a/src/features/instance/status/analytics/tabs/kpi/kpiMath.ts b/src/features/instance/status/analytics/tabs/kpi/kpiMath.ts
new file mode 100644
index 000000000..8e6f6fd80
--- /dev/null
+++ b/src/features/instance/status/analytics/tabs/kpi/kpiMath.ts
@@ -0,0 +1,93 @@
+// Pure math for the Health-tab KPI stat strip: collapse a pipeline SeriesData
+// into one cluster-level point list, then reduce it to the tile's headline
+// (latest bucket), window aggregate (mean of buckets), and the delta vs the
+// previous window. Kept free of React/query imports so it unit-tests plain.
+
+import type { SeriesData } from '../../types/analytics';
+
+export interface KpiPoint {
+ x: number;
+ y: number;
+}
+
+/** How multi-series pipeline output folds into the tile's single line.
+ * `sum` is for breakdowns whose parts add up to a meaningful total (CPU
+ * harper + user scopes); `mean` for everything else (and a no-op for
+ * single-series field specs). */
+export type KpiCombine = 'sum' | 'mean';
+
+/** Collapse cluster-aggregate SeriesData into one point per time bucket.
+ * Non-finite ys are dropped; a bucket with no finite value across all
+ * series is omitted entirely (the sparkline connects across it). */
+export function collapseSeries(data: SeriesData, combine: KpiCombine): KpiPoint[] {
+ const byX = new Map();
+ for (const s of data.series) {
+ for (const p of s.points) {
+ if (typeof p.y === 'number' && Number.isFinite(p.y)) {
+ let ys = byX.get(p.x);
+ if (!ys) {
+ ys = [];
+ byX.set(p.x, ys);
+ }
+ ys.push(p.y);
+ }
+ }
+ }
+ return [...byX.entries()]
+ .sort((a, b) => a[0] - b[0])
+ .map(([x, ys]) => {
+ const total = ys.reduce((a, b) => a + b, 0);
+ return { x, y: combine === 'sum' ? total : total / ys.length };
+ });
+}
+
+/** Headline value: the most recent bucket's y. Null when the window is empty. */
+export function latestValue(points: KpiPoint[]): number | null {
+ return points.length > 0 ? points[points.length - 1].y : null;
+}
+
+/** Window aggregate feeding the delta: plain mean of the bucket values.
+ * Buckets are period-spaced, so this approximates the time-mean without
+ * re-weighting. Null when the window is empty. */
+export function windowMean(points: KpiPoint[]): number | null {
+ if (points.length === 0) { return null; }
+ return points.reduce((a, p) => a + p.y, 0) / points.length;
+}
+
+export interface KpiDelta {
+ /** Relative change in percent: (current − previous) / |previous| × 100. */
+ pct: number;
+ direction: 'up' | 'down' | 'flat';
+}
+
+/** |pct| below this renders as 'flat' — a ±0.0% arrow is noise, not signal. */
+const FLAT_EPSILON_PCT = 0.05;
+
+/** Delta of the current window vs the previous window of equal length.
+ * Returns null when either window has no data, or when previous === 0 with
+ * a nonzero current (relative change is undefined — the tile shows no
+ * delta rather than an infinity). previous === current === 0 is a real
+ * "still zero" reading and reports flat 0%. */
+export function computeDelta(current: number | null, previous: number | null): KpiDelta | null {
+ if (current === null || previous === null) { return null; }
+ if (!Number.isFinite(current) || !Number.isFinite(previous)) { return null; }
+ if (previous === 0) {
+ return current === 0 ? { pct: 0, direction: 'flat' } : null;
+ }
+ const pct = ((current - previous) / Math.abs(previous)) * 100;
+ const direction = Math.abs(pct) < FLAT_EPSILON_PCT ? 'flat' : pct > 0 ? 'up' : 'down';
+ return { pct, direction };
+}
+
+const MIN = 60_000;
+const HOUR = 60 * MIN;
+const DAY = 24 * HOUR;
+
+/** Compact window-length label for the delta caption ("vs prev 6h").
+ * Whole hours up to 48h stay in hours so the 24h preset reads "24h";
+ * longer whole-day windows read in days; anything else rounds to minutes. */
+export function formatWindowLabel(durationMs: number): string {
+ if (durationMs % HOUR === 0 && durationMs <= 48 * HOUR) { return `${durationMs / HOUR}h`; }
+ if (durationMs % DAY === 0) { return `${durationMs / DAY}d`; }
+ return `${Math.max(1, Math.round(durationMs / MIN))}m`;
+}
diff --git a/src/features/instance/status/analytics/tabs/kpi/kpiTiles.test.ts b/src/features/instance/status/analytics/tabs/kpi/kpiTiles.test.ts
new file mode 100644
index 000000000..1f628b4ae
--- /dev/null
+++ b/src/features/instance/status/analytics/tabs/kpi/kpiTiles.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from 'vitest';
+import { runPipeline } from '../../pipeline/pipeline';
+import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics';
+import { collapseSeries } from './kpiMath';
+import { KPI_TILES } from './kpiTiles';
+
+const WINDOW: TimeRange = { startTime: 0, endTime: 600_000 };
+
+describe('KPI_TILES', () => {
+ it('defines the five vitals symmetrically (id, label, metric, spec, combine, formatter)', () => {
+ expect(KPI_TILES).toHaveLength(5);
+ expect(KPI_TILES.map((t) => t.id)).toEqual(['cpu', 'memory', 'main-thread', 'error-rate', 'p95-duration']);
+ expect(new Set(KPI_TILES.map((t) => t.metric)).size).toBe(5);
+ for (const t of KPI_TILES) {
+ expect(t.label.length).toBeGreaterThan(0);
+ expect(t.metric.length).toBeGreaterThan(0);
+ expect(['sum', 'mean']).toContain(t.combine);
+ expect(t.formatter.length).toBeGreaterThan(0);
+ expect(t.spec.aggregator).toBeDefined();
+ // Confidence suppression would blank a tile on quiet windows — the
+ // tiles show whatever data exists and let the panels do the gating.
+ expect(t.spec.confidence).toBeUndefined();
+ }
+ });
+
+ it('error-rate tile is Σ-correct (Σcount−Σtotal)/Σcount, not mean-of-ratios', () => {
+ // Canonical ratio-of-ratios fixture from pipeline/derived/error-rate.ts:
+ // Σ-correct answer ≈ 0.0188; the mean-of-ratios bug would report 0.455.
+ const def = KPI_TILES.find((t) => t.id === 'error-rate')!;
+ const records: AnalyticsDataPoint[] = [
+ { time: 60_000, node: 'n1', period: 60_000, count: 1000, total: 990 },
+ { time: 60_000, node: 'n1', period: 60_000, count: 10, total: 1 },
+ ];
+ const points = collapseSeries(runPipeline(def.spec, records, WINDOW, []), def.combine);
+ expect(points).toHaveLength(1);
+ expect(points[0].y).toBeCloseTo(19 / 1010, 10);
+ });
+
+ it('cpu tile sums the harper + user scopes into total process CPU', () => {
+ const def = KPI_TILES.find((t) => t.id === 'cpu')!;
+ const records: AnalyticsDataPoint[] = [
+ { time: 60_000, node: 'n1', period: 60_000, path: 'harper', count: 50, p95: 0.3 },
+ { time: 60_000, node: 'n1', period: 60_000, path: 'user', count: 50, p95: 0.2 },
+ ];
+ const points = collapseSeries(runPipeline(def.spec, records, WINDOW, []), def.combine);
+ expect(points).toHaveLength(1);
+ expect(points[0].y).toBeCloseTo(0.5, 10);
+ });
+
+ it('memory tile reads the last heapUsed per node and means across nodes', () => {
+ const def = KPI_TILES.find((t) => t.id === 'memory')!;
+ const records: AnalyticsDataPoint[] = [
+ { time: 60_000, node: 'n1', period: 60_000, heapUsed: 100 },
+ { time: 60_000, node: 'n2', period: 60_000, heapUsed: 300 },
+ ];
+ const points = collapseSeries(runPipeline(def.spec, records, WINDOW, []), def.combine);
+ expect(points).toEqual([{ x: 60_000, y: 200 }]);
+ });
+
+ it('main-thread tile projects active / (active + idle)', () => {
+ const def = KPI_TILES.find((t) => t.id === 'main-thread')!;
+ const records: AnalyticsDataPoint[] = [
+ { time: 60_000, node: 'n1', period: 60_000, active: 25, idle: 75 },
+ ];
+ const points = collapseSeries(runPipeline(def.spec, records, WINDOW, []), def.combine);
+ expect(points).toEqual([{ x: 60_000, y: 0.25 }]);
+ });
+
+ it('p95-duration tile count-weights p95 across paths', () => {
+ const def = KPI_TILES.find((t) => t.id === 'p95-duration')!;
+ const records: AnalyticsDataPoint[] = [
+ { time: 60_000, node: 'n1', period: 60_000, path: '/a', count: 900, p95: 10 },
+ { time: 60_000, node: 'n1', period: 60_000, path: '/b', count: 100, p95: 110 },
+ ];
+ const points = collapseSeries(runPipeline(def.spec, records, WINDOW, []), def.combine);
+ expect(points).toHaveLength(1);
+ expect(points[0].y).toBeCloseTo(20, 10); // (900·10 + 100·110) / 1000
+ });
+});
diff --git a/src/features/instance/status/analytics/tabs/kpi/kpiTiles.ts b/src/features/instance/status/analytics/tabs/kpi/kpiTiles.ts
new file mode 100644
index 000000000..ee79799d0
--- /dev/null
+++ b/src/features/instance/status/analytics/tabs/kpi/kpiTiles.ts
@@ -0,0 +1,148 @@
+// Tile definitions for the Health-tab KPI stat strip. Each tile names the
+// Harper metric it queries (the SAME metric string the corresponding panel
+// uses, so the current-window fetch dedupes with the panel's query key) and
+// a cluster-aggregate MetricSpec run through the shared runPipeline.
+//
+// Every vital here is up-is-bad (more CPU / memory / utilization / errors /
+// latency is worse), so KpiTile colors all up-deltas destructive and all
+// down-deltas green — there is no per-tile polarity flag on purpose; add one
+// if a tile ever lands where up is good.
+
+import type { ValueFormatter } from '@/lib/formatValue';
+import type { MetricSpec } from '../../types/analytics';
+import type { KpiCombine } from './kpiMath';
+
+export interface KpiTileDef {
+ id: string;
+ label: string;
+ /** Harper get_analytics metric name — must match the panel's source
+ * metric verbatim so react-query dedupes the current-window POST. */
+ metric: string;
+ spec: MetricSpec;
+ combine: KpiCombine;
+ formatter: ValueFormatter;
+}
+
+/** Shared boilerplate for the tile specs; the interesting part of each tile
+ * is its `series` + aggregators. title/description/tab/primitive are
+ * required by MetricSpec but unused by the strip (no panel chrome). */
+function tileSpec(spec: Pick & Partial): MetricSpec {
+ return {
+ title: '',
+ description: '',
+ tab: 'health',
+ primaryDimension: 'node',
+ timestamp: 'time',
+ bucket: { source: 'period-field', fallbackMs: 60000 },
+ primitive: 'line',
+ yAxis: { unit: '' },
+ ...spec,
+ };
+}
+
+export const KPI_TILES: readonly KpiTileDef[] = [
+ {
+ id: 'cpu',
+ label: 'CPU (p95)',
+ metric: 'cpu-usage',
+ // Per-scope (harper/user) count-weighted p95 — the panel's exact
+ // aggregation — then the scope series are SUMMED per bucket so the
+ // tile reads as total process CPU, not a mean of the two scopes.
+ spec: tileSpec({
+ series: {
+ kind: 'groupBy',
+ dimension: 'path',
+ field: { field: 'p95', label: 'CPU %' },
+ },
+ aggregator: { temporal: 'count-weighted-mean', crossNode: 'count-weighted-mean' },
+ }),
+ combine: 'sum',
+ formatter: 'percent',
+ },
+ {
+ id: 'memory',
+ label: 'Heap used',
+ metric: 'memory',
+ // Matches the Process memory panel: gauge semantics (temporal 'last'),
+ // mean across nodes.
+ spec: tileSpec({
+ series: { kind: 'field', fields: [{ field: 'heapUsed', label: 'heap used' }] },
+ aggregator: { temporal: 'last', crossNode: 'mean' },
+ }),
+ combine: 'mean',
+ formatter: 'bytes-si',
+ },
+ {
+ id: 'main-thread',
+ label: 'Main thread',
+ metric: 'main-thread-utilization',
+ // active / (active + idle), mean/mean — the panel's default
+ // Utilization field projection.
+ spec: tileSpec({
+ series: {
+ kind: 'field',
+ fields: [{
+ field: {
+ kind: 'op',
+ op: '/',
+ left: { kind: 'ref', field: 'active' },
+ right: {
+ kind: 'op',
+ op: '+',
+ left: { kind: 'ref', field: 'active' },
+ right: { kind: 'ref', field: 'idle' },
+ },
+ },
+ label: 'utilization',
+ }],
+ },
+ aggregator: { temporal: 'mean', crossNode: 'mean' },
+ }),
+ combine: 'mean',
+ formatter: 'percent',
+ },
+ {
+ id: 'error-rate',
+ label: 'Error rate',
+ metric: 'success',
+ // Σ-correct error rate: per-record 1 − total/count, count-weighted-mean
+ // with weight = count collapses to (Σcount − Σtotal)/Σcount exactly —
+ // the same arithmetic as the derived error-rate panel, NOT the
+ // mean-of-ratios bug it documents.
+ spec: tileSpec({
+ series: {
+ kind: 'field',
+ fields: [{
+ field: {
+ kind: 'op',
+ op: '-',
+ left: { kind: 'const', value: 1 },
+ right: {
+ kind: 'op',
+ op: '/',
+ left: { kind: 'ref', field: 'total' },
+ right: { kind: 'ref', field: 'count' },
+ },
+ },
+ label: 'error rate',
+ }],
+ },
+ aggregator: { temporal: 'count-weighted-mean', crossNode: 'count-weighted-mean' },
+ }),
+ combine: 'mean',
+ formatter: 'percent',
+ },
+ {
+ id: 'p95-duration',
+ label: 'Request p95',
+ metric: 'duration',
+ // Count-weighted p95 across all paths and nodes — the duration
+ // panel's aggregators without its top-10 path split.
+ spec: tileSpec({
+ series: { kind: 'field', fields: [{ field: 'p95', label: 'p95 duration (ms)' }] },
+ aggregator: { temporal: 'count-weighted-mean', crossNode: 'count-weighted-mean' },
+ }),
+ combine: 'mean',
+ formatter: 'ms',
+ },
+];
diff --git a/src/features/instance/status/analytics/tabs/kpi/useKpiTileData.ts b/src/features/instance/status/analytics/tabs/kpi/useKpiTileData.ts
new file mode 100644
index 000000000..7fb670800
--- /dev/null
+++ b/src/features/instance/status/analytics/tabs/kpi/useKpiTileData.ts
@@ -0,0 +1,82 @@
+import { useMemo } from 'react';
+import { useAnalyticsContext } from '../../context/AnalyticsContext';
+import { useAnalyticsRecords } from '../../hooks/useAnalyticsRecords';
+import { runPipeline } from '../../pipeline/pipeline';
+import type { TimeRange } from '../../types/analytics';
+import { collapseSeries, computeDelta, type KpiDelta, type KpiPoint, latestValue, windowMean } from './kpiMath';
+import type { KpiTileDef } from './kpiTiles';
+
+export interface KpiTileData {
+ /** Latest-bucket cluster value; null when the window has no data (the
+ * tile renders an em-dash and no delta). */
+ latest: number | null;
+ /** Current-window mean vs previous-window mean; null while the previous
+ * window is loading, errored, empty, or the ratio is undefined. */
+ delta: KpiDelta | null;
+ /** Collapsed current-window points feeding the sparkline. */
+ sparkPoints: KpiPoint[];
+ /** Current-window fetch in flight — the tile shows its skeleton. */
+ isLoading: boolean;
+ timeRange: TimeRange;
+ windowMs: number;
+}
+
+/** Data for one KPI tile: the current window plus the previous window of
+ * equal length, each through the shared spec pipeline.
+ *
+ * Query accounting — the strip's dedupe contract (#1457): the
+ * current-window call passes exactly the arguments MetricPanel passes
+ * (same metric string, same context timeRange/bucketMs, no conditions), so
+ * it lands on the panels' existing query key and react-query serves both
+ * from one POST. The previous-window call is the only new key — at most
+ * one extra POST per metric per window slide. Its key shares
+ * ANALYTICS_QUERY_KEY_PREFIX + instanceId, so StatusTabs' in-flight
+ * refresh guard covers it too. */
+export function useKpiTileData(def: KpiTileDef): KpiTileData {
+ const { timeRange, bucketMs, instanceParams } = useAnalyticsContext();
+ const windowMs = timeRange.endTime - timeRange.startTime;
+
+ const current = useAnalyticsRecords({
+ metric: def.metric,
+ startTime: timeRange.startTime,
+ endTime: timeRange.endTime,
+ instanceParams,
+ bucketMs,
+ });
+ const previous = useAnalyticsRecords({
+ metric: def.metric,
+ startTime: timeRange.startTime - windowMs,
+ endTime: timeRange.startTime,
+ instanceParams,
+ bucketMs,
+ });
+
+ const sparkPoints = useMemo(() => {
+ if (current.isError) { return []; }
+ return collapseSeries(
+ runPipeline(def.spec, current.data, timeRange, [], { snapToPeriod: true }),
+ def.combine,
+ );
+ }, [def, current.data, current.isError, timeRange]);
+
+ const previousMean = useMemo(() => {
+ if (previous.isLoading || previous.isError) { return null; }
+ const prevRange: TimeRange = {
+ startTime: timeRange.startTime - windowMs,
+ endTime: timeRange.startTime,
+ };
+ return windowMean(collapseSeries(
+ runPipeline(def.spec, previous.data, prevRange, [], { snapToPeriod: true }),
+ def.combine,
+ ));
+ }, [def, previous.data, previous.isLoading, previous.isError, timeRange.startTime, windowMs]);
+
+ return {
+ latest: latestValue(sparkPoints),
+ delta: computeDelta(windowMean(sparkPoints), previousMean),
+ sparkPoints,
+ isLoading: current.isLoading,
+ timeRange,
+ windowMs,
+ };
+}
From 94d8446f2800a2cb35970b499a16d6396b4d5005 Mon Sep 17 00:00:00 2001
From: Kyle Bernhardy
Date: Tue, 14 Jul 2026 22:15:39 -0600
Subject: [PATCH 16/92] fix(status): correct KPI CPU scope summing,
placeholder-phase deltas, and prev-window caching
- CPU tile: constrain the scope sum to the harper/user dims via a new
includeDims on KpiTileDef. The profiler also emits per-hot-function-
location cpu-usage records (>100 hits at one location) whose samples
are already counted inside the harper/user scope totals, so summing
every 'path' dim double-counted CPU on busy nodes. A bucket missing
an expected scope now gaps instead of presenting a partial sum as
the total.
- Delta: useAnalyticsRecords now exposes isPlaceholderData, and the
tile only computes a fresh delta when BOTH windows hold settled
(non-placeholder) data, holding the last settled delta otherwise.
Previously each refresh tick had a settle-gap where keepPreviousData
kept isLoading false while one side still held the older window's
rows, so computeDelta paired mismatched windows and flipped the
arrow through a bogus near-zero reading.
- Previous window: quantize its boundaries to the bucket grid so
consecutive refresh ticks within one bucket share a query key and
hit the staleTime-Infinity cache (the current-window args stay
byte-identical to MetricPanel's). Corrected the dedupe comments to
the honest accounting: the success/duration panels live on the
unmounted Requests tab, so those two current-window fetches are real
POSTs per tick that seed the cache for a Requests visit.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV
---
.../analytics/hooks/useAnalyticsRecords.ts | 7 ++
.../tabs/__tests__/ConnectionsPanel.test.tsx | 1 +
.../tabs/__tests__/MetricPanel.test.tsx | 1 +
.../tabs/__tests__/StorageTab.test.tsx | 2 +
.../tabs/__tests__/tabs.smoke.test.tsx | 3 +
.../analytics/tabs/kpi/KpiTile.test.tsx | 97 +++++++++++++++++++
.../status/analytics/tabs/kpi/kpiMath.test.ts | 24 ++++-
.../status/analytics/tabs/kpi/kpiMath.ts | 32 ++++--
.../analytics/tabs/kpi/kpiTiles.test.ts | 32 +++++-
.../status/analytics/tabs/kpi/kpiTiles.ts | 24 ++++-
.../analytics/tabs/kpi/useKpiTileData.ts | 58 ++++++++---
11 files changed, 251 insertions(+), 30 deletions(-)
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/__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/KpiTile.test.tsx b/src/features/instance/status/analytics/tabs/kpi/KpiTile.test.tsx
index f4e1efa4e..e5d1a42a1 100644
--- a/src/features/instance/status/analytics/tabs/kpi/KpiTile.test.tsx
+++ b/src/features/instance/status/analytics/tabs/kpi/KpiTile.test.tsx
@@ -176,6 +176,103 @@ describe('KpiTile', () => {
expect(bodies.filter((b) => b.start_time === START && b.end_time === END)).toHaveLength(1);
expect(bodies).toHaveLength(2);
});
+
+ it('holds the last settled delta while a window slide is in flight (placeholder phase)', async () => {
+ // Slide one bucket forward: current [300s, 420s], quantized previous
+ // [180s, 300s]. The new previous settles immediately while the new
+ // current stays gated — the exact phase where pairing placeholder
+ // current data (old window) with fresh previous data flips the delta
+ // through a bogus near-zero reading.
+ const START2 = START + BUCKET;
+ const END2 = END + BUCKET;
+ const CUR2_ROWS = [
+ { time: 360_000, node: 'n1', period: 60_000, path: '/a', count: 100, p95: 20 },
+ { time: 420_000, node: 'n1', period: 60_000, path: '/a', count: 100, p95: 40 },
+ ];
+ // Mean 15 — equal to the old current window's mean, so a mismatched
+ // pair would compute a flat +0.0% delta.
+ const PREV2_ROWS = [
+ { time: 240_000, node: 'n1', period: 60_000, path: '/a', count: 100, p95: 15 },
+ ];
+ let releaseCurrent!: () => void;
+ const currentGate = new Promise((resolve) => {
+ releaseCurrent = resolve;
+ });
+ const params = makeInstanceParams(async (body) => {
+ if (body.end_time === END2) {
+ await currentGate;
+ return CUR2_ROWS;
+ }
+ if (body.end_time === START2) { return PREV2_ROWS; }
+ return twoWindowHandler(body);
+ });
+ const { client } = makeWrapper(params);
+ const tile = (start: number, end: number) => (
+
+
+
+
+
+ );
+ const { rerender } = render(tile(START, END));
+ await waitFor(() => expect(screen.getByText('+50.0%')).toBeTruthy());
+
+ rerender(tile(START2, END2));
+ // Wait until the new previous window has settled; the current window
+ // is still placeholder data from the old window.
+ const prev2Key = getRawAnalyticsQueryOptions({
+ metric: durationTile.metric,
+ startTime: START2 - (END - START),
+ endTime: START2,
+ instanceParams: params,
+ bucketMs: BUCKET,
+ }).queryKey;
+ await waitFor(() => expect(client.getQueryState(prev2Key)?.status).toBe('success'));
+ // Held delta from the last settled pair — not the mismatched +0.0%.
+ expect(screen.getByText('+50.0%')).toBeTruthy();
+ expect(screen.queryByText('+0.0%')).toBeNull();
+
+ releaseCurrent();
+ // Both sides settled: fresh pair, means 30 vs 15 → +100%.
+ await waitFor(() => expect(screen.getByText('40.0 ms')).toBeTruthy());
+ expect(screen.getByText('+100.0%')).toBeTruthy();
+ });
+
+ it('quantizes the previous-window key to the bucket grid so ticks within one bucket cache-hit', async () => {
+ const params = makeInstanceParams(() => []);
+ const { client } = makeWrapper(params);
+ const post = params.instanceClient.post as unknown as ReturnType;
+ const bodies = () => post.mock.calls.map((c) => c[1] as PostBody);
+ const tile = (start: number, end: number) => (
+
+
+
+
+
+ );
+
+ // Unaligned window [250s, 370s] → previous quantized to [120s, 240s].
+ const { rerender } = render(tile(250_000, 370_000));
+ await waitFor(() => expect(bodies()).toHaveLength(2));
+ expect(bodies()).toContainEqual(expect.objectContaining({ start_time: 120_000, end_time: 240_000 }));
+
+ // A refresh tick within the same bucket ([280s, 400s] still floors to
+ // 240s) re-fetches only the current window; the previous-window key is
+ // unchanged and served from the staleTime-Infinity cache.
+ rerender(tile(280_000, 400_000));
+ await waitFor(() => expect(bodies()).toHaveLength(3));
+ expect(bodies().filter((b) => b.end_time === 240_000)).toHaveLength(1);
+
+ // Crossing a bucket boundary ([310s, 430s] floors to 300s) issues a
+ // fresh previous-window POST for [180s, 300s].
+ rerender(tile(310_000, 430_000));
+ await waitFor(() => expect(bodies()).toHaveLength(5));
+ expect(bodies()).toContainEqual(expect.objectContaining({ start_time: 180_000, end_time: 300_000 }));
+ });
});
describe('KpiSparkline', () => {
diff --git a/src/features/instance/status/analytics/tabs/kpi/kpiMath.test.ts b/src/features/instance/status/analytics/tabs/kpi/kpiMath.test.ts
index a8e8e3c81..b1a013aa9 100644
--- a/src/features/instance/status/analytics/tabs/kpi/kpiMath.test.ts
+++ b/src/features/instance/status/analytics/tabs/kpi/kpiMath.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { SeriesData } from '../../types/analytics';
import { collapseSeries, computeDelta, formatWindowLabel, latestValue, windowMean } from './kpiMath';
-function seriesData(series: { key: string; points: { x: number; y: number | null }[] }[]): SeriesData {
+function seriesData(series: { key: string; dim?: string; points: { x: number; y: number | null }[] }[]): SeriesData {
return { series: series.map((s) => ({ ...s, label: s.key })) };
}
@@ -44,6 +44,28 @@ describe('collapseSeries', () => {
it('returns [] for empty series data', () => {
expect(collapseSeries(seriesData([]), 'sum')).toEqual([]);
});
+
+ it('includeDims excludes series outside the declared total', () => {
+ const data = seriesData([
+ { key: 'harper', dim: 'harper', points: [{ x: 1, y: 0.2 }] },
+ { key: 'user', dim: 'user', points: [{ x: 1, y: 0.1 }] },
+ // Profiler hot-location series — already counted inside the scopes.
+ { key: '/some/fn', dim: '/some/fn', points: [{ x: 1, y: 0.15 }] },
+ ]);
+ expect(collapseSeries(data, 'sum', ['harper', 'user'])).toEqual([
+ { x: 1, y: expect.closeTo(0.3, 10) },
+ ]);
+ });
+
+ it('includeDims gaps a bucket missing an expected dim instead of summing partially', () => {
+ const data = seriesData([
+ { key: 'harper', dim: 'harper', points: [{ x: 1, y: 0.2 }, { x: 2, y: 0.4 }] },
+ { key: 'user', dim: 'user', points: [{ x: 1, y: 0.1 }] },
+ ]);
+ expect(collapseSeries(data, 'sum', ['harper', 'user'])).toEqual([
+ { x: 1, y: expect.closeTo(0.3, 10) },
+ ]);
+ });
});
describe('latestValue / windowMean', () => {
diff --git a/src/features/instance/status/analytics/tabs/kpi/kpiMath.ts b/src/features/instance/status/analytics/tabs/kpi/kpiMath.ts
index 8e6f6fd80..1273008f3 100644
--- a/src/features/instance/status/analytics/tabs/kpi/kpiMath.ts
+++ b/src/features/instance/status/analytics/tabs/kpi/kpiMath.ts
@@ -18,24 +18,36 @@ export type KpiCombine = 'sum' | 'mean';
/** Collapse cluster-aggregate SeriesData into one point per time bucket.
* Non-finite ys are dropped; a bucket with no finite value across all
- * series is omitted entirely (the sparkline connects across it). */
-export function collapseSeries(data: SeriesData, combine: KpiCombine): KpiPoint[] {
- const byX = new Map();
- for (const s of data.series) {
+ * series is omitted entirely (the sparkline connects across it).
+ *
+ * `includeDims` declares the exact dimension values that constitute the
+ * tile's total: series whose `dim` is not listed are excluded (e.g. the
+ * profiler's per-hot-function-location cpu-usage records, whose samples are
+ * already counted inside the harper/user scope totals), and a bucket
+ * missing any listed dim is omitted rather than collapsed into a partial
+ * value presented as the total. */
+export function collapseSeries(data: SeriesData, combine: KpiCombine, includeDims?: readonly string[]): KpiPoint[] {
+ const series = includeDims
+ ? data.series.filter((s) => s.dim !== undefined && includeDims.includes(s.dim))
+ : data.series;
+ const byX = new Map }>();
+ for (const s of series) {
for (const p of s.points) {
if (typeof p.y === 'number' && Number.isFinite(p.y)) {
- let ys = byX.get(p.x);
- if (!ys) {
- ys = [];
- byX.set(p.x, ys);
+ let bucket = byX.get(p.x);
+ if (!bucket) {
+ bucket = { ys: [], dims: new Set() };
+ byX.set(p.x, bucket);
}
- ys.push(p.y);
+ bucket.ys.push(p.y);
+ if (s.dim !== undefined) { bucket.dims.add(s.dim); }
}
}
}
return [...byX.entries()]
+ .filter(([, { dims }]) => !includeDims || dims.size === includeDims.length)
.sort((a, b) => a[0] - b[0])
- .map(([x, ys]) => {
+ .map(([x, { ys }]) => {
const total = ys.reduce((a, b) => a + b, 0);
return { x, y: combine === 'sum' ? total : total / ys.length };
});
diff --git a/src/features/instance/status/analytics/tabs/kpi/kpiTiles.test.ts b/src/features/instance/status/analytics/tabs/kpi/kpiTiles.test.ts
index 1f628b4ae..55727c287 100644
--- a/src/features/instance/status/analytics/tabs/kpi/kpiTiles.test.ts
+++ b/src/features/instance/status/analytics/tabs/kpi/kpiTiles.test.ts
@@ -42,8 +42,38 @@ describe('KPI_TILES', () => {
{ time: 60_000, node: 'n1', period: 60_000, path: 'harper', count: 50, p95: 0.3 },
{ time: 60_000, node: 'n1', period: 60_000, path: 'user', count: 50, p95: 0.2 },
];
- const points = collapseSeries(runPipeline(def.spec, records, WINDOW, []), def.combine);
+ const points = collapseSeries(runPipeline(def.spec, records, WINDOW, []), def.combine, def.includeDims);
+ expect(points).toHaveLength(1);
+ expect(points[0].y).toBeCloseTo(0.5, 10);
+ });
+
+ it('cpu tile excludes hot-function-location records from the scope sum', () => {
+ const def = KPI_TILES.find((t) => t.id === 'cpu')!;
+ const records: AnalyticsDataPoint[] = [
+ { time: 60_000, node: 'n1', period: 60_000, path: 'harper', count: 50, p95: 0.3 },
+ { time: 60_000, node: 'n1', period: 60_000, path: 'user', count: 50, p95: 0.2 },
+ // Profiler hot-location record (>100 hits at one code location):
+ // its samples are already counted in the harper/user totals, so
+ // summing its series would double-count CPU on busy nodes.
+ { time: 60_000, node: 'n1', period: 60_000, path: '/app/resources.js:42', count: 120, p95: 0.25 },
+ ];
+ const points = collapseSeries(runPipeline(def.spec, records, WINDOW, []), def.combine, def.includeDims);
+ expect(points).toHaveLength(1);
+ expect(points[0].y).toBeCloseTo(0.5, 10);
+ });
+
+ it('cpu tile gaps a bucket missing one scope instead of reporting a partial sum as the total', () => {
+ const def = KPI_TILES.find((t) => t.id === 'cpu')!;
+ const records: AnalyticsDataPoint[] = [
+ { time: 60_000, node: 'n1', period: 60_000, path: 'harper', count: 50, p95: 0.3 },
+ { time: 60_000, node: 'n1', period: 60_000, path: 'user', count: 50, p95: 0.2 },
+ // Second bucket only has the harper scope — harper-alone is not
+ // total process CPU, so the bucket must gap, not read as 0.4.
+ { time: 120_000, node: 'n1', period: 60_000, path: 'harper', count: 50, p95: 0.4 },
+ ];
+ const points = collapseSeries(runPipeline(def.spec, records, WINDOW, []), def.combine, def.includeDims);
expect(points).toHaveLength(1);
+ expect(points[0].x).toBe(60_000);
expect(points[0].y).toBeCloseTo(0.5, 10);
});
diff --git a/src/features/instance/status/analytics/tabs/kpi/kpiTiles.ts b/src/features/instance/status/analytics/tabs/kpi/kpiTiles.ts
index ee79799d0..c9687d503 100644
--- a/src/features/instance/status/analytics/tabs/kpi/kpiTiles.ts
+++ b/src/features/instance/status/analytics/tabs/kpi/kpiTiles.ts
@@ -1,7 +1,14 @@
// Tile definitions for the Health-tab KPI stat strip. Each tile names the
// Harper metric it queries (the SAME metric string the corresponding panel
-// uses, so the current-window fetch dedupes with the panel's query key) and
-// a cluster-aggregate MetricSpec run through the shared runPipeline.
+// uses, so the current-window key matches the panel's query key) and a
+// cluster-aggregate MetricSpec run through the shared runPipeline.
+//
+// The key match only dedupes the POST when that panel is mounted (or its
+// window is still cached): cpu-usage / memory / main-thread-utilization
+// panels live on the Health tab alongside the strip, but success and
+// duration panels live on the Requests tab — while Health is shown those
+// two current-window fetches are real POSTs (which seed the cache for a
+// Requests visit). See useKpiTileData for the full per-tick accounting.
//
// Every vital here is up-is-bad (more CPU / memory / utilization / errors /
// latency is worse), so KpiTile colors all up-deltas destructive and all
@@ -16,10 +23,16 @@ export interface KpiTileDef {
id: string;
label: string;
/** Harper get_analytics metric name — must match the panel's source
- * metric verbatim so react-query dedupes the current-window POST. */
+ * metric verbatim so the current-window query key coincides with the
+ * panel's (deduped when that panel is mounted, shared cache otherwise). */
metric: string;
spec: MetricSpec;
combine: KpiCombine;
+ /** For groupBy specs whose dimension carries extra values beyond the
+ * ones that add up to the tile's total: the exact dims to collapse.
+ * Passed through to collapseSeries — other dims are excluded and a
+ * bucket missing any listed dim gaps instead of summing partially. */
+ includeDims?: readonly string[];
formatter: ValueFormatter;
}
@@ -48,6 +61,10 @@ export const KPI_TILES: readonly KpiTileDef[] = [
// Per-scope (harper/user) count-weighted p95 — the panel's exact
// aggregation — then the scope series are SUMMED per bucket so the
// tile reads as total process CPU, not a mean of the two scopes.
+ // includeDims pins the sum to those two scopes: the profiler also
+ // emits per-hot-function-location cpu-usage records (>100 hits at
+ // one location) whose samples are ALREADY counted in the harper/user
+ // totals — summing every 'path' dim would double-count them.
spec: tileSpec({
series: {
kind: 'groupBy',
@@ -57,6 +74,7 @@ export const KPI_TILES: readonly KpiTileDef[] = [
aggregator: { temporal: 'count-weighted-mean', crossNode: 'count-weighted-mean' },
}),
combine: 'sum',
+ includeDims: ['harper', 'user'],
formatter: 'percent',
},
{
diff --git a/src/features/instance/status/analytics/tabs/kpi/useKpiTileData.ts b/src/features/instance/status/analytics/tabs/kpi/useKpiTileData.ts
index 7fb670800..d18cd1239 100644
--- a/src/features/instance/status/analytics/tabs/kpi/useKpiTileData.ts
+++ b/src/features/instance/status/analytics/tabs/kpi/useKpiTileData.ts
@@ -1,4 +1,4 @@
-import { useMemo } from 'react';
+import { useMemo, useRef } from 'react';
import { useAnalyticsContext } from '../../context/AnalyticsContext';
import { useAnalyticsRecords } from '../../hooks/useAnalyticsRecords';
import { runPipeline } from '../../pipeline/pipeline';
@@ -11,7 +11,10 @@ export interface KpiTileData {
* tile renders an em-dash and no delta). */
latest: number | null;
/** Current-window mean vs previous-window mean; null while the previous
- * window is loading, errored, empty, or the ratio is undefined. */
+ * window is loading, errored, empty, or the ratio is undefined. Only
+ * recomputed when BOTH windows hold settled (non-placeholder) data —
+ * while a window slide is in flight the last settled delta is held, so
+ * a mismatched current/previous pair never flips the arrow. */
delta: KpiDelta | null;
/** Collapsed current-window points feeding the sparkline. */
sparkPoints: KpiPoint[];
@@ -24,17 +27,29 @@ export interface KpiTileData {
/** Data for one KPI tile: the current window plus the previous window of
* equal length, each through the shared spec pipeline.
*
- * Query accounting — the strip's dedupe contract (#1457): the
+ * Query accounting (#1457), per refresh tick while Health is shown: the
* current-window call passes exactly the arguments MetricPanel passes
* (same metric string, same context timeRange/bucketMs, no conditions), so
- * it lands on the panels' existing query key and react-query serves both
- * from one POST. The previous-window call is the only new key — at most
- * one extra POST per metric per window slide. Its key shares
+ * it lands on the panels' query key — deduped for the three metrics whose
+ * panels are mounted on Health (cpu-usage, memory,
+ * main-thread-utilization), but a real POST for success and duration,
+ * whose panels live on the (unmounted) Requests tab; those fetches seed
+ * the cache for a Requests visit. The previous-window keys are quantized
+ * to the bucket grid below, so they only re-fetch (5 POSTs) when the
+ * sliding window crosses a bucket boundary — consecutive ticks within one
+ * bucket hit the staleTime-Infinity cache. Every key shares
* ANALYTICS_QUERY_KEY_PREFIX + instanceId, so StatusTabs' in-flight
- * refresh guard covers it too. */
+ * refresh guard covers them all. */
export function useKpiTileData(def: KpiTileDef): KpiTileData {
const { timeRange, bucketMs, instanceParams } = useAnalyticsContext();
const windowMs = timeRange.endTime - timeRange.startTime;
+ // The previous window is historical, so its exact boundaries are
+ // cosmetic — quantize them to the bucket grid so every tick within one
+ // bucket produces the same query key (a cache hit, not a POST). The
+ // current window must NOT be quantized: its args must stay byte-identical
+ // to MetricPanel's for the key to coincide.
+ const prevEnd = Math.floor(timeRange.startTime / bucketMs) * bucketMs;
+ const prevStart = prevEnd - windowMs;
const current = useAnalyticsRecords({
metric: def.metric,
@@ -45,8 +60,8 @@ export function useKpiTileData(def: KpiTileDef): KpiTileData {
});
const previous = useAnalyticsRecords({
metric: def.metric,
- startTime: timeRange.startTime - windowMs,
- endTime: timeRange.startTime,
+ startTime: prevStart,
+ endTime: prevEnd,
instanceParams,
bucketMs,
});
@@ -56,24 +71,37 @@ export function useKpiTileData(def: KpiTileDef): KpiTileData {
return collapseSeries(
runPipeline(def.spec, current.data, timeRange, [], { snapToPeriod: true }),
def.combine,
+ def.includeDims,
);
}, [def, current.data, current.isError, timeRange]);
const previousMean = useMemo(() => {
if (previous.isLoading || previous.isError) { return null; }
- const prevRange: TimeRange = {
- startTime: timeRange.startTime - windowMs,
- endTime: timeRange.startTime,
- };
+ const prevRange: TimeRange = { startTime: prevStart, endTime: prevEnd };
return windowMean(collapseSeries(
runPipeline(def.spec, previous.data, prevRange, [], { snapToPeriod: true }),
def.combine,
+ def.includeDims,
));
- }, [def, previous.data, previous.isLoading, previous.isError, timeRange.startTime, windowMs]);
+ }, [def, previous.data, previous.isLoading, previous.isError, prevStart, prevEnd]);
+
+ // keepPreviousData keeps isLoading false while `data` still holds the
+ // OLDER window's rows during a window slide, so a delta computed then
+ // would pair mismatched windows (one side settles before the other) and
+ // flip the arrow through a bogus near-zero reading. Only compute a fresh
+ // delta from a settled pair; hold the last one otherwise.
+ const lastSettledDelta = useRef(null);
+ let delta: KpiDelta | null;
+ if (current.isPlaceholderData || previous.isPlaceholderData) {
+ delta = lastSettledDelta.current;
+ } else {
+ delta = computeDelta(windowMean(sparkPoints), previousMean);
+ lastSettledDelta.current = delta;
+ }
return {
latest: latestValue(sparkPoints),
- delta: computeDelta(windowMean(sparkPoints), previousMean),
+ delta,
sparkPoints,
isLoading: current.isLoading,
timeRange,
From f51b7e900cdaa2b1b9f063cd3eb67db9ad54e7b9 Mon Sep 17 00:00:00 2001
From: Kyle Bernhardy
Date: Tue, 14 Jul 2026 22:23:04 -0600
Subject: [PATCH 17/92] fix(status): unsign zero-rounding deltas, role=img on
delta row, skip empty sparkline path (review)
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV
---
.../analytics/tabs/kpi/KpiSparkline.tsx | 24 ++++++++++++-------
.../analytics/tabs/kpi/KpiTile.test.tsx | 4 +++-
.../status/analytics/tabs/kpi/KpiTile.tsx | 7 +++++-
3 files changed, 24 insertions(+), 11 deletions(-)
diff --git a/src/features/instance/status/analytics/tabs/kpi/KpiSparkline.tsx b/src/features/instance/status/analytics/tabs/kpi/KpiSparkline.tsx
index 82238228a..f89a6080c 100644
--- a/src/features/instance/status/analytics/tabs/kpi/KpiSparkline.tsx
+++ b/src/features/instance/status/analytics/tabs/kpi/KpiSparkline.tsx
@@ -49,15 +49,21 @@ export function KpiSparkline({ points, xDomain }: Props) {
aria-hidden="true"
focusable="false"
>
-
+ {
+ /* Two points = only the accent segment below; a one-coord path
+ would be an invisible MoveTo-only element. */
+ }
+ {points.length > 2 && (
+
+ )}
{
,
);
const paths = container.querySelectorAll('path');
- expect(paths.length).toBe(2);
+ // Two points render only the accent segment (the base path would be a
+ // MoveTo-only element).
+ expect(paths.length).toBe(1);
for (const d of [...paths].map((p) => p.getAttribute('d') ?? '')) {
// VIEW_H / 2 = 16 for every y coordinate.
expect(d.match(/,(\d+\.\d+)/g)?.every((m) => m === ',16.00')).toBe(true);
diff --git a/src/features/instance/status/analytics/tabs/kpi/KpiTile.tsx b/src/features/instance/status/analytics/tabs/kpi/KpiTile.tsx
index 5935c1a7d..5fc96e931 100644
--- a/src/features/instance/status/analytics/tabs/kpi/KpiTile.tsx
+++ b/src/features/instance/status/analytics/tabs/kpi/KpiTile.tsx
@@ -45,7 +45,11 @@ export function KpiTile({ def }: { def: KpiTileDef }) {
function DeltaRow({ delta, windowMs }: { delta: KpiDelta | null; windowMs: number }) {
if (!delta) { return ; }
const windowLabel = formatWindowLabel(windowMs);
- const pctText = `${delta.pct >= 0 ? '+' : ''}${delta.pct.toFixed(1)}%`;
+ // Unsign a pct that ROUNDS to zero — "+0.0%" reads as a real move.
+ const rounded = delta.pct.toFixed(1);
+ const pctText = rounded === '0.0' || rounded === '-0.0'
+ ? '0.0%'
+ : `${delta.pct >= 0 ? '+' : ''}${rounded}%`;
const color = delta.direction === 'up'
? 'text-destructive'
: delta.direction === 'down'
@@ -54,6 +58,7 @@ function DeltaRow({ delta, windowMs }: { delta: KpiDelta | null; windowMs: numbe
const Icon = delta.direction === 'up' ? ArrowUp : delta.direction === 'down' ? ArrowDown : Minus;
return (
From 4d4ecc13e5d0178708ab9fe9c81db3aa1cc27977 Mon Sep 17 00:00:00 2001
From: Kyle Bernhardy
Date: Tue, 14 Jul 2026 20:55:08 -0600
Subject: [PATCH 18/92] feat(status): drill into node-pair series from
replication heatmap cells
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Clicking (or pressing Enter/Space on) a data-bearing replication-heatmap
cell now opens a near-fullscreen dialog titled `source → target` with
that pair's latency line chart over the selected window and quantile.
- HeatmapMatrix gains a generic optional onCellSelect(row, col) prop.
Click and Enter/Space share one isActivatable guard: only 'ok'/'grey'
cells fire; suppressed/absent cells render aria-disabled and Space is
consumed so it can't scroll the page. The hidden grid description
announces the interaction when a handler is present.
- New HeatmapDrilldownDialog reuses the ChartExpandButton dialog shell
(95vw × 90vh flex column) as a controlled component.
- The replication renderer keeps the drilldown series live across
quantile/window changes, re-applies the greyBelow confidence gate on
refresh (sub-threshold pairs show a 'series hidden' notice instead of
leaking data), and renders the dialog in every branch so a background
refresh that collapses the matrix to the line fallback doesn't unmount
an open drilldown. The heatmap's measureLabel now tracks the selected
quantile.
- Onboarding hint advertises the new interaction; storage key bumped to
v2 so previously-dismissed users see the updated tip once.
Fixes #1455
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV
---
.../pipeline/replication-drilldown.test.tsx | 182 ++++++++++++++++++
.../primitives/heatmap-matrix.test.tsx | 84 +++++++-
.../AnalyticsOnboardingHint.test.tsx | 15 +-
.../components/AnalyticsOnboardingHint.tsx | 8 +-
.../components/HeatmapDrilldownDialog.tsx | 39 ++++
.../pipeline/replication-latency.tsx | 75 +++++++-
.../analytics/primitives/HeatmapMatrix.tsx | 38 +++-
7 files changed, 431 insertions(+), 10 deletions(-)
create mode 100644 src/features/instance/status/analytics/__tests__/pipeline/replication-drilldown.test.tsx
create mode 100644 src/features/instance/status/analytics/components/HeatmapDrilldownDialog.tsx
diff --git a/src/features/instance/status/analytics/__tests__/pipeline/replication-drilldown.test.tsx b/src/features/instance/status/analytics/__tests__/pipeline/replication-drilldown.test.tsx
new file mode 100644
index 000000000..e257d64e4
--- /dev/null
+++ b/src/features/instance/status/analytics/__tests__/pipeline/replication-drilldown.test.tsx
@@ -0,0 +1,182 @@
+// @vitest-environment jsdom
+// Drilldown from a replication-heatmap cell into the node-pair time series
+// (issue #1455): click / Enter on a data cell opens a dialog titled
+// `source → target` with the pair's latency line chart.
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+import { ReplicationLatencyRenderer } from '../../pipeline/replication-latency';
+import type { AnalyticsDataPoint } from '../../types/analytics';
+
+const NODES = ['node-a', 'node-b'];
+
+function mk(source: string, dest: string, time: number, p95: number): AnalyticsDataPoint {
+ return { path: `${source}.db.tbl`, node: dest, time, p95, median: p95 / 2, count: 120, period: 60 };
+}
+
+// 2×2 matrix (heatmap branch: rows ≥ 2, cols ≥ 2, cells ≤ 12) with both
+// diagonal cells absent (a node never replicates to itself here).
+const records: AnalyticsDataPoint[] = [
+ mk('node-a', 'node-b', 1_000, 10),
+ mk('node-a', 'node-b', 2_000, 12),
+ mk('node-b', 'node-a', 1_000, 20),
+ mk('node-b', 'node-a', 2_000, 22),
+];
+
+function renderIt() {
+ return render(
+ ,
+ );
+}
+
+function findCell(re: RegExp) {
+ return screen.getAllByRole('gridcell').find((c) => re.test(c.getAttribute('aria-label') ?? ''))!;
+}
+
+afterEach(() => cleanup());
+
+describe('replication heatmap cell drilldown', () => {
+ it('clicking a data cell opens a dialog titled "source → target"', () => {
+ renderIt();
+ expect(screen.queryByRole('dialog')).toBeNull();
+ fireEvent.click(findCell(/node-a.*node-b/));
+ const dialog = screen.getByRole('dialog');
+ expect(dialog).toBeTruthy();
+ expect(screen.getByRole('heading', { name: 'node-a → node-b' })).toBeTruthy();
+ });
+
+ it('the dialog reflects the pair that was clicked', () => {
+ renderIt();
+ fireEvent.click(findCell(/node-b.*node-a/));
+ expect(screen.getByRole('heading', { name: 'node-b → node-a' })).toBeTruthy();
+ expect(screen.queryByRole('heading', { name: 'node-a → node-b' })).toBeNull();
+ });
+
+ it('Enter on the focused cell opens the dialog (keyboard parity)', () => {
+ renderIt();
+ const cell = findCell(/node-a.*node-b/);
+ (cell as HTMLElement).focus();
+ fireEvent.keyDown(cell, { key: 'Enter' });
+ expect(screen.getByRole('dialog')).toBeTruthy();
+ expect(screen.getByRole('heading', { name: 'node-a → node-b' })).toBeTruthy();
+ });
+
+ it('absent cells are aria-disabled and do not open the dialog', () => {
+ renderIt();
+ const absent = findCell(/node-a.*node-a/);
+ expect(absent.getAttribute('data-confidence')).toBe('absent');
+ expect(absent.getAttribute('aria-disabled')).toBe('true');
+ fireEvent.click(absent);
+ (absent as HTMLElement).focus();
+ fireEvent.keyDown(absent, { key: 'Enter' });
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ it('dialog description names the currently selected quantile', () => {
+ renderIt();
+ // Switch quantile to p50 (Harper field `median`) before drilling in.
+ const p50 = screen.getAllByTestId('quantile-button').find((b) => b.getAttribute('data-value') === 'median')!;
+ fireEvent.click(p50);
+ fireEvent.click(findCell(/node-a.*node-b/));
+ expect(screen.getByText(/p50 replication latency/i)).toBeTruthy();
+ });
+
+ it('a pair whose records all lack a numeric time opens with the empty-chart state', () => {
+ // Same 2×2 shape, but every node-a→node-b record has a bogus `time` —
+ // the cell still renders (matrix ignores time) yet the pair series is
+ // empty, so the dialog must fall through to "No data in window".
+ const noTime = [
+ { ...mk('node-a', 'node-b', 0, 10), time: 'bogus' as unknown as number },
+ { ...mk('node-a', 'node-b', 0, 12), time: 'bogus' as unknown as number },
+ mk('node-b', 'node-a', 1_000, 20),
+ ];
+ render(
+ ,
+ );
+ fireEvent.click(findCell(/node-a.*node-b/));
+ const dialog = screen.getByRole('dialog');
+ expect(screen.getByRole('heading', { name: 'node-a → node-b' })).toBeTruthy();
+ expect(dialog.textContent).toMatch(/No data in window/);
+ });
+
+ it('keeps the dialog open and tracking the pair when records refresh', () => {
+ const { rerender } = renderIt();
+ fireEvent.click(findCell(/node-a.*node-b/));
+ expect(screen.getByRole('heading', { name: 'node-a → node-b' })).toBeTruthy();
+ // Background poll delivers a new window where the drilled pair has no
+ // timestamped records — the dialog stays open and shows the empty state.
+ const refreshed = [
+ { ...mk('node-a', 'node-b', 0, 11), time: 'bogus' as unknown as number },
+ mk('node-b', 'node-a', 3_000, 24),
+ ];
+ rerender(
+ ,
+ );
+ const dialog = screen.getByRole('dialog');
+ expect(screen.getByRole('heading', { name: 'node-a → node-b' })).toBeTruthy();
+ expect(dialog.textContent).toMatch(/No data in window/);
+ });
+
+ it('stays open when a refresh collapses the matrix into the line-fallback branch', () => {
+ const { rerender } = renderIt();
+ fireEvent.click(findCell(/node-a.*node-b/));
+ expect(screen.getByRole('heading', { name: 'node-a → node-b' })).toBeTruthy();
+ // New window has a single source → renderer switches to the line
+ // fallback (no heatmap). The open drilldown must not vanish.
+ rerender(
+ ,
+ );
+ expect(screen.getByRole('dialog')).toBeTruthy();
+ expect(screen.getByRole('heading', { name: 'node-a → node-b' })).toBeTruthy();
+ });
+
+ it('re-applies the confidence gate when a refresh drops the pair below greyBelow', () => {
+ const { rerender } = renderIt();
+ fireEvent.click(findCell(/node-a.*node-b/));
+ expect(screen.getByRole('heading', { name: 'node-a → node-b' })).toBeTruthy();
+ // Refresh leaves the drilled pair with 20 samples (< greyBelow 40):
+ // the heatmap suppresses that cell, so the open dialog must hide the
+ // series too instead of leaking sub-threshold data.
+ const refreshed = [
+ { ...mk('node-a', 'node-b', 1_000, 10), count: 20 },
+ mk('node-b', 'node-a', 1_000, 20),
+ mk('node-b', 'node-a', 2_000, 22),
+ ];
+ rerender(
+ ,
+ );
+ const dialog = screen.getByRole('dialog');
+ expect(dialog.textContent).toMatch(/Fewer than 40 samples/);
+ expect(dialog.textContent).toMatch(/series hidden/);
+ });
+
+ it('closing the dialog (Escape) returns to the heatmap and allows re-drill', () => {
+ renderIt();
+ fireEvent.click(findCell(/node-a.*node-b/));
+ const dialog = screen.getByRole('dialog');
+ fireEvent.keyDown(dialog, { key: 'Escape' });
+ expect(screen.queryByRole('dialog')).toBeNull();
+ // The grid is still there and another pair can be drilled.
+ fireEvent.click(findCell(/node-b.*node-a/));
+ expect(screen.getByRole('heading', { name: 'node-b → node-a' })).toBeTruthy();
+ });
+});
diff --git a/src/features/instance/status/analytics/__tests__/primitives/heatmap-matrix.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/heatmap-matrix.test.tsx
index 0e618466a..0a3541bde 100644
--- a/src/features/instance/status/analytics/__tests__/primitives/heatmap-matrix.test.tsx
+++ b/src/features/instance/status/analytics/__tests__/primitives/heatmap-matrix.test.tsx
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { afterEach, describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import { HeatmapMatrix } from '../../primitives/HeatmapMatrix';
import type { HeatmapData } from '../../types/analytics';
@@ -224,6 +224,88 @@ describe('HeatmapMatrix primitive', () => {
expect(rect?.getAttribute('stroke')).toBe('var(--chart-heatmap-muted-stroke, #9ca3af)');
});
+ describe('cell activation (onCellSelect)', () => {
+ const findCell = (re: RegExp) =>
+ screen.getAllByRole('gridcell').find((c) => re.test(c.getAttribute('aria-label') ?? ''))!;
+
+ it('click on a data cell fires onCellSelect with (row, col)', () => {
+ const onCellSelect = vi.fn();
+ render();
+ fireEvent.click(findCell(/src-1.*dest-a/));
+ expect(onCellSelect).toHaveBeenCalledTimes(1);
+ expect(onCellSelect).toHaveBeenCalledWith('src-1', 'dest-a');
+ });
+
+ it('click on a grey (low-confidence) cell still fires — grey cells carry data', () => {
+ const onCellSelect = vi.fn();
+ render();
+ const grey = findCell(/src-2.*dest-a/);
+ expect(grey.getAttribute('data-confidence')).toBe('grey');
+ fireEvent.click(grey);
+ expect(onCellSelect).toHaveBeenCalledWith('src-2', 'dest-a');
+ });
+
+ it('Enter on the focused cell activates and preventDefaults', () => {
+ const onCellSelect = vi.fn();
+ render();
+ const cell = findCell(/src-1.*dest-b/);
+ (cell as HTMLElement).focus();
+ const notPrevented = fireEvent.keyDown(cell, { key: 'Enter' });
+ expect(notPrevented, 'Enter preventDefaulted').toBe(false);
+ expect(onCellSelect).toHaveBeenCalledWith('src-1', 'dest-b');
+ });
+
+ it('Space on the focused cell activates', () => {
+ const onCellSelect = vi.fn();
+ render();
+ const cell = findCell(/src-1.*dest-a/);
+ (cell as HTMLElement).focus();
+ fireEvent.keyDown(cell, { key: ' ' });
+ expect(onCellSelect).toHaveBeenCalledWith('src-1', 'dest-a');
+ });
+
+ it('suppressed and absent cells are aria-disabled and fire on neither click nor Enter', () => {
+ const onCellSelect = vi.fn();
+ render();
+ const suppressed = findCell(/src-2.*dest-b/);
+ const absent = findCell(/src-1.*dest-c/);
+ expect(suppressed.getAttribute('data-confidence')).toBe('suppress');
+ expect(absent.getAttribute('data-confidence')).toBe('absent');
+ for (const cell of [suppressed, absent]) {
+ expect(cell.getAttribute('aria-disabled')).toBe('true');
+ fireEvent.click(cell);
+ (cell as HTMLElement).focus();
+ fireEvent.keyDown(cell, { key: 'Enter' });
+ fireEvent.keyDown(cell, { key: ' ' });
+ }
+ expect(onCellSelect).not.toHaveBeenCalled();
+ });
+
+ it('data cells are not aria-disabled when a handler is present', () => {
+ render( {}} />);
+ expect(findCell(/src-1.*dest-a/).getAttribute('aria-disabled')).toBeNull();
+ });
+
+ it('without onCellSelect no cell is aria-disabled and Enter is inert', () => {
+ render();
+ const cells = screen.getAllByRole('gridcell');
+ expect(cells.every((c) => c.getAttribute('aria-disabled') === null)).toBe(true);
+ const cell = findCell(/src-1.*dest-a/);
+ (cell as HTMLElement).focus();
+ // Should not throw or preventDefault — nothing to activate.
+ const notPrevented = fireEvent.keyDown(cell, { key: 'Enter' });
+ expect(notPrevented).toBe(true);
+ });
+
+ it('grid description mentions activation only when a handler is present', () => {
+ const { unmount } = render( {}} />);
+ expect(screen.getByTestId('heatmap-desc').textContent ?? '').toMatch(/Enter or Space/);
+ unmount();
+ render();
+ expect(screen.getByTestId('heatmap-desc').textContent ?? '').not.toMatch(/Enter or Space/);
+ });
+ });
+
it('takes the measure label from data.measureLabel (legend, description, and cell labels)', () => {
render();
const legend = screen.getByRole('img', { name: /p50 latency/i });
diff --git a/src/features/instance/status/analytics/components/AnalyticsOnboardingHint.test.tsx b/src/features/instance/status/analytics/components/AnalyticsOnboardingHint.test.tsx
index 6f206bd8d..d973e329c 100644
--- a/src/features/instance/status/analytics/components/AnalyticsOnboardingHint.test.tsx
+++ b/src/features/instance/status/analytics/components/AnalyticsOnboardingHint.test.tsx
@@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { AnalyticsOnboardingHint } from './AnalyticsOnboardingHint';
-const STORAGE_KEY = 'studio:analytics:onboarding-dismissed:v1';
+const STORAGE_KEY = 'studio:analytics:onboarding-dismissed:v2';
afterEach(() => {
cleanup();
@@ -18,6 +18,19 @@ describe('AnalyticsOnboardingHint', () => {
it('renders the tip on first visit', async () => {
render();
expect(await screen.findByText(/Click a legend entry to isolate one node/i)).toBeTruthy();
+ // The heatmap-drilldown claim must stay truthful — the feature ships
+ // with #1455, so the hint may advertise it.
+ expect(screen.getByText(/replication heatmap, click a cell/i)).toBeTruthy();
+ });
+
+ it('re-shows the tip for users who dismissed the v1 copy (key is versioned)', async () => {
+ window.localStorage.setItem('studio:analytics:onboarding-dismissed:v1', '1');
+ try {
+ render();
+ expect(await screen.findByText(/Click a legend entry to isolate one node/i)).toBeTruthy();
+ } finally {
+ window.localStorage.removeItem('studio:analytics:onboarding-dismissed:v1');
+ }
});
it('does not render when previously dismissed', () => {
diff --git a/src/features/instance/status/analytics/components/AnalyticsOnboardingHint.tsx b/src/features/instance/status/analytics/components/AnalyticsOnboardingHint.tsx
index a184ed147..e62f850f2 100644
--- a/src/features/instance/status/analytics/components/AnalyticsOnboardingHint.tsx
+++ b/src/features/instance/status/analytics/components/AnalyticsOnboardingHint.tsx
@@ -5,11 +5,13 @@ import { useEffect, useState } from 'react';
// Versioned key — bump the suffix to re-show the tip after a major UX
// change (e.g. when we add a new keyboard shortcut or remove an existing
// one). Past dismissals at older versions are ignored on purpose.
-const STORAGE_KEY = 'studio:analytics:onboarding-dismissed:v1';
+// v2: added the replication-heatmap cell drilldown (#1455).
+const STORAGE_KEY = 'studio:analytics:onboarding-dismissed:v2';
/** First-visit hint explaining the chart interactions that aren't visually
* discoverable: click a legend entry to solo a node, ⌘/Ctrl-click to
- * multi-select, and click a storage bar segment to pin its table's trend.
+ * multi-select, click a storage bar segment to pin its table's trend, and
+ * click a replication-heatmap cell to open that node pair's trend.
* Dismissal is persisted to localStorage so it doesn't reappear. */
export function AnalyticsOnboardingHint() {
const [dismissed, setDismissed] = useState(null);
@@ -44,7 +46,7 @@ export function AnalyticsOnboardingHint() {
⌘
{' / '}
Ctrl
- {"-click to compare a few. In the storage charts, click a bar segment to pin that table's trend."}
+ {"-click to compare a few. In the storage charts, click a bar segment to pin that table's trend. In the replication heatmap, click a cell to see that node pair's latency over time."}
@@ -105,10 +116,6 @@ export function StackedAreaChart(
const resolvedUnit = yAxis?.unit;
const fillOpacity = theme === 'dark' ? 0.5 : 0.35;
- // Full node FQDNs on this chart — the shared tooltip shortens them in
- // displayed series names (display-layer only; `label` stays untouched).
- const nodeNames = [...new Set(data.series.flatMap((s) => (s.node !== undefined ? [s.node] : [])))];
-
return (
Date: Wed, 15 Jul 2026 10:42:59 -0600
Subject: [PATCH 32/92] fix(status): shorten node names in the NodeLegend
filter chips (#1515)
Per Dawson's #1515 note to shorten displayed FQDNs across all of analytics:
the per-node filter chips rendered the raw FQDN while the chart series and
tooltip already showed the short name. Use the same collision-aware
shortNodeLabelMap; full FQDN stays on the button title.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV
---
.../__tests__/charts/node-legend.test.tsx | 32 +++++++++++++++++++
.../status/analytics/charts/NodeLegend.tsx | 9 ++++--
2 files changed, 39 insertions(+), 2 deletions(-)
create mode 100644 src/features/instance/status/analytics/__tests__/charts/node-legend.test.tsx
diff --git a/src/features/instance/status/analytics/__tests__/charts/node-legend.test.tsx b/src/features/instance/status/analytics/__tests__/charts/node-legend.test.tsx
new file mode 100644
index 000000000..a31df3750
--- /dev/null
+++ b/src/features/instance/status/analytics/__tests__/charts/node-legend.test.tsx
@@ -0,0 +1,32 @@
+/** @vitest-environment jsdom */
+import { cleanup, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+import { NodeLegend } from '../../charts/NodeLegend';
+
+describe('NodeLegend', () => {
+ afterEach(cleanup);
+
+ const nodes = ['node1.us.acme.com', 'node1.eu.acme.com', 'other.acme.com'];
+
+ it('shows collision-aware short names, not the full FQDN', () => {
+ render( true} onClickNode={() => {}} />);
+ // node1.* collide on the first segment → keep one more segment.
+ expect(screen.getByText('node1.us')).toBeTruthy();
+ expect(screen.getByText('node1.eu')).toBeTruthy();
+ // unique first segment → shortened all the way.
+ expect(screen.getByText('other')).toBeTruthy();
+ expect(screen.queryByText('node1.us.acme.com')).toBe(null);
+ });
+
+ it('keeps the full FQDN on the button title for hover', () => {
+ render( true} onClickNode={() => {}} />);
+ const btn = screen.getByText('other').closest('button');
+ expect(btn?.getAttribute('title')).toBe('other.acme.com');
+ });
+
+ it('disabled tab keeps its explanatory title instead of the FQDN', () => {
+ render( true} onClickNode={() => {}} disabled />);
+ const btn = screen.getByText('other').closest('button');
+ expect(btn?.getAttribute('title')).toBe("Per-node filter unavailable on this tab's panels");
+ });
+});
diff --git a/src/features/instance/status/analytics/charts/NodeLegend.tsx b/src/features/instance/status/analytics/charts/NodeLegend.tsx
index cb9f2a55a..1daa2327f 100644
--- a/src/features/instance/status/analytics/charts/NodeLegend.tsx
+++ b/src/features/instance/status/analytics/charts/NodeLegend.tsx
@@ -1,4 +1,5 @@
import { getNodeColor } from '../lib/nodeColors';
+import { shortNodeLabelMap } from '../lib/nodeLabels';
interface NodeLegendProps {
nodeIds: string[];
@@ -10,6 +11,10 @@ interface NodeLegendProps {
}
export function NodeLegend({ nodeIds, isActive, onClickNode, disabled }: NodeLegendProps) {
+ // Show collision-aware short names (matching the chart series + tooltip),
+ // keeping the full FQDN on `title` for hover. Chips previously rendered the
+ // raw FQDN, so series read short while their own filter read long (#1515).
+ const shortLabels = shortNodeLabelMap(nodeIds);
return (
{
if (disabled) { return; }
onClickNode(node, e.ctrlKey || e.metaKey);
@@ -51,7 +56,7 @@ export function NodeLegend({ nodeIds, isActive, onClickNode, disabled }: NodeLeg
className="inline-block h-[3px] w-3 rounded"
style={{ backgroundColor: color }}
/>
- {node}
+ {shortLabels.get(node) ?? node}
);
})}
From 95ab07ead10f94ec97927faa2069a98646ff646c Mon Sep 17 00:00:00 2001
From: Kyle Bernhardy
Date: Tue, 14 Jul 2026 21:38:21 -0600
Subject: [PATCH 33/92] test: fail on render-phase update warnings;
smoke-render every registry metric
Two harnesses derived from the runtime-verification passes:
- src/testSetup/failOnRenderPhaseUpdate.ts (global vitest setup): any test
during which React logs 'Cannot update a component while rendering a
different component' now fails with the substituted component names. This
class shipped silently twice (Transitioner/router rebuild, and the
analytics freshness watcher fixed in #1510) and was only ever caught by
watching a real browser console. Narrow match; intentional console.error
paths are unaffected. Zero offenders in the current suite.
- registry-smoke.test.tsx: renders all 24 specRegistry metrics through the
real MetricRenderer + PanelErrorBoundary with synthetic records derived
from each spec's required fields (quantile columns, confidence-gate-
clearing counts, timestamp: 'id' mirroring). Two tiers: no metric may
trip the boundary; every line/stacked-area metric (derived from
spec.primitive, not a hardcoded list) must emit an actual chart.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV
---
.../__tests__/registry-smoke.test.tsx | 133 ++++++++++++++++++
src/testSetup/failOnRenderPhaseUpdate.ts | 54 +++++++
vitest.config.ts | 1 +
3 files changed, 188 insertions(+)
create mode 100644 src/features/instance/status/analytics/__tests__/registry-smoke.test.tsx
create mode 100644 src/testSetup/failOnRenderPhaseUpdate.ts
diff --git a/src/features/instance/status/analytics/__tests__/registry-smoke.test.tsx b/src/features/instance/status/analytics/__tests__/registry-smoke.test.tsx
new file mode 100644
index 000000000..6786a4441
--- /dev/null
+++ b/src/features/instance/status/analytics/__tests__/registry-smoke.test.tsx
@@ -0,0 +1,133 @@
+/** @vitest-environment jsdom */
+// Registry-wide panel smoke test — the unit-test form of the "do the metrics
+// panels actually load?" browser verification pass. Every specRegistry entry
+// renders through the same MetricRenderer dispatch MetricPanel uses, inside
+// the real PanelErrorBoundary, fed synthetic records shaped from the spec's
+// own required fields. Catches the "refactor broke a renderer nobody's test
+// imports" class (e.g. a registry retarget dropping a spec) without a browser.
+//
+// Assertion is two-tier: NO metric may trip the error boundary or the
+// render-failed fallback, and the well-known chart-backed metrics must
+// actually produce recharts SVG (guards against a vacuous pass where every
+// panel silently renders its empty state).
+import { render } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { getSpecRequiredFields } from '../lib/specRequiredFields';
+import { specRegistry } from '../pipeline/index';
+import { QUANTILE_FIELDS } from '../pipeline/quantileFields';
+import { MetricRenderer } from '../primitives/MetricRenderer';
+import { PanelErrorBoundary } from '../tabs/PanelErrorBoundary';
+import type { AnalyticsDataPoint, TimeRange } from '../types/analytics';
+
+const T0 = 1_752_000_000_000;
+const NODES = ['node-a', 'node-b'];
+const WINDOW: TimeRange = { startTime: T0, endTime: T0 + 6 * 60_000 };
+
+/** Quantile columns are deliberately NOT in getSpecRequiredFields (they're
+ * treated as picker alternates there — see its docstring), but p95 is the
+ * default axis on the quantile-bearing specs, so records must carry them
+ * for those charts to draw. `count`/`total` feed the count-weighted-mean
+ * aggregators. */
+const COMMON_NUMERIC_FIELDS = [
+ ...QUANTILE_FIELDS.map((quantile) => quantile.field),
+ 'count',
+ 'total',
+ 'mean',
+];
+
+/** Six buckets × two nodes of records carrying every field the spec's
+ * default view reads (per getSpecRequiredFields) plus the common quantile
+ * and count columns, plus every dimension the spec groups or filters on.
+ * Values are small positive numbers so ratio-style fields (success rates,
+ * cache hits) stay in-domain. */
+function syntheticRecords(metric: string): AnalyticsDataPoint[] {
+ const fields = getSpecRequiredFields(metric);
+ const spec = specRegistry[metric]?.spec;
+ const dimensions = new Set();
+ if (spec?.series.kind === 'groupBy') { dimensions.add(spec.series.dimension); }
+ for (const dim of [spec?.primaryDimension, spec?.subDimension].flat()) {
+ if (dim) { dimensions.add(dim); }
+ }
+ dimensions.delete('node');
+ const records: AnalyticsDataPoint[] = [];
+ for (let bucket = 0; bucket < 6; bucket++) {
+ for (const node of NODES) {
+ const record: AnalyticsDataPoint = {
+ time: T0 + bucket * 60_000,
+ period: 60,
+ node,
+ };
+ for (const field of [...fields, ...COMMON_NUMERIC_FIELDS]) {
+ record[field] = 1 + bucket;
+ }
+ // Big enough that Σcount per series clears every spec's
+ // confidence gate (duration et al. suppress below 100 samples);
+ // `total` stays small so error-ratio fields (1 − total/count)
+ // remain in-domain.
+ record.count = 500;
+ record.total = 5;
+ for (const dim of dimensions) {
+ record[dim] = bucket % 2 === 0 ? 'alpha' : 'beta';
+ }
+ // Re-stamp the structural columns last so a spec that lists
+ // time/period/node among its required fields can't corrupt the
+ // record's timestamp (which would silently eject it from WINDOW).
+ // `id` mirrors `time` for the specs with `timestamp: 'id'`
+ // (database-size, storage-volume).
+ record.time = T0 + bucket * 60_000;
+ record.id = record.time;
+ record.period = 60;
+ record.node = node;
+ records.push(record);
+ }
+ }
+ return records;
+}
+
+/** Metrics whose default view is a recharts cartesian chart — these must
+ * emit SVG, not just avoid crashing. Derived from the spec's primitive so
+ * a newly registered line/stacked-area metric is automatically held to the
+ * chart tier (a hardcoded list would silently exempt new metrics).
+ * Non-cartesian primitives (heatmap, small-multiples) are dispatched to
+ * bespoke DOM and stay in the no-crash tier only. */
+const MUST_RENDER_CHART = Object.keys(specRegistry).filter((metric) => {
+ const primitive = specRegistry[metric].spec.primitive;
+ return primitive === 'line' || primitive === 'stacked-area';
+});
+
+describe('spec registry smoke — every registered metric renders without tripping the panel boundary', () => {
+ for (const metric of Object.keys(specRegistry)) {
+ it(`renders '${metric}'`, () => {
+ const { container } = render(
+
+
+ ,
+ );
+ const text = container.textContent ?? '';
+ expect(text).not.toContain('is unavailable'); // PanelErrorBoundary tripped
+ expect(text).not.toContain('Render failed'); // MetricRenderer error fallback
+ });
+ }
+
+ for (const metric of MUST_RENDER_CHART) {
+ it(`'${metric}' produces an actual chart from populated records`, () => {
+ const { container } = render(
+
+
+ ,
+ );
+ expect(container.querySelector('.recharts-responsive-container, svg')).not.toBeNull();
+ expect(container.textContent ?? '').not.toContain('No data in window');
+ });
+ }
+});
diff --git a/src/testSetup/failOnRenderPhaseUpdate.ts b/src/testSetup/failOnRenderPhaseUpdate.ts
new file mode 100644
index 000000000..134135196
--- /dev/null
+++ b/src/testSetup/failOnRenderPhaseUpdate.ts
@@ -0,0 +1,54 @@
+// Global tripwire: fail any test during which React logs a render-phase
+// cross-component update ("Cannot update a component (`X`) while rendering a
+// different component (`Y`)"). This defect class ships silently — the suite
+// stays green while every affected render pollutes the browser console (and
+// Datadog RUM) in production. It has now bitten twice: the router-rebuild
+// Transitioner warning (see CLAUDE.md, Jul 2026) and the analytics freshness
+// watcher setState-ing from a QueryCache subscription (PR #1510) — both were
+// only caught by manually watching a real browser console.
+//
+// The match is deliberately narrow (this one React message), NOT a blanket
+// console.error ban: intentional error paths (PanelErrorBoundary's
+// `[panel:x] render failed`, the query-cache toast handler) log through
+// console.error legitimately.
+//
+// A test that MUST assert this warning fires (e.g. proving a repro against a
+// known-bad implementation) can capture console.error itself; replacing the
+// function inside the test body takes this interceptor out of the chain for
+// matching calls it swallows.
+import { afterEach, beforeEach } from 'vitest';
+
+const RENDER_PHASE_UPDATE = 'Cannot update a component';
+
+let offenders: string[] = [];
+let interceptedError: typeof console.error | undefined;
+
+beforeEach(() => {
+ offenders = [];
+ const original = console.error;
+ interceptedError = original;
+ console.error = (...args: unknown[]) => {
+ if (typeof args[0] === 'string' && args[0].includes(RENDER_PHASE_UPDATE)) {
+ // React logs printf-style: substitute %s args so the failure
+ // message names the actual components.
+ let i = 1;
+ offenders.push(args[0].replace(/%s/g, () => String(args[i++] ?? '%s')));
+ }
+ original.apply(console, args as Parameters);
+ };
+});
+
+afterEach(() => {
+ if (interceptedError) {
+ console.error = interceptedError;
+ interceptedError = undefined;
+ }
+ if (offenders.length > 0) {
+ const details = offenders.join('\n ');
+ offenders = [];
+ throw new Error(
+ `React render-phase update detected — a component setState'd (or notified a store subscriber) while another component was rendering. `
+ + `Defer the notification (see useAnalyticsFreshness / notifyManager.batchCalls for the pattern):\n ${details}`,
+ );
+ }
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index c8ecc3cdf..ef396723e 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -18,6 +18,7 @@ export default defineConfig({
'**/.claude/worktrees/**',
],
setupFiles: [
+ './src/testSetup/failOnRenderPhaseUpdate.ts',
'./src/features/instance/status/analytics/__tests__/setup.ts',
'./src/lib/monaco/__tests__/setup.ts',
],
From fce6944098753c4684830a47a93da6cb35e41522 Mon Sep 17 00:00:00 2001
From: Kyle Bernhardy
Date: Tue, 14 Jul 2026 22:18:06 -0600
Subject: [PATCH 34/92] test: don't shadow a primary assertion failure from the
tripwire hook (review)
When the test body already failed, log the render-phase-update detail through
the restored console.error instead of throwing from afterEach, so vitest
reports the real assertion failure; passing tests still fail hard.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV
---
src/testSetup/failOnRenderPhaseUpdate.ts | 20 ++++++++++++++------
1 file changed, 14 insertions(+), 6 deletions(-)
diff --git a/src/testSetup/failOnRenderPhaseUpdate.ts b/src/testSetup/failOnRenderPhaseUpdate.ts
index 134135196..8292c22f3 100644
--- a/src/testSetup/failOnRenderPhaseUpdate.ts
+++ b/src/testSetup/failOnRenderPhaseUpdate.ts
@@ -38,17 +38,25 @@ beforeEach(() => {
};
});
-afterEach(() => {
- if (interceptedError) {
- console.error = interceptedError;
+afterEach((context) => {
+ const original = interceptedError;
+ if (original) {
+ console.error = original;
interceptedError = undefined;
}
if (offenders.length > 0) {
const details = offenders.join('\n ');
offenders = [];
- throw new Error(
+ const message =
`React render-phase update detected — a component setState'd (or notified a store subscriber) while another component was rendering. `
- + `Defer the notification (see useAnalyticsFreshness / notifyManager.batchCalls for the pattern):\n ${details}`,
- );
+ + `Defer the notification (see useAnalyticsFreshness / notifyManager.batchCalls for the pattern):\n ${details}`;
+ // If the test body already failed, don't throw from the hook — a hook
+ // error would shadow the primary assertion failure in the report. The
+ // offender detail still reaches the log; the test is red either way.
+ if (context.task.result?.state === 'fail') {
+ original?.(`[failOnRenderPhaseUpdate] ${message}`);
+ return;
+ }
+ throw new Error(message);
}
});
From 73968a998183217b281fd45af7b70e9f4b2b513e Mon Sep 17 00:00:00 2001
From: Kyle Bernhardy
Date: Wed, 15 Jul 2026 11:08:45 -0600
Subject: [PATCH 35/92] =?UTF-8?q?test:=20address=20review=20=E2=80=94=20de?=
=?UTF-8?q?rived=20coverage,=20real-render=20tiers,=20tripwire=20net=20det?=
=?UTF-8?q?ection?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Addresses kriszyp + dawsontoth review feedback on #1511.
registry-smoke.test.tsx:
- Fix dead SVG selector: assert `svg` directly instead of
`.recharts-responsive-container, svg` (the wrapper div is an ancestor of the
svg and always matched first, so the svg branch never evaluated). The
analytics setup forces 800×600 so recharts genuinely draws (kriszyp).
- Make `.spec` access consistent: `specRegistry[metric].spec` non-optionally in
syntheticRecords, matching MUST_RENDER_CHART (spec is required on
SpecRegistryEntry) (kriszyp nit).
- Add a derived-registry no-crash tier iterating Object.keys(derivedRegistry).
MetricRenderer dispatches derivedRegistry[metric] before specRegistry, so the
five derived metrics (mqtt-traffic-sent/received, request-rate, error-rate,
transaction-log-growth) previously had zero coverage. Fed synthetic records
carrying the raw source columns each recompute reads directly (kriszyp #1).
- Add dedicated real-render cases for the non-cartesian primitives: heatmap
(replication-latency) asserts a `[role="grid"]` with gridcells from a 2×2
source/destination matrix; small-multiples (resource-usage) asserts multiple
sub-charts. Both assert real DOM, not their empty state (dawson #1521).
failOnRenderPhaseUpdate.ts:
- Add a tripwire-net self-check: capture the installed wrapper in beforeEach and,
in afterEach, detect + defensively restore when a test replaced console.error
and left it replaced (net disabled). Severity is a loud console.warn naming the
test, not a hard failure, so the tests that legitimately mock console.error
keep passing. Refactored into exported installTripwire / detectDisabledNet and
added failOnRenderPhaseUpdate.test.ts proving the detection fires (dawson #1520).
- Document the DEV-mode dependency: the net relies on React's dev-build warning
and silently goes green under a production React build (dawson #3 / kriszyp #3).
- Simplify the already-failed path to call console.error directly (restored two
lines up) instead of original?.() (kriszyp nit).
queryClient.test.ts:
- The new self-check surfaced that this suite used mockReset() (leaves a
swallowing spy installed at teardown); switch to mockRestore() so the tripwire
wrapper is back in place between tests.
Fixes #1520
Fixes #1521
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV
---
.../__tests__/registry-smoke.test.tsx | 159 +++++++++++++++++-
src/react-query/queryClient.test.ts | 6 +-
src/testSetup/failOnRenderPhaseUpdate.test.ts | 36 ++++
src/testSetup/failOnRenderPhaseUpdate.ts | 95 +++++++++--
4 files changed, 275 insertions(+), 21 deletions(-)
create mode 100644 src/testSetup/failOnRenderPhaseUpdate.test.ts
diff --git a/src/features/instance/status/analytics/__tests__/registry-smoke.test.tsx b/src/features/instance/status/analytics/__tests__/registry-smoke.test.tsx
index 6786a4441..c00e6f30e 100644
--- a/src/features/instance/status/analytics/__tests__/registry-smoke.test.tsx
+++ b/src/features/instance/status/analytics/__tests__/registry-smoke.test.tsx
@@ -13,6 +13,7 @@
import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { getSpecRequiredFields } from '../lib/specRequiredFields';
+import { derivedRegistry } from '../pipeline/derived/index';
import { specRegistry } from '../pipeline/index';
import { QUANTILE_FIELDS } from '../pipeline/quantileFields';
import { MetricRenderer } from '../primitives/MetricRenderer';
@@ -42,10 +43,13 @@ const COMMON_NUMERIC_FIELDS = [
* cache hits) stay in-domain. */
function syntheticRecords(metric: string): AnalyticsDataPoint[] {
const fields = getSpecRequiredFields(metric);
- const spec = specRegistry[metric]?.spec;
+ // `spec` is required on SpecRegistryEntry (types/analytics.ts) and `metric`
+ // is always a specRegistry key here, so read it non-optionally — matches
+ // MUST_RENDER_CHART's `specRegistry[metric].spec` below.
+ const spec = specRegistry[metric].spec;
const dimensions = new Set();
- if (spec?.series.kind === 'groupBy') { dimensions.add(spec.series.dimension); }
- for (const dim of [spec?.primaryDimension, spec?.subDimension].flat()) {
+ if (spec.series.kind === 'groupBy') { dimensions.add(spec.series.dimension); }
+ for (const dim of [spec.primaryDimension, spec.subDimension].flat()) {
if (dim) { dimensions.add(dim); }
}
dimensions.delete('node');
@@ -89,12 +93,81 @@ function syntheticRecords(metric: string): AnalyticsDataPoint[] {
* a newly registered line/stacked-area metric is automatically held to the
* chart tier (a hardcoded list would silently exempt new metrics).
* Non-cartesian primitives (heatmap, small-multiples) are dispatched to
- * bespoke DOM and stay in the no-crash tier only. */
+ * bespoke DOM and get their own dedicated real-render cases below. */
const MUST_RENDER_CHART = Object.keys(specRegistry).filter((metric) => {
const primitive = specRegistry[metric].spec.primitive;
return primitive === 'line' || primitive === 'stacked-area';
});
+/** Records for the derived-metric no-crash tier. Derived metrics recompute
+ * from RAW source columns (their `recompute` bypasses runPipeline / the spec
+ * aggregator — see pipeline/derived/index.ts), so getSpecRequiredFields
+ * can't infer their inputs. This carries every raw column the five derived
+ * recomputes read directly:
+ * - request-rate → path, count, period (PerPathRateRenderer)
+ * - error-rate → path, count, total
+ * - mqtt-traffic-{sent,received} → type, count, period (runPipeline on the
+ * inner bytes-{sent,received} spec via TrafficByTypeRenderer)
+ * - transaction-log-growth → database, transactionLog (monotonic per node
+ * so consecutive-sample deltas are positive), id/time
+ * `count` is high so any confidence gate clears; `total` stays small so
+ * error-rate's `1 − Σtotal/Σcount` stays in-domain. */
+const REQUEST_PATHS = ['GET /', 'POST /data'];
+const MQTT_TYPES = ['publish', 'subscribe'];
+
+function derivedSyntheticRecords(): AnalyticsDataPoint[] {
+ const records: AnalyticsDataPoint[] = [];
+ for (let bucket = 0; bucket < 6; bucket++) {
+ const time = T0 + bucket * 60_000;
+ for (const node of NODES) {
+ for (const path of REQUEST_PATHS) {
+ records.push({
+ time,
+ id: time,
+ period: 60_000,
+ node,
+ path,
+ type: MQTT_TYPES[bucket % MQTT_TYPES.length],
+ database: 'data',
+ // Cumulative counter — strictly increasing per (database, node)
+ // so transaction-log-growth's delta walk yields positive rates.
+ transactionLog: 1_000 * (bucket + 1),
+ count: 500,
+ total: 5,
+ p95: 10 + bucket,
+ });
+ }
+ }
+ }
+ return records;
+}
+
+/** Records that populate replication-latency's heatmap grid (dawson #1521).
+ * `node` is the DESTINATION; the SOURCE is parsed from `path`
+ * (`..
` — see pathParser). Two sources × two destinations
+ * gives a 2×2 matrix that clears both the ≥2-rows/≥2-cols and ≤12-cells gates
+ * in ReplicationLatencyRenderer, so the real HeatmapMatrix draws instead of
+ * the single-source line fallback. `count=500` clears the confidence gate. */
+function replicationHeatmapRecords(): AnalyticsDataPoint[] {
+ const records: AnalyticsDataPoint[] = [];
+ for (let bucket = 0; bucket < 3; bucket++) {
+ const time = T0 + bucket * 60_000;
+ for (const destination of NODES) {
+ for (const source of NODES) {
+ records.push({
+ time,
+ period: 60,
+ node: destination,
+ path: `${source}.data.tbl`,
+ p95: 10 + bucket,
+ count: 500,
+ });
+ }
+ }
+ }
+ return records;
+}
+
describe('spec registry smoke — every registered metric renders without tripping the panel boundary', () => {
for (const metric of Object.keys(specRegistry)) {
it(`renders '${metric}'`, () => {
@@ -126,8 +199,84 @@ describe('spec registry smoke — every registered metric renders without trippi
/>
,
);
- expect(container.querySelector('.recharts-responsive-container, svg')).not.toBeNull();
+ // Assert the