From 43795caf7820f2b4d3ebab58b6718672606a780d Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Mon, 13 Jul 2026 15:29:37 -0600 Subject: [PATCH 01/92] =?UTF-8?q?refactor(status):=20sweep=20file=20conven?= =?UTF-8?q?tions=20=E2=80=94=20extensionless=20imports,=20.ts=20renames,?= =?UTF-8?q?=20dead=20theme=20plumbing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 411 extensionful relative/alias imports rewritten extensionless repo-wide (the status feature was the only holdout convention-wise) - 18 JSX-free .tsx modules renamed to .ts (factory re-export shims and derived recompute modules); request-rate stays .tsx (has JSX) - the deprecated no-op theme prop chain removed end to end: SpecRegistryRendererProps, MetricRenderer pass-through, pipeline renderers, AnalyticsContext.theme + AnalyticsTheme, StatusTabs, ConnectionsPanel, StorageTab, TableSize charts; replication-latency's three theme-conditional banner backgrounds now use color-mix over CSS tokens (HeatmapMatrix keeps useResolvedTheme internally for its ramp) - FallbackRenderer's vestigial window/nodes args dropped from MetricRenderer - registry comments: response_200 wire-name exception, connections vs connection disambiguation - test layout decision: colocated *.test.tsx is the going-forward convention; the existing __tests__ tree stays put (churn without value) Fixes #1452 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV --- .../hooks/useApplicationTypeIntelligence.ts | 2 +- .../instance/status/analytics/StatusTabs.tsx | 40 ++++++++----------- .../StatusTabs.capability-gating.test.tsx | 2 +- .../StatusTabs.sliding-window.test.tsx | 4 +- .../__tests__/StatusTabs.url-sync.test.tsx | 2 +- .../charts/table-size-chip-row.test.tsx | 4 +- .../analytics/__tests__/nodeColors.test.ts | 2 +- .../__tests__/paletteDisjointness.test.ts | 6 +-- .../__tests__/pipeline/aggregators.test.ts | 2 +- .../__tests__/pipeline/approxLabel.test.ts | 2 +- .../pipeline/bytes-received-pipeline.test.ts | 6 +-- .../pipeline/bytes-sent-tolerance.test.ts | 6 +-- .../pipeline/cache-hit-pipeline.test.ts | 6 +-- .../cache-resolution-pipeline.test.ts | 6 +-- .../pipeline/compute-cell-size.test.ts | 2 +- .../__tests__/pipeline/confidence.test.ts | 2 +- .../pipeline/connections-pipeline.test.ts | 4 +- .../pipeline/cpu-usage-pipeline.test.ts | 8 ++-- .../pipeline/database-size-pipeline.test.ts | 6 +-- .../__tests__/pipeline/db-pipelines.test.ts | 10 ++--- .../pipeline/duration-pipeline.test.ts | 8 ++-- .../__tests__/pipeline/fieldExpr.test.ts | 4 +- .../main-thread-utilization-pipeline.test.ts | 6 +-- .../pipeline/memory-pipeline.test.ts | 6 +-- .../__tests__/pipeline/pathParser.test.ts | 2 +- .../pipeline/pipeline-per-node.test.ts | 4 +- .../pipeline/pipeline-time-buckets.test.ts | 4 +- .../__tests__/pipeline/pipeline.test.ts | 4 +- .../pipeline/rate-period-fallback.test.ts | 4 +- .../pipeline/replication-pipeline.test.ts | 4 +- .../pipeline/resource-usage-pipeline.test.ts | 4 +- .../pipeline/response-200-pipeline.test.ts | 6 +-- .../__tests__/pipeline/runTransform.test.ts | 4 +- .../pipeline/sort-by-magnitude.test.ts | 2 +- .../__tests__/pipeline/spec-registry.test.ts | 4 +- .../pipeline/storage-volume-pipeline.test.ts | 6 +-- .../pipeline/success-pipeline.test.ts | 6 +-- .../pipeline/tls-reused-pipeline.test.ts | 6 +-- .../pipeline/transaction-log-growth.test.ts | 4 +- .../pipeline/transfer-pipeline.test.ts | 6 +-- .../__tests__/pipeline/transforms.test.ts | 2 +- .../pipeline/utilization-pipeline.test.ts | 6 +-- .../primitives/dimension-chip-row.test.tsx | 2 +- .../primitives/dimension-combobox.test.tsx | 2 +- .../dimension-selector-pipe-dim.test.tsx | 4 +- .../dimension-selector-switch.test.tsx | 4 +- .../primitives/fallback-renderer.test.ts | 4 +- .../primitives/heatmap-matrix.test.tsx | 34 ++++++++-------- .../__tests__/primitives/line-chart.test.tsx | 21 +++++----- .../primitives/small-multiples.test.tsx | 4 +- .../primitives/stack-by-toggle.test.tsx | 4 +- .../primitives/stacked-area.test.tsx | 4 +- .../analytics/__tests__/tableColors.test.ts | 2 +- .../analytics/__tests__/tableSize.test.ts | 4 +- .../status/analytics/__tests__/time.test.ts | 2 +- .../status/analytics/charts/NodeLegend.tsx | 2 +- .../analytics/charts/TableSizeChipRow.tsx | 6 +-- .../analytics/charts/TableSizeSnapshot.tsx | 20 ++++------ .../analytics/charts/TableSizeTrend.tsx | 22 +++++----- .../AnalyticsOnboardingHint.test.tsx | 2 +- .../components/ChartCopyButton.test.tsx | 4 +- .../analytics/components/ChartCopyButton.tsx | 2 +- .../components/ChartExpandButton.test.tsx | 4 +- .../components/ChartExpandButton.tsx | 4 +- .../components/ChartExportButton.test.tsx | 4 +- .../components/ChartExportButton.tsx | 4 +- .../components/TimeRangePicker.test.tsx | 6 +-- .../analytics/components/TimeRangePicker.tsx | 6 +-- .../analytics/context/AnalyticsContext.tsx | 13 +----- .../analytics/context/timePresets.test.ts | 2 +- .../hooks/useAnalyticsCapability.test.ts | 4 +- .../analytics/hooks/useAnalyticsCapability.ts | 2 +- .../hooks/useAnalyticsFreshness.test.ts | 2 +- .../analytics/hooks/useAnalyticsFreshness.ts | 2 +- .../hooks/useAnalyticsRecords.test.ts | 4 +- .../analytics/hooks/useAnalyticsRecords.ts | 9 ++--- .../analytics/hooks/useNodeFilteredSeries.ts | 8 ++-- .../analytics/hooks/useNodeSelection.ts | 2 +- .../analytics/lib/specRequiredFields.test.ts | 6 +-- .../analytics/lib/specRequiredFields.ts | 6 +-- .../status/analytics/lib/tableSize.ts | 2 +- .../status/analytics/pipeline/aggregators.ts | 2 +- .../status/analytics/pipeline/approxLabel.ts | 2 +- .../{bytes-received.tsx => bytes-received.ts} | 2 +- .../{bytes-sent.tsx => bytes-sent.ts} | 2 +- .../pipeline/{cache-hit.tsx => cache-hit.ts} | 2 +- ...che-resolution.tsx => cache-resolution.ts} | 2 +- .../status/analytics/pipeline/confidence.ts | 2 +- .../status/analytics/pipeline/connection.tsx | 16 ++++---- .../{connections.tsx => connections.ts} | 2 +- .../pipeline/{cpu-usage.tsx => cpu-usage.ts} | 2 +- .../{database-size.tsx => database-size.ts} | 2 +- .../{db-message.tsx => db-message.ts} | 2 +- .../pipeline/{db-read.tsx => db-read.ts} | 2 +- .../pipeline/{db-write.tsx => db-write.ts} | 2 +- .../derived/{error-rate.tsx => error-rate.ts} | 2 +- .../analytics/pipeline/derived/index.ts | 10 ++--- ...-received.tsx => mqtt-traffic-received.ts} | 2 +- ...-traffic-sent.tsx => mqtt-traffic-sent.ts} | 2 +- .../pipeline/derived/mqtt-traffic.tsx | 6 +-- .../pipeline/derived/request-rate.tsx | 7 ++-- ...g-growth.tsx => transaction-log-growth.ts} | 2 +- .../pipeline/{duration.tsx => duration.ts} | 2 +- .../status/analytics/pipeline/fieldExpr.ts | 2 +- .../status/analytics/pipeline/index.ts | 24 ++++++----- .../pipeline/main-thread-utilization.tsx | 16 ++++---- .../status/analytics/pipeline/memory.tsx | 16 ++++---- .../status/analytics/pipeline/pipeline.ts | 12 +++--- .../pipeline/replication-latency.tsx | 34 ++++++++-------- .../analytics/pipeline/resource-usage.ts | 2 +- .../{response-200.tsx => response-200.ts} | 2 +- .../status/analytics/pipeline/runTransform.ts | 4 +- .../analytics/pipeline/storage-volume.ts | 2 +- .../pipeline/{success.tsx => success.ts} | 2 +- .../status/analytics/pipeline/tls-reused.ts | 2 +- .../pipeline/{transfer.tsx => transfer.ts} | 2 +- .../status/analytics/pipeline/utilization.ts | 2 +- .../analytics/pipeline/wrapperMetrics.tsx | 8 ++-- .../analytics/primitives/DimensionChipRow.tsx | 2 +- .../primitives/DimensionSelectorRenderer.tsx | 16 ++++---- .../analytics/primitives/FallbackRenderer.tsx | 9 +---- .../analytics/primitives/HeatmapMatrix.tsx | 13 ++---- .../status/analytics/primitives/LineChart.tsx | 14 +++---- .../primitives/LineChartWithNodeLegend.tsx | 10 ++--- .../analytics/primitives/MetricRenderer.tsx | 28 +++++-------- .../primitives/PerPathRateRenderer.tsx | 18 ++++----- .../analytics/primitives/SmallMultiples.tsx | 10 ++--- .../analytics/primitives/StackedAreaChart.tsx | 12 +++--- .../primitives/TrafficByTypeRenderer.tsx | 22 +++++----- .../analytics/tabs/ConnectionsPanel.tsx | 15 ++++--- .../status/analytics/tabs/DatabaseTab.tsx | 2 +- .../status/analytics/tabs/HealthTab.tsx | 2 +- .../status/analytics/tabs/MetricPanel.tsx | 16 ++++---- .../status/analytics/tabs/OverviewTab.tsx | 8 ++-- .../status/analytics/tabs/PanelShell.tsx | 6 +-- .../status/analytics/tabs/ReplicationTab.tsx | 2 +- .../status/analytics/tabs/RequestsTab.tsx | 2 +- .../status/analytics/tabs/StorageTab.tsx | 26 ++++++------ .../status/analytics/tabs/TrafficTab.tsx | 4 +- .../tabs/__tests__/ConnectionsPanel.test.tsx | 6 +-- .../tabs/__tests__/MetricPanel.test.tsx | 6 +-- .../tabs/__tests__/OverviewTab.test.tsx | 4 +- .../tabs/__tests__/StorageTab.test.tsx | 8 ++-- .../tabs/__tests__/groupSections.test.ts | 2 +- .../tabs/__tests__/tabs.smoke.test.tsx | 16 ++++---- .../analytics/tabs/__tests__/testHelpers.tsx | 7 ++-- .../status/analytics/types/analytics.ts | 1 - src/features/instance/status/index.tsx | 4 +- src/features/instance/status/routes.ts | 2 +- .../instance/status/statusSearch.test.ts | 2 +- src/features/instance/status/statusSearch.ts | 2 +- .../api/instance/status/getAnalytics.ts | 4 +- src/lib/humanFileSize.ts | 2 +- src/lib/units.test.ts | 2 +- 154 files changed, 453 insertions(+), 520 deletions(-) rename src/features/instance/status/analytics/pipeline/{bytes-received.tsx => bytes-received.ts} (84%) rename src/features/instance/status/analytics/pipeline/{bytes-sent.tsx => bytes-sent.ts} (83%) rename src/features/instance/status/analytics/pipeline/{cache-hit.tsx => cache-hit.ts} (83%) rename src/features/instance/status/analytics/pipeline/{cache-resolution.tsx => cache-resolution.ts} (85%) rename src/features/instance/status/analytics/pipeline/{connections.tsx => connections.ts} (84%) rename src/features/instance/status/analytics/pipeline/{cpu-usage.tsx => cpu-usage.ts} (83%) rename src/features/instance/status/analytics/pipeline/{database-size.tsx => database-size.ts} (84%) rename src/features/instance/status/analytics/pipeline/{db-message.tsx => db-message.ts} (83%) rename src/features/instance/status/analytics/pipeline/{db-read.tsx => db-read.ts} (83%) rename src/features/instance/status/analytics/pipeline/{db-write.tsx => db-write.ts} (83%) rename src/features/instance/status/analytics/pipeline/derived/{error-rate.tsx => error-rate.ts} (97%) rename src/features/instance/status/analytics/pipeline/derived/{mqtt-traffic-received.tsx => mqtt-traffic-received.ts} (65%) rename src/features/instance/status/analytics/pipeline/derived/{mqtt-traffic-sent.tsx => mqtt-traffic-sent.ts} (67%) rename src/features/instance/status/analytics/pipeline/derived/{transaction-log-growth.tsx => transaction-log-growth.ts} (98%) rename src/features/instance/status/analytics/pipeline/{duration.tsx => duration.ts} (83%) rename src/features/instance/status/analytics/pipeline/{response-200.tsx => response-200.ts} (84%) rename src/features/instance/status/analytics/pipeline/{success.tsx => success.ts} (83%) rename src/features/instance/status/analytics/pipeline/{transfer.tsx => transfer.ts} (83%) diff --git a/src/features/instance/applications/hooks/useApplicationTypeIntelligence.ts b/src/features/instance/applications/hooks/useApplicationTypeIntelligence.ts index c4f89432d..5fa1cab22 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.ts'` has + * model — the open file — so `import { increment } from '@/counter'` 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/StatusTabs.tsx b/src/features/instance/status/analytics/StatusTabs.tsx index 593d07e40..df0d48868 100644 --- a/src/features/instance/status/analytics/StatusTabs.tsx +++ b/src/features/instance/status/analytics/StatusTabs.tsx @@ -1,25 +1,24 @@ import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig.ts'; -import { ANALYTICS_QUERY_KEY_PREFIX } from '@/integrations/api/instance/status/getAnalytics.ts'; +import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig'; +import { ANALYTICS_QUERY_KEY_PREFIX } from '@/integrations/api/instance/status/getAnalytics'; import { useQueryClient } from '@tanstack/react-query'; import { useNavigate, useSearch } from '@tanstack/react-router'; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { type StatusTabId, validateStatusSearch } from '../statusSearch.ts'; -import { AnalyticsOnboardingHint } from './components/AnalyticsOnboardingHint.tsx'; -import { TimeRangePicker } from './components/TimeRangePicker.tsx'; -import { type AnalyticsContextValue, AnalyticsProvider } from './context/AnalyticsContext.tsx'; -import { getPreset, type TimePresetId } from './context/timePresets.ts'; -import { type AnalyticsCapability, useAnalyticsCapability } from './hooks/useAnalyticsCapability.ts'; -import { useResolvedTheme } from './lib/theme.ts'; -import { DatabaseTab } from './tabs/DatabaseTab.tsx'; -import { HealthTab } from './tabs/HealthTab.tsx'; -import { OverviewTab } from './tabs/OverviewTab.tsx'; -import { ReplicationTab } from './tabs/ReplicationTab.tsx'; -import { RequestsTab } from './tabs/RequestsTab.tsx'; -import { StorageTab } from './tabs/StorageTab.tsx'; -import { TrafficTab } from './tabs/TrafficTab.tsx'; +import { type StatusTabId, validateStatusSearch } from '../statusSearch'; +import { AnalyticsOnboardingHint } from './components/AnalyticsOnboardingHint'; +import { TimeRangePicker } from './components/TimeRangePicker'; +import { type AnalyticsContextValue, AnalyticsProvider } from './context/AnalyticsContext'; +import { getPreset, type TimePresetId } from './context/timePresets'; +import { type AnalyticsCapability, useAnalyticsCapability } from './hooks/useAnalyticsCapability'; +import { DatabaseTab } from './tabs/DatabaseTab'; +import { HealthTab } from './tabs/HealthTab'; +import { OverviewTab } from './tabs/OverviewTab'; +import { ReplicationTab } from './tabs/ReplicationTab'; +import { RequestsTab } from './tabs/RequestsTab'; +import { StorageTab } from './tabs/StorageTab'; +import { TrafficTab } from './tabs/TrafficTab'; interface Props { instanceParams: InstanceClientIdConfig & InstanceTypeConfig; @@ -155,12 +154,6 @@ function StatusTabsInner({ instanceParams, isLocalStudio, capability }: InnerPro }; }, [refreshMs, refresh, queryClient, tick, instanceParams.entityId]); - // Resolved app theme (user's explicit choice, not raw OS preference) — - // kept in the context only for the consumers that still read it there - // (StorageTab's table-size charts, ConnectionsPanel). Chart primitives - // re-theme via `--chart-*` CSS tokens without any prop. - const theme = useResolvedTheme(); - const updatePreset = useCallback((id: TimePresetId) => { void navigate({ to: '.', search: { tab, range: id, refresh: refreshMs } }); }, [navigate, tab, refreshMs]); @@ -201,10 +194,9 @@ function StatusTabsInner({ instanceParams, isLocalStudio, capability }: InnerPro return { timeRange: { startTime, endTime }, bucketMs: preset.bucketMs, - theme, instanceParams, }; - }, [presetId, theme, instanceParams, tick]); + }, [presetId, instanceParams, tick]); const showTimePicker = tab !== 'overview'; const picker = showTimePicker 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 cfa65e9c4..2757430a0 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 @@ -76,7 +76,7 @@ beforeEach(() => { }); }); -import { StatusTabs } from '../StatusTabs.tsx'; +import { StatusTabs } from '../StatusTabs'; const instanceParams = { instanceClient: { post: vi.fn(async () => ({ data: [] })) } as never, 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 e78751c13..87ca74516 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 @@ -49,8 +49,8 @@ vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, })); -import { DEFAULT_REFRESH_MS } from '../context/timePresets.ts'; -import { StatusTabs } from '../StatusTabs.tsx'; +import { DEFAULT_REFRESH_MS } from '../context/timePresets'; +import { StatusTabs } from '../StatusTabs'; const instanceParams = { instanceClient: { post: vi.fn(async () => ({ data: [] })) } as never, 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 fe73b3b53..362671110 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 @@ -65,7 +65,7 @@ beforeEach(() => { }); }); -import { StatusTabs } from '../StatusTabs.tsx'; +import { StatusTabs } from '../StatusTabs'; const instanceParams = { instanceClient: { post: vi.fn(async () => ({ data: [] })) } as never, diff --git a/src/features/instance/status/analytics/__tests__/charts/table-size-chip-row.test.tsx b/src/features/instance/status/analytics/__tests__/charts/table-size-chip-row.test.tsx index ac3e75308..cebae9fad 100644 --- a/src/features/instance/status/analytics/__tests__/charts/table-size-chip-row.test.tsx +++ b/src/features/instance/status/analytics/__tests__/charts/table-size-chip-row.test.tsx @@ -1,8 +1,8 @@ // @vitest-environment happy-dom import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it } from 'vitest'; -import { TableSizeChipRow } from '../../charts/TableSizeChipRow.tsx'; -import { OTHER_KEY } from '../../lib/tableSize.ts'; +import { TableSizeChipRow } from '../../charts/TableSizeChipRow'; +import { OTHER_KEY } from '../../lib/tableSize'; describe('TableSizeChipRow radiogroup (shared roving-tabindex primitive)', () => { afterEach(() => cleanup()); diff --git a/src/features/instance/status/analytics/__tests__/nodeColors.test.ts b/src/features/instance/status/analytics/__tests__/nodeColors.test.ts index 23ee45fbb..2957cb6aa 100644 --- a/src/features/instance/status/analytics/__tests__/nodeColors.test.ts +++ b/src/features/instance/status/analytics/__tests__/nodeColors.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { getNodeColor, NODE_PALETTE } from '../lib/nodeColors.ts'; +import { getNodeColor, NODE_PALETTE } from '../lib/nodeColors'; describe('getNodeColor', () => { it('assigns colors based on sorted node order', () => { diff --git a/src/features/instance/status/analytics/__tests__/paletteDisjointness.test.ts b/src/features/instance/status/analytics/__tests__/paletteDisjointness.test.ts index aa41b8463..57f89c39c 100644 --- a/src/features/instance/status/analytics/__tests__/paletteDisjointness.test.ts +++ b/src/features/instance/status/analytics/__tests__/paletteDisjointness.test.ts @@ -1,8 +1,8 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; -import { TYPE_PALETTE } from '../lib/colorAllocators/typeColors.ts'; -import { NODE_PALETTE } from '../lib/nodeColors.ts'; -import { OTHER_COLOR, TABLE_PALETTE } from '../lib/tableColors.ts'; +import { TYPE_PALETTE } from '../lib/colorAllocators/typeColors'; +import { NODE_PALETTE } from '../lib/nodeColors'; +import { OTHER_COLOR, TABLE_PALETTE } from '../lib/tableColors'; // The palettes are now CSS custom properties (`var(--chart-…)`), so the // disjointness invariant is enforced against their definitions in diff --git a/src/features/instance/status/analytics/__tests__/pipeline/aggregators.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/aggregators.test.ts index ec1a4a127..789d8d13c 100644 --- a/src/features/instance/status/analytics/__tests__/pipeline/aggregators.test.ts +++ b/src/features/instance/status/analytics/__tests__/pipeline/aggregators.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { type AggInput, aggregate } from '../../pipeline/aggregators.ts'; +import { type AggInput, aggregate } from '../../pipeline/aggregators'; describe('aggregate', () => { it('sum', () => { diff --git a/src/features/instance/status/analytics/__tests__/pipeline/approxLabel.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/approxLabel.test.ts index 0c322c43f..1900fdb6d 100644 --- a/src/features/instance/status/analytics/__tests__/pipeline/approxLabel.test.ts +++ b/src/features/instance/status/analytics/__tests__/pipeline/approxLabel.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isApproxAggregator, labelWithApprox } from '../../pipeline/approxLabel.ts'; +import { isApproxAggregator, labelWithApprox } from '../../pipeline/approxLabel'; describe('approxLabel', () => { it('appends " (approx)" when aggregator is count-weighted-mean', () => { 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 e22c16fa7..53e2e44ea 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,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { bytesReceivedSpec } from '../../pipeline/bytes-received.tsx'; -import { runPipeline } from '../../pipeline/pipeline.ts'; -import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics.ts'; +import { bytesReceivedSpec } from '../../pipeline/bytes-received'; +import { runPipeline } from '../../pipeline/pipeline'; +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/bytes-sent-tolerance.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/bytes-sent-tolerance.test.ts index abf28f8b3..e3db59fe6 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,9 +1,9 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { bytesSentSpec } from '../../pipeline/bytes-sent.tsx'; -import { mqttTrafficSentDerived } from '../../pipeline/derived/mqtt-traffic-sent.tsx'; -import { runPipeline } from '../../pipeline/pipeline.ts'; +import { bytesSentSpec } from '../../pipeline/bytes-sent'; +import { mqttTrafficSentDerived } from '../../pipeline/derived/mqtt-traffic-sent'; +import { runPipeline } from '../../pipeline/pipeline'; 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 e88545407..b50f369de 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,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { cacheHitSpec } from '../../pipeline/cache-hit.tsx'; -import { runPipeline } from '../../pipeline/pipeline.ts'; -import type { AnalyticsDataPoint } from '../../types/analytics.ts'; +import { cacheHitSpec } from '../../pipeline/cache-hit'; +import { runPipeline } from '../../pipeline/pipeline'; +import type { AnalyticsDataPoint } from '../../types/analytics'; // Synthetic rows shaped like the real Harper response: // { time, node, path, period, count, total, ratio } 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 19c26d867..7018341ba 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,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { cacheResolutionSpec } from '../../pipeline/cache-resolution.tsx'; -import { runPipeline } from '../../pipeline/pipeline.ts'; -import type { AnalyticsDataPoint } from '../../types/analytics.ts'; +import { cacheResolutionSpec } from '../../pipeline/cache-resolution'; +import { runPipeline } from '../../pipeline/pipeline'; +import type { AnalyticsDataPoint } from '../../types/analytics'; // Synthetic rows shaped like the real Harper response. The real schema // carries a per-bucket count-weighted distribution for each path; the diff --git a/src/features/instance/status/analytics/__tests__/pipeline/compute-cell-size.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/compute-cell-size.test.ts index d4c1c2b3b..23e844564 100644 --- a/src/features/instance/status/analytics/__tests__/pipeline/compute-cell-size.test.ts +++ b/src/features/instance/status/analytics/__tests__/pipeline/compute-cell-size.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { computeCellSize } from '../../primitives/computeCellSize.ts'; +import { computeCellSize } from '../../primitives/computeCellSize'; describe('computeCellSize', () => { it('returns MAX when container is wide enough', () => { diff --git a/src/features/instance/status/analytics/__tests__/pipeline/confidence.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/confidence.test.ts index b6db4407a..74c4ca8a8 100644 --- a/src/features/instance/status/analytics/__tests__/pipeline/confidence.test.ts +++ b/src/features/instance/status/analytics/__tests__/pipeline/confidence.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { classifyConfidence } from '../../pipeline/confidence.ts'; +import { classifyConfidence } from '../../pipeline/confidence'; describe('classifyConfidence (post-aggregation windowed count)', () => { const rule = { greyBelow: 40, suppressBelow: 100 }; 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 a7d7df1d2..72faa49ee 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,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { connectionsSpec } from '../../pipeline/connections.tsx'; -import { runPipeline } from '../../pipeline/pipeline.ts'; +import { connectionsSpec } from '../../pipeline/connections'; +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 70afa4412..51a6a1c09 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,8 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { cpuUsageSpec } from '../../pipeline/cpu-usage.tsx'; -import { runPipeline } from '../../pipeline/pipeline.ts'; -import { QUANTILE_FIELDS } from '../../pipeline/quantileFields.ts'; -import type { AnalyticsDataPoint, MetricSpec, TimeRange } from '../../types/analytics.ts'; +import { cpuUsageSpec } from '../../pipeline/cpu-usage'; +import { runPipeline } from '../../pipeline/pipeline'; +import { QUANTILE_FIELDS } from '../../pipeline/quantileFields'; +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/database-size-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/database-size-pipeline.test.ts index d256532c1..190d14519 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,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { databaseSizeSpec } from '../../pipeline/database-size.tsx'; -import { runPipeline } from '../../pipeline/pipeline.ts'; -import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics.ts'; +import { databaseSizeSpec } from '../../pipeline/database-size'; +import { runPipeline } from '../../pipeline/pipeline'; +import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics'; const window: TimeRange = { startTime: 0, endTime: 10_000_000 }; 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 7f6b33678..c95d0f4b1 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,9 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { dbMessageSpec } from '../../pipeline/db-message.tsx'; -import { dbReadSpec } from '../../pipeline/db-read.tsx'; -import { dbWriteSpec } from '../../pipeline/db-write.tsx'; -import { runPipeline } from '../../pipeline/pipeline.ts'; -import type { AnalyticsDataPoint, MetricSpec, TimeRange } from '../../types/analytics.ts'; +import { dbMessageSpec } from '../../pipeline/db-message'; +import { dbReadSpec } from '../../pipeline/db-read'; +import { dbWriteSpec } from '../../pipeline/db-write'; +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 e564ad6b1..6b5005a82 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,8 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { labelWithApprox } from '../../pipeline/approxLabel.ts'; -import { durationSpec } from '../../pipeline/duration.tsx'; -import { runPipeline } from '../../pipeline/pipeline.ts'; -import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics.ts'; +import { labelWithApprox } from '../../pipeline/approxLabel'; +import { durationSpec } from '../../pipeline/duration'; +import { runPipeline } from '../../pipeline/pipeline'; +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/fieldExpr.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/fieldExpr.test.ts index 968f62e3b..5f654b371 100644 --- a/src/features/instance/status/analytics/__tests__/pipeline/fieldExpr.test.ts +++ b/src/features/instance/status/analytics/__tests__/pipeline/fieldExpr.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { evalFieldExpr } from '../../pipeline/fieldExpr.ts'; -import type { FieldExpr } from '../../types/analytics.ts'; +import { evalFieldExpr } from '../../pipeline/fieldExpr'; +import type { FieldExpr } from '../../types/analytics'; const record = { count: 100, diff --git a/src/features/instance/status/analytics/__tests__/pipeline/main-thread-utilization-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/main-thread-utilization-pipeline.test.ts index 71ee7b4c4..f4050c5e3 100644 --- a/src/features/instance/status/analytics/__tests__/pipeline/main-thread-utilization-pipeline.test.ts +++ b/src/features/instance/status/analytics/__tests__/pipeline/main-thread-utilization-pipeline.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { mainThreadUtilizationSpec } from '../../pipeline/main-thread-utilization.tsx'; -import { runPipeline } from '../../pipeline/pipeline.ts'; -import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics.ts'; +import { mainThreadUtilizationSpec } from '../../pipeline/main-thread-utilization'; +import { runPipeline } from '../../pipeline/pipeline'; +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/memory-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/memory-pipeline.test.ts index 4d9caf2fb..d0f99bbe4 100644 --- a/src/features/instance/status/analytics/__tests__/pipeline/memory-pipeline.test.ts +++ b/src/features/instance/status/analytics/__tests__/pipeline/memory-pipeline.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { memorySpec } from '../../pipeline/memory.tsx'; -import { runPipeline } from '../../pipeline/pipeline.ts'; -import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics.ts'; +import { memorySpec } from '../../pipeline/memory'; +import { runPipeline } from '../../pipeline/pipeline'; +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/pathParser.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/pathParser.test.ts index b32ab9810..7d18e5136 100644 --- a/src/features/instance/status/analytics/__tests__/pipeline/pathParser.test.ts +++ b/src/features/instance/status/analytics/__tests__/pipeline/pathParser.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { parseReplicationPath } from '../../pipeline/pathParser.ts'; +import { parseReplicationPath } from '../../pipeline/pathParser'; const NODES = [ 'xb6-us-west-1.prod.ibm.harperfabric.com', diff --git a/src/features/instance/status/analytics/__tests__/pipeline/pipeline-per-node.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/pipeline-per-node.test.ts index 47cf8fb7d..2c3cbd619 100644 --- a/src/features/instance/status/analytics/__tests__/pipeline/pipeline-per-node.test.ts +++ b/src/features/instance/status/analytics/__tests__/pipeline/pipeline-per-node.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { makeSeriesKey, runPipeline } from '../../pipeline/pipeline.ts'; -import type { AnalyticsDataPoint, MetricSpec, TimeRange } from '../../types/analytics.ts'; +import { makeSeriesKey, 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/pipeline-time-buckets.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/pipeline-time-buckets.test.ts index 9800c8f47..b967d0614 100644 --- a/src/features/instance/status/analytics/__tests__/pipeline/pipeline-time-buckets.test.ts +++ b/src/features/instance/status/analytics/__tests__/pipeline/pipeline-time-buckets.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { runPipeline } from '../../pipeline/pipeline.ts'; -import type { AnalyticsDataPoint, MetricSpec } from '../../types/analytics.ts'; +import { runPipeline } from '../../pipeline/pipeline'; +import type { AnalyticsDataPoint, MetricSpec } from '../../types/analytics'; const baseSpec: MetricSpec = { title: 'test', diff --git a/src/features/instance/status/analytics/__tests__/pipeline/pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/pipeline.test.ts index e286fb26f..2eb42c41f 100644 --- a/src/features/instance/status/analytics/__tests__/pipeline/pipeline.test.ts +++ b/src/features/instance/status/analytics/__tests__/pipeline/pipeline.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { runPipeline } from '../../pipeline/pipeline.ts'; -import type { AnalyticsDataPoint, MetricSpec, TimeRange } from '../../types/analytics.ts'; +import { runPipeline } from '../../pipeline/pipeline'; +import type { AnalyticsDataPoint, MetricSpec, TimeRange } from '../../types/analytics'; const spec: MetricSpec = { title: 'Bytes (test)', diff --git a/src/features/instance/status/analytics/__tests__/pipeline/rate-period-fallback.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/rate-period-fallback.test.ts index fbe272c2e..3bf724b22 100644 --- a/src/features/instance/status/analytics/__tests__/pipeline/rate-period-fallback.test.ts +++ b/src/features/instance/status/analytics/__tests__/pipeline/rate-period-fallback.test.ts @@ -3,8 +3,8 @@ // transforms. The pipeline resolves the effective period via the same // `spec.bucket.fallbackMs ?? 60_000` convention snapToBucketTime uses. import { describe, expect, it } from 'vitest'; -import { runPipeline } from '../../pipeline/pipeline.ts'; -import type { AnalyticsDataPoint, MetricSpec, TimeRange } from '../../types/analytics.ts'; +import { runPipeline } from '../../pipeline/pipeline'; +import type { AnalyticsDataPoint, MetricSpec, TimeRange } from '../../types/analytics'; const window: TimeRange = { startTime: 1_000_000, endTime: 1_060_000 }; diff --git a/src/features/instance/status/analytics/__tests__/pipeline/replication-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/replication-pipeline.test.ts index 7f00f2d8b..b538a51c1 100644 --- a/src/features/instance/status/analytics/__tests__/pipeline/replication-pipeline.test.ts +++ b/src/features/instance/status/analytics/__tests__/pipeline/replication-pipeline.test.ts @@ -1,8 +1,8 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { aggregateReplicationMatrix, bucketLineSeries } from '../../pipeline/replication-latency.tsx'; -import type { AnalyticsDataPoint } from '../../types/analytics.ts'; +import { aggregateReplicationMatrix, bucketLineSeries } from '../../pipeline/replication-latency'; +import type { AnalyticsDataPoint } from '../../types/analytics'; const multi = JSON.parse( readFileSync(join(import.meta.dirname, '../fixtures/replication-latency/multi-source.json'), 'utf8'), diff --git a/src/features/instance/status/analytics/__tests__/pipeline/resource-usage-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/resource-usage-pipeline.test.ts index bdf62dbab..66fa4232f 100644 --- a/src/features/instance/status/analytics/__tests__/pipeline/resource-usage-pipeline.test.ts +++ b/src/features/instance/status/analytics/__tests__/pipeline/resource-usage-pipeline.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { runPipeline } from '../../pipeline/pipeline.ts'; -import { resourceUsageSpec } from '../../pipeline/resource-usage.ts'; +import { runPipeline } from '../../pipeline/pipeline'; +import { resourceUsageSpec } from '../../pipeline/resource-usage'; const records = [ { 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 adf1dd590..10563882d 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,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { runPipeline } from '../../pipeline/pipeline.ts'; -import { response200Spec } from '../../pipeline/response-200.tsx'; -import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics.ts'; +import { runPipeline } from '../../pipeline/pipeline'; +import { response200Spec } from '../../pipeline/response-200'; +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/runTransform.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/runTransform.test.ts index 1f8f5a9be..0eb0d47d4 100644 --- a/src/features/instance/status/analytics/__tests__/pipeline/runTransform.test.ts +++ b/src/features/instance/status/analytics/__tests__/pipeline/runTransform.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { runTransform } from '../../pipeline/runTransform.ts'; -import type { Transform } from '../../types/analytics.ts'; +import { runTransform } from '../../pipeline/runTransform'; +import type { Transform } from '../../types/analytics'; describe('runTransform', () => { it('raw returns input unchanged', () => { diff --git a/src/features/instance/status/analytics/__tests__/pipeline/sort-by-magnitude.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/sort-by-magnitude.test.ts index 21d8ae08c..1b8602350 100644 --- a/src/features/instance/status/analytics/__tests__/pipeline/sort-by-magnitude.test.ts +++ b/src/features/instance/status/analytics/__tests__/pipeline/sort-by-magnitude.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { sortByMagnitude } from '../../primitives/sortByMagnitude.ts'; +import { sortByMagnitude } from '../../primitives/sortByMagnitude'; describe('sortByMagnitude', () => { it('orders series by sum across all points (descending)', () => { diff --git a/src/features/instance/status/analytics/__tests__/pipeline/spec-registry.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/spec-registry.test.ts index 5116d0ece..ea139bbfb 100644 --- a/src/features/instance/status/analytics/__tests__/pipeline/spec-registry.test.ts +++ b/src/features/instance/status/analytics/__tests__/pipeline/spec-registry.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { derivedRegistry } from '../../pipeline/derived/index.ts'; -import { specRegistry } from '../../pipeline/index.ts'; +import { derivedRegistry } from '../../pipeline/derived/index'; +import { specRegistry } from '../../pipeline/index'; // Pins the full registry contents so a typo in the wrapperMetrics factory // table (or a dropped line in the registry) can't silently lose a metric. diff --git a/src/features/instance/status/analytics/__tests__/pipeline/storage-volume-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/storage-volume-pipeline.test.ts index c3d4923ab..6960805e5 100644 --- a/src/features/instance/status/analytics/__tests__/pipeline/storage-volume-pipeline.test.ts +++ b/src/features/instance/status/analytics/__tests__/pipeline/storage-volume-pipeline.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { runPipeline } from '../../pipeline/pipeline.ts'; -import { storageVolumeSpec } from '../../pipeline/storage-volume.ts'; -import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics.ts'; +import { runPipeline } from '../../pipeline/pipeline'; +import { storageVolumeSpec } from '../../pipeline/storage-volume'; +import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics'; const window: TimeRange = { startTime: 0, endTime: 10_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 dd03aaa61..72a627083 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,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { runPipeline } from '../../pipeline/pipeline.ts'; -import { successSpec } from '../../pipeline/success.tsx'; -import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics.ts'; +import { runPipeline } from '../../pipeline/pipeline'; +import { successSpec } from '../../pipeline/success'; +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/tls-reused-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/tls-reused-pipeline.test.ts index e65412b03..458ad05ac 100644 --- a/src/features/instance/status/analytics/__tests__/pipeline/tls-reused-pipeline.test.ts +++ b/src/features/instance/status/analytics/__tests__/pipeline/tls-reused-pipeline.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { runPipeline } from '../../pipeline/pipeline.ts'; -import { tlsReusedSpec } from '../../pipeline/tls-reused.ts'; -import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics.ts'; +import { runPipeline } from '../../pipeline/pipeline'; +import { tlsReusedSpec } from '../../pipeline/tls-reused'; +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/transaction-log-growth.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/transaction-log-growth.test.ts index bfc2d1672..6ae07209c 100644 --- a/src/features/instance/status/analytics/__tests__/pipeline/transaction-log-growth.test.ts +++ b/src/features/instance/status/analytics/__tests__/pipeline/transaction-log-growth.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { recomputeTransactionLogGrowth } from '../../pipeline/derived/transaction-log-growth.tsx'; -import type { AnalyticsDataPoint } from '../../types/analytics.ts'; +import { recomputeTransactionLogGrowth } from '../../pipeline/derived/transaction-log-growth'; +import type { AnalyticsDataPoint } from '../../types/analytics'; const range = { startTime: 0, endTime: 10_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 bbe595697..28102d773 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,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { runPipeline } from '../../pipeline/pipeline.ts'; -import { transferSpec } from '../../pipeline/transfer.tsx'; -import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics.ts'; +import { runPipeline } from '../../pipeline/pipeline'; +import { transferSpec } from '../../pipeline/transfer'; +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/transforms.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/transforms.test.ts index 21afcb0bd..f92689a92 100644 --- a/src/features/instance/status/analytics/__tests__/pipeline/transforms.test.ts +++ b/src/features/instance/status/analytics/__tests__/pipeline/transforms.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { type NamedTransformKey, namedTransforms } from '../../pipeline/transforms.ts'; +import { type NamedTransformKey, namedTransforms } from '../../pipeline/transforms'; describe('namedTransforms registry', () => { it('exports percent-of-core that multiplies by 100', () => { diff --git a/src/features/instance/status/analytics/__tests__/pipeline/utilization-pipeline.test.ts b/src/features/instance/status/analytics/__tests__/pipeline/utilization-pipeline.test.ts index 9e23032bd..76b0efc1d 100644 --- a/src/features/instance/status/analytics/__tests__/pipeline/utilization-pipeline.test.ts +++ b/src/features/instance/status/analytics/__tests__/pipeline/utilization-pipeline.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { runPipeline } from '../../pipeline/pipeline.ts'; -import { utilizationSpec } from '../../pipeline/utilization.ts'; -import type { AnalyticsDataPoint, TimeRange } from '../../types/analytics.ts'; +import { runPipeline } from '../../pipeline/pipeline'; +import { utilizationSpec } from '../../pipeline/utilization'; +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__/primitives/dimension-chip-row.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/dimension-chip-row.test.tsx index 83a54eb6d..df8df3660 100644 --- a/src/features/instance/status/analytics/__tests__/primitives/dimension-chip-row.test.tsx +++ b/src/features/instance/status/analytics/__tests__/primitives/dimension-chip-row.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it } from 'vitest'; -import { DimensionChipRow } from '../../primitives/DimensionChipRow.tsx'; +import { DimensionChipRow } from '../../primitives/DimensionChipRow'; describe('DimensionChipRow primitive', () => { afterEach(() => cleanup()); diff --git a/src/features/instance/status/analytics/__tests__/primitives/dimension-combobox.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/dimension-combobox.test.tsx index fecefdf53..5554bdd57 100644 --- a/src/features/instance/status/analytics/__tests__/primitives/dimension-combobox.test.tsx +++ b/src/features/instance/status/analytics/__tests__/primitives/dimension-combobox.test.tsx @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { useState } from 'react'; import { afterEach, describe, expect, it } from 'vitest'; -import { DimensionCombobox } from '../../primitives/DimensionCombobox.tsx'; +import { DimensionCombobox } from '../../primitives/DimensionCombobox'; function Harness(props: { values: readonly string[]; initial: string; otherKey?: string }) { const [sel, setSel] = useState(props.initial); diff --git a/src/features/instance/status/analytics/__tests__/primitives/dimension-selector-pipe-dim.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/dimension-selector-pipe-dim.test.tsx index f565c6c7f..5a187d5b3 100644 --- a/src/features/instance/status/analytics/__tests__/primitives/dimension-selector-pipe-dim.test.tsx +++ b/src/features/instance/status/analytics/__tests__/primitives/dimension-selector-pipe-dim.test.tsx @@ -6,8 +6,8 @@ // reads the structured Series.dim/Series.node fields. import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it } from 'vitest'; -import { DimensionSelectorRenderer } from '../../primitives/DimensionSelectorRenderer.tsx'; -import type { AnalyticsDataPoint, MetricSpec } from '../../types/analytics.ts'; +import { DimensionSelectorRenderer } from '../../primitives/DimensionSelectorRenderer'; +import type { AnalyticsDataPoint, MetricSpec } from '../../types/analytics'; const range = { startTime: 0, endTime: 10_000_000 }; diff --git a/src/features/instance/status/analytics/__tests__/primitives/dimension-selector-switch.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/dimension-selector-switch.test.tsx index e1c5623c6..fd7922abf 100644 --- a/src/features/instance/status/analytics/__tests__/primitives/dimension-selector-switch.test.tsx +++ b/src/features/instance/status/analytics/__tests__/primitives/dimension-selector-switch.test.tsx @@ -1,8 +1,8 @@ // @vitest-environment happy-dom import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it } from 'vitest'; -import { DimensionSelectorRenderer } from '../../primitives/DimensionSelectorRenderer.tsx'; -import type { AnalyticsDataPoint, MetricSpec } from '../../types/analytics.ts'; +import { DimensionSelectorRenderer } from '../../primitives/DimensionSelectorRenderer'; +import type { AnalyticsDataPoint, MetricSpec } from '../../types/analytics'; const range = { startTime: 0, endTime: 10_000_000 }; diff --git a/src/features/instance/status/analytics/__tests__/primitives/fallback-renderer.test.ts b/src/features/instance/status/analytics/__tests__/primitives/fallback-renderer.test.ts index 3fe1d88f5..2bab0c4cd 100644 --- a/src/features/instance/status/analytics/__tests__/primitives/fallback-renderer.test.ts +++ b/src/features/instance/status/analytics/__tests__/primitives/fallback-renderer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { buildFallbackPanels } from '../../primitives/FallbackRenderer.tsx'; -import type { AnalyticsDataPoint } from '../../types/analytics.ts'; +import { buildFallbackPanels } from '../../primitives/FallbackRenderer'; +import type { AnalyticsDataPoint } from '../../types/analytics'; describe('buildFallbackPanels', () => { it('drops records lacking a numeric time instead of plotting them at x=0 (1970)', () => { 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 2ae3ada0b..0e618466a 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,8 +1,8 @@ // @vitest-environment happy-dom import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it } from 'vitest'; -import { HeatmapMatrix } from '../../primitives/HeatmapMatrix.tsx'; -import type { HeatmapData } from '../../types/analytics.ts'; +import { HeatmapMatrix } from '../../primitives/HeatmapMatrix'; +import type { HeatmapData } from '../../types/analytics'; const data: HeatmapData = { rows: ['src-1', 'src-2'], @@ -26,7 +26,7 @@ const data: HeatmapData = { describe('HeatmapMatrix primitive', () => { afterEach(() => cleanup()); it('renders a grid with one row per data row', () => { - render(); + render(); const grid = screen.getByRole('grid'); expect(grid).toBeTruthy(); // 2 data rows + 1 header row @@ -35,7 +35,7 @@ describe('HeatmapMatrix primitive', () => { }); it('includes "(approx)" and "p95" in cell aria-labels when approx flag is set', () => { - render(); + render(); const cells = screen.getAllByRole('gridcell'); const okCell = cells.find((c) => /src-1.*dest-a|30/.test(c.getAttribute('aria-label') ?? ''))!; expect(okCell).toBeTruthy(); @@ -45,39 +45,39 @@ describe('HeatmapMatrix primitive', () => { }); it('absent cells announce "no data"', () => { - render(); + render(); const cell = screen.getAllByRole('gridcell').find((c) => (c.getAttribute('aria-label') ?? '').includes('no data')); expect(cell).toBeTruthy(); }); it('suppresses cells below suppressBelow as data-confidence="suppress"', () => { - render(); + render(); const cells = screen.getAllByRole('gridcell'); const suppressed = cells.filter((c) => c.getAttribute('data-confidence') === 'suppress'); expect(suppressed.length >= 1).toBeTruthy(); }); it('greys cells where greyBelow ≤ count < suppressBelow', () => { - render(); + render(); const cells = screen.getAllByRole('gridcell'); const grey = cells.filter((c) => c.getAttribute('data-confidence') === 'grey'); expect(grey.length >= 1).toBeTruthy(); }); it('renders rowheader + columnheader cells', () => { - render(); + render(); expect(screen.getByRole('rowheader', { name: /src-1/i })).toBeTruthy(); expect(screen.getByRole('columnheader', { name: /dest-a/i })).toBeTruthy(); }); it('renders a color-scale legend', () => { - render(); + render(); const legend = screen.getByRole('img', { name: /color scale|p95 latency/i }); expect(legend).toBeTruthy(); }); it('ArrowRight moves focus one column right and preventDefaults', () => { - render(); + render(); const cells = screen.getAllByRole('gridcell'); // Find src-1 → dest-a const first = cells.find((c) => @@ -94,7 +94,7 @@ describe('HeatmapMatrix primitive', () => { }); it('ArrowRight on rightmost cell is a no-op (no wrap)', () => { - render(); + render(); const cells = screen.getAllByRole('gridcell'); const last = cells.find((c) => /src-1/.test(c.getAttribute('aria-label') ?? '') && /dest-c/.test(c.getAttribute('aria-label') ?? '') @@ -106,7 +106,7 @@ describe('HeatmapMatrix primitive', () => { }); it('Home/End jump to row start/end', () => { - render(); + render(); const cells = screen.getAllByRole('gridcell'); const mid = cells.find((c) => /src-1/.test(c.getAttribute('aria-label') ?? '') && /dest-b/.test(c.getAttribute('aria-label') ?? '') @@ -120,20 +120,20 @@ describe('HeatmapMatrix primitive', () => { }); it('omits "(approx)" from description when data.approx is false', () => { - render(); + render(); const desc = screen.getByTestId('heatmap-desc'); expect(desc.textContent ?? '').not.toMatch(/\(approx\)/i); expect(desc.textContent ?? '').not.toMatch(/count-weighted-mean/i); }); it('omits "(count-weighted-mean, approx)" from color-legend aria-label when data.approx is false', () => { - render(); + render(); const legend = screen.getByRole('img', { name: /p95 latency/i }); expect(legend.getAttribute('aria-label') ?? '').not.toMatch(/count-weighted-mean.*approx/i); }); it('clamps the roving tabindex when the matrix shrinks (regression #1443)', () => { - const { rerender } = render(); + const { rerender } = render(); // Move the active cell to the last row/col via keyboard: End then // ArrowDown lands on src-2 → dest-c ([1, 2]). const cells = screen.getAllByRole('gridcell'); @@ -156,7 +156,7 @@ describe('HeatmapMatrix primitive', () => { { row: 'src-1', col: 'dest-b', value: 60, count: 300 }, ], }; - rerender(); + rerender(); const shrunkCells = screen.getAllByRole('gridcell'); expect(shrunkCells.length).toBe(2); @@ -173,7 +173,7 @@ describe('HeatmapMatrix primitive', () => { }); it('exposes data-cell-size attribute as a number on the SVG root', () => { - const { container } = render(); + const { container } = render(); const svg = container.querySelector('svg[role="grid"]'); const cellSize = Number(svg?.getAttribute('data-cell-size')); expect(Number.isFinite(cellSize) && cellSize >= 40 && cellSize <= 80).toBeTruthy(); diff --git a/src/features/instance/status/analytics/__tests__/primitives/line-chart.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/line-chart.test.tsx index a1900b5a8..0cb08d5fa 100644 --- a/src/features/instance/status/analytics/__tests__/primitives/line-chart.test.tsx +++ b/src/features/instance/status/analytics/__tests__/primitives/line-chart.test.tsx @@ -1,8 +1,8 @@ // @vitest-environment happy-dom import { cleanup, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it } from 'vitest'; -import { LineChart } from '../../primitives/LineChart.tsx'; -import type { SeriesData } from '../../types/analytics.ts'; +import { LineChart } from '../../primitives/LineChart'; +import type { SeriesData } from '../../types/analytics'; const singleSeries: SeriesData = { series: [{ key: 's1', label: 'Series One', points: [{ x: 1, y: 10 }, { x: 2, y: 20 }] }], @@ -18,18 +18,18 @@ describe('LineChart primitive', () => { afterEach(() => cleanup()); it('renders empty-state when series is empty', () => { - render(); + render(); expect(screen.getByText(/no data in window/i)).toBeTruthy(); }); it('mounts a Recharts wrapper', () => { - render(); + render(); const container = document.querySelector('.recharts-responsive-container'); expect(container, 'ResponsiveContainer mounted').toBeTruthy(); }); it('exposes role=img with a composed aria-label so screen readers see the chart', () => { - render(); + render(); const img = screen.getByRole('img'); expect(img.getAttribute('aria-label') ?? '').toMatch(/Series One/); }); @@ -41,7 +41,7 @@ describe('LineChart primitive', () => { { value: 0.999, label: '99.9% SLO', direction: 'below-is-bad' }, ], }; - render(); + render(); // Recharts renders the label text somewhere in the SVG. Search the // whole rendered DOM for a string containing label + direction marker. await waitFor(() => { @@ -52,7 +52,7 @@ describe('LineChart primitive', () => { }); it('empty-state uses role=status for live-region announcement', () => { - render(); + render(); const status = screen.getByRole('status'); expect(status.textContent ?? '').toMatch(/no data in window/i); }); @@ -67,7 +67,6 @@ describe('LineChart primitive', () => { render( { { key: 'b', label: 'B', points: [{ x: 0, y: 15 }, { x: 1, y: 25 }], opacity: 0.55 }, ], }; - const { container } = render(); + const { container } = render(); // Recharts emits // after ResponsiveContainer sizes via ResizeObserver. Wait for the dimmed // line to assert it carries the explicit opacity. @@ -103,7 +102,7 @@ describe('LineChart primitive', () => { ...singleSeries, thresholds: [{ value: 15, label: 'max', direction: 'above-is-bad' }], }; - render(); + render(); // Recharts renders ReferenceLine inside the svg after ResponsiveContainer sizes. await waitFor(() => { const lines = document.querySelectorAll('.recharts-reference-line'); @@ -114,7 +113,7 @@ describe('LineChart primitive', () => { it.skip('appends unit suffix to Y-axis tick labels', async () => { // Use formatter='count' so the suffix is the only decoration on values. const data: SeriesData = { series: [{ key: 'a', label: 'A', points: [{ x: 0, y: 50 }, { x: 1, y: 100 }] }] }; - const { container } = render(); + const { container } = render(); await waitFor(() => { const tickEls = container.querySelectorAll('.recharts-cartesian-axis-tick-value, .recharts-yAxis text'); const labels = Array.from(tickEls).map((t) => t.textContent ?? ''); diff --git a/src/features/instance/status/analytics/__tests__/primitives/small-multiples.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/small-multiples.test.tsx index 37ed61308..18a3468a3 100644 --- a/src/features/instance/status/analytics/__tests__/primitives/small-multiples.test.tsx +++ b/src/features/instance/status/analytics/__tests__/primitives/small-multiples.test.tsx @@ -1,8 +1,8 @@ // @vitest-environment happy-dom import { cleanup, render, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it } from 'vitest'; -import { SmallMultiples } from '../../primitives/SmallMultiples.tsx'; -import type { AxisSpec, SeriesData } from '../../types/analytics.ts'; +import { SmallMultiples } from '../../primitives/SmallMultiples'; +import type { AxisSpec, SeriesData } from '../../types/analytics'; const panels: { title: string; data: SeriesData; yAxis?: AxisSpec | { left: AxisSpec; right?: AxisSpec } }[] = [ { title: 'CPU', data: { series: [{ key: 'cpu', label: 'cpu', points: [{ x: 1, y: 10 }] }] } }, diff --git a/src/features/instance/status/analytics/__tests__/primitives/stack-by-toggle.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/stack-by-toggle.test.tsx index b7ce9f093..3688d3284 100644 --- a/src/features/instance/status/analytics/__tests__/primitives/stack-by-toggle.test.tsx +++ b/src/features/instance/status/analytics/__tests__/primitives/stack-by-toggle.test.tsx @@ -1,8 +1,8 @@ // @vitest-environment happy-dom import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it } from 'vitest'; -import { TrafficByTypeRenderer } from '../../primitives/TrafficByTypeRenderer.tsx'; -import type { AnalyticsDataPoint, MetricSpec } from '../../types/analytics.ts'; +import { TrafficByTypeRenderer } from '../../primitives/TrafficByTypeRenderer'; +import type { AnalyticsDataPoint, MetricSpec } from '../../types/analytics'; const range = { startTime: 0, endTime: 10_000_000 }; diff --git a/src/features/instance/status/analytics/__tests__/primitives/stacked-area.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/stacked-area.test.tsx index 3f9d8d40e..4819088d3 100644 --- a/src/features/instance/status/analytics/__tests__/primitives/stacked-area.test.tsx +++ b/src/features/instance/status/analytics/__tests__/primitives/stacked-area.test.tsx @@ -1,8 +1,8 @@ // @vitest-environment happy-dom import { cleanup, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it } from 'vitest'; -import { StackedAreaChart, StackedAreaTooltip } from '../../primitives/StackedAreaChart.tsx'; -import type { SeriesData } from '../../types/analytics.ts'; +import { StackedAreaChart, StackedAreaTooltip } from '../../primitives/StackedAreaChart'; +import type { SeriesData } from '../../types/analytics'; const stacked: SeriesData = { series: [ diff --git a/src/features/instance/status/analytics/__tests__/tableColors.test.ts b/src/features/instance/status/analytics/__tests__/tableColors.test.ts index c822b1cd5..ba21662c4 100644 --- a/src/features/instance/status/analytics/__tests__/tableColors.test.ts +++ b/src/features/instance/status/analytics/__tests__/tableColors.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { getTableColor, OTHER_COLOR, TABLE_PALETTE } from '../lib/tableColors.ts'; +import { getTableColor, OTHER_COLOR, TABLE_PALETTE } from '../lib/tableColors'; describe('getTableColor', () => { it('returns distinct colors for the first 8 indices', () => { diff --git a/src/features/instance/status/analytics/__tests__/tableSize.test.ts b/src/features/instance/status/analytics/__tests__/tableSize.test.ts index 9b0439c99..aa41015eb 100644 --- a/src/features/instance/status/analytics/__tests__/tableSize.test.ts +++ b/src/features/instance/status/analytics/__tests__/tableSize.test.ts @@ -16,8 +16,8 @@ import { type Snapshot, TOP_N, toTableKey, -} from '../lib/tableSize.ts'; -import type { TableSizeRecord } from '../types/analytics.ts'; +} from '../lib/tableSize'; +import type { TableSizeRecord } from '../types/analytics'; function fixture(name: string): TableSizeRecord[] { const path = join(import.meta.dirname, 'fixtures', 'table-size', `${name}.json`); diff --git a/src/features/instance/status/analytics/__tests__/time.test.ts b/src/features/instance/status/analytics/__tests__/time.test.ts index c13834787..2bcd935e0 100644 --- a/src/features/instance/status/analytics/__tests__/time.test.ts +++ b/src/features/instance/status/analytics/__tests__/time.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatAxisTick, formatTooltipTime, getTimezoneAbbr } from '../lib/time.ts'; +import { formatAxisTick, formatTooltipTime, getTimezoneAbbr } from '../lib/time'; describe('formatAxisTick', () => { it('formats a timestamp to local time string', () => { diff --git a/src/features/instance/status/analytics/charts/NodeLegend.tsx b/src/features/instance/status/analytics/charts/NodeLegend.tsx index 8743cc158..cb9f2a55a 100644 --- a/src/features/instance/status/analytics/charts/NodeLegend.tsx +++ b/src/features/instance/status/analytics/charts/NodeLegend.tsx @@ -1,4 +1,4 @@ -import { getNodeColor } from '../lib/nodeColors.ts'; +import { getNodeColor } from '../lib/nodeColors'; interface NodeLegendProps { nodeIds: string[]; diff --git a/src/features/instance/status/analytics/charts/TableSizeChipRow.tsx b/src/features/instance/status/analytics/charts/TableSizeChipRow.tsx index 26155b10c..489836af3 100644 --- a/src/features/instance/status/analytics/charts/TableSizeChipRow.tsx +++ b/src/features/instance/status/analytics/charts/TableSizeChipRow.tsx @@ -1,6 +1,6 @@ -import { useRovingRadioGroup } from '../hooks/useRovingRadioGroup.ts'; -import { getTableColor, OTHER_COLOR } from '../lib/tableColors.ts'; -import { OTHER_KEY } from '../lib/tableSize.ts'; +import { useRovingRadioGroup } from '../hooks/useRovingRadioGroup'; +import { getTableColor, OTHER_COLOR } from '../lib/tableColors'; +import { OTHER_KEY } from '../lib/tableSize'; interface TableSizeChipRowProps { /** Selectable table keys, in display order. */ diff --git a/src/features/instance/status/analytics/charts/TableSizeSnapshot.tsx b/src/features/instance/status/analytics/charts/TableSizeSnapshot.tsx index 8de8f3b60..094fa9209 100644 --- a/src/features/instance/status/analytics/charts/TableSizeSnapshot.tsx +++ b/src/features/instance/status/analytics/charts/TableSizeSnapshot.tsx @@ -1,13 +1,13 @@ import { formatValue } from '@/lib/formatValue'; import { Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; -import { useNodeSelection } from '../hooks/useNodeSelection.ts'; -import { getTableColor, OTHER_COLOR } from '../lib/tableColors.ts'; -import { OTHER_KEY, type Snapshot } from '../lib/tableSize.ts'; -import { getChartColors, type Theme } from '../lib/theme.ts'; -import { tooltipContentStyle, tooltipItemStyle, tooltipLabelStyle } from '../primitives/tooltipStyle.ts'; -import type { ViewMode } from '../types/analytics.ts'; -import { NodeLegend } from './NodeLegend.tsx'; -import { TableSizeChipRow } from './TableSizeChipRow.tsx'; +import { useNodeSelection } from '../hooks/useNodeSelection'; +import { getTableColor, OTHER_COLOR } from '../lib/tableColors'; +import { OTHER_KEY, type Snapshot } from '../lib/tableSize'; +import { getChartColors } from '../lib/theme'; +import { tooltipContentStyle, tooltipItemStyle, tooltipLabelStyle } from '../primitives/tooltipStyle'; +import type { ViewMode } from '../types/analytics'; +import { NodeLegend } from './NodeLegend'; +import { TableSizeChipRow } from './TableSizeChipRow'; /** One byte-format path for every chart — see src/lib/formatValue.ts. */ const formatBytes = (bytes: number) => formatValue(bytes, 'bytes-si'); @@ -16,10 +16,6 @@ interface Props { snapshot: Snapshot; /** Display mode: 'per-node' → Absolute (raw bytes), 'aggregate' → Normalized (percent of cluster max). */ viewMode: ViewMode; - /** @deprecated Ignored — theming resolves via `--chart-*` CSS tokens. - * Kept optional only while StorageTab (owned by a parallel refactor) - * still passes it. */ - theme?: Theme; /** Snapshot's own highlight — drives chip `aria-checked` + bar-segment outline. */ selectedTable: string | null; /** Chip click / Enter / Space / arrow-nav — local to this panel; should NOT diff --git a/src/features/instance/status/analytics/charts/TableSizeTrend.tsx b/src/features/instance/status/analytics/charts/TableSizeTrend.tsx index 10d748cc1..0a517c5f7 100644 --- a/src/features/instance/status/analytics/charts/TableSizeTrend.tsx +++ b/src/features/instance/status/analytics/charts/TableSizeTrend.tsx @@ -1,15 +1,15 @@ import { formatValue } from '@/lib/formatValue'; import { useMemo } from 'react'; import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; -import { useNodeSelection } from '../hooks/useNodeSelection.ts'; -import { getNodeColor } from '../lib/nodeColors.ts'; -import { computeGrowthAnnotation, type RankBy, type TableSizeDerived } from '../lib/tableSize.ts'; -import { getChartColors, type Theme } from '../lib/theme.ts'; -import { formatAxisTick, formatTooltipTime } from '../lib/time.ts'; -import { tooltipContentStyle, tooltipItemStyle, tooltipLabelStyle } from '../primitives/tooltipStyle.ts'; -import type { TimeRange, ViewMode } from '../types/analytics.ts'; -import { NodeLegend } from './NodeLegend.tsx'; -import { TableSizeChipRow } from './TableSizeChipRow.tsx'; +import { useNodeSelection } from '../hooks/useNodeSelection'; +import { getNodeColor } from '../lib/nodeColors'; +import { computeGrowthAnnotation, type RankBy, type TableSizeDerived } from '../lib/tableSize'; +import { getChartColors } from '../lib/theme'; +import { formatAxisTick, formatTooltipTime } from '../lib/time'; +import { tooltipContentStyle, tooltipItemStyle, tooltipLabelStyle } from '../primitives/tooltipStyle'; +import type { TimeRange, ViewMode } from '../types/analytics'; +import { NodeLegend } from './NodeLegend'; +import { TableSizeChipRow } from './TableSizeChipRow'; /** One byte-format path for every chart — see src/lib/formatValue.ts. */ const formatBytes = (bytes: number) => formatValue(bytes, 'bytes-si'); @@ -17,10 +17,6 @@ const formatBytes = (bytes: number) => formatValue(bytes, 'bytes-si'); interface Props { derived: TableSizeDerived; viewMode: ViewMode; - /** @deprecated Ignored — theming resolves via `--chart-*` CSS tokens. - * Kept optional only while StorageTab (owned by a parallel refactor) - * still passes it. */ - theme?: Theme; selectedTable: string | null; /** Trend chip click — local to this panel; should NOT touch the Snapshot. */ onChipSelect: (tableKey: string) => void; diff --git a/src/features/instance/status/analytics/components/AnalyticsOnboardingHint.test.tsx b/src/features/instance/status/analytics/components/AnalyticsOnboardingHint.test.tsx index b9dc0c408..6f206bd8d 100644 --- a/src/features/instance/status/analytics/components/AnalyticsOnboardingHint.test.tsx +++ b/src/features/instance/status/analytics/components/AnalyticsOnboardingHint.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { AnalyticsOnboardingHint } from './AnalyticsOnboardingHint.tsx'; +import { AnalyticsOnboardingHint } from './AnalyticsOnboardingHint'; const STORAGE_KEY = 'studio:analytics:onboarding-dismissed:v1'; diff --git a/src/features/instance/status/analytics/components/ChartCopyButton.test.tsx b/src/features/instance/status/analytics/components/ChartCopyButton.test.tsx index b6f672e5b..5850e97bc 100644 --- a/src/features/instance/status/analytics/components/ChartCopyButton.test.tsx +++ b/src/features/instance/status/analytics/components/ChartCopyButton.test.tsx @@ -15,8 +15,8 @@ vi.mock('sonner', () => ({ toast: { success: mocks.toastSuccess, error: mocks.toastError }, })); -import { AnalyticsTestWrapper } from '../tabs/__tests__/testHelpers.tsx'; -import { ChartCopyButton } from './ChartCopyButton.tsx'; +import { AnalyticsTestWrapper } from '../tabs/__tests__/testHelpers'; +import { ChartCopyButton } from './ChartCopyButton'; afterEach(() => { cleanup(); diff --git a/src/features/instance/status/analytics/components/ChartCopyButton.tsx b/src/features/instance/status/analytics/components/ChartCopyButton.tsx index 260d0c117..6c4e5f50a 100644 --- a/src/features/instance/status/analytics/components/ChartCopyButton.tsx +++ b/src/features/instance/status/analytics/components/ChartCopyButton.tsx @@ -3,7 +3,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { ClipboardCopy } from 'lucide-react'; import { type RefObject, useState } from 'react'; import { toast } from 'sonner'; -import { copyChartToClipboard } from '../lib/chartExport.ts'; +import { copyChartToClipboard } from '../lib/chartExport'; interface Props { /** The DOM node that should be captured. Usually the chart's outer Card. */ diff --git a/src/features/instance/status/analytics/components/ChartExpandButton.test.tsx b/src/features/instance/status/analytics/components/ChartExpandButton.test.tsx index c7d547b87..4f7d05a13 100644 --- a/src/features/instance/status/analytics/components/ChartExpandButton.test.tsx +++ b/src/features/instance/status/analytics/components/ChartExpandButton.test.tsx @@ -13,8 +13,8 @@ vi.mock('sonner', () => ({ toast: { success: mocks.toastSuccess, error: mocks.toastError }, })); -import { AnalyticsTestWrapper } from '../tabs/__tests__/testHelpers.tsx'; -import { ChartExpandButton } from './ChartExpandButton.tsx'; +import { AnalyticsTestWrapper } from '../tabs/__tests__/testHelpers'; +import { ChartExpandButton } from './ChartExpandButton'; afterEach(() => { cleanup(); diff --git a/src/features/instance/status/analytics/components/ChartExpandButton.tsx b/src/features/instance/status/analytics/components/ChartExpandButton.tsx index c0a1968ff..37f626d29 100644 --- a/src/features/instance/status/analytics/components/ChartExpandButton.tsx +++ b/src/features/instance/status/analytics/components/ChartExpandButton.tsx @@ -3,8 +3,8 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Maximize2 } from 'lucide-react'; import { type ReactNode, useRef, useState } from 'react'; -import { ChartCopyButton } from './ChartCopyButton.tsx'; -import { ChartExportButton } from './ChartExportButton.tsx'; +import { ChartCopyButton } from './ChartCopyButton'; +import { ChartExportButton } from './ChartExportButton'; interface Props { /** Slug for the export filename if the user exports from the expanded view. */ diff --git a/src/features/instance/status/analytics/components/ChartExportButton.test.tsx b/src/features/instance/status/analytics/components/ChartExportButton.test.tsx index ddc337c43..8ab6f8758 100644 --- a/src/features/instance/status/analytics/components/ChartExportButton.test.tsx +++ b/src/features/instance/status/analytics/components/ChartExportButton.test.tsx @@ -16,8 +16,8 @@ vi.mock('sonner', () => ({ toast: { success: mocks.toastSuccess, error: mocks.toastError }, })); -import { AnalyticsTestWrapper } from '../tabs/__tests__/testHelpers.tsx'; -import { ChartExportButton } from './ChartExportButton.tsx'; +import { AnalyticsTestWrapper } from '../tabs/__tests__/testHelpers'; +import { ChartExportButton } from './ChartExportButton'; afterEach(() => { cleanup(); diff --git a/src/features/instance/status/analytics/components/ChartExportButton.tsx b/src/features/instance/status/analytics/components/ChartExportButton.tsx index 4f97f764f..18f9cdce6 100644 --- a/src/features/instance/status/analytics/components/ChartExportButton.tsx +++ b/src/features/instance/status/analytics/components/ChartExportButton.tsx @@ -3,8 +3,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { Download } from 'lucide-react'; import { type RefObject, useState } from 'react'; import { toast } from 'sonner'; -import { useAnalyticsContext } from '../context/AnalyticsContext.tsx'; -import { downloadChart, makeExportFilename } from '../lib/chartExport.ts'; +import { useAnalyticsContext } from '../context/AnalyticsContext'; +import { downloadChart, makeExportFilename } from '../lib/chartExport'; interface Props { /** The DOM node that should be captured. Usually the chart's outer Card. */ diff --git a/src/features/instance/status/analytics/components/TimeRangePicker.test.tsx b/src/features/instance/status/analytics/components/TimeRangePicker.test.tsx index 6386d5c5d..eada6fa4b 100644 --- a/src/features/instance/status/analytics/components/TimeRangePicker.test.tsx +++ b/src/features/instance/status/analytics/components/TimeRangePicker.test.tsx @@ -7,8 +7,8 @@ vi.mock('../hooks/useAnalyticsFreshness.ts', () => ({ formatRelativeUpdate: () => null, })); -import { AnalyticsTestWrapper } from '../tabs/__tests__/testHelpers.tsx'; -import { TimeRangePicker } from './TimeRangePicker.tsx'; +import { AnalyticsTestWrapper } from '../tabs/__tests__/testHelpers'; +import { TimeRangePicker } from './TimeRangePicker'; afterEach(cleanup); @@ -63,7 +63,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( + const { TimeRangePicker: PickerWithFetching } = await vi.importActual( './TimeRangePicker.tsx', ); // Use the same component but assert via aria-busy — the mock above is diff --git a/src/features/instance/status/analytics/components/TimeRangePicker.tsx b/src/features/instance/status/analytics/components/TimeRangePicker.tsx index a0ecaaf28..c3b5fbaed 100644 --- a/src/features/instance/status/analytics/components/TimeRangePicker.tsx +++ b/src/features/instance/status/analytics/components/TimeRangePicker.tsx @@ -2,9 +2,9 @@ import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { cn } from '@/lib/cn'; import { RefreshCw } from 'lucide-react'; -import { useAnalyticsContext } from '../context/AnalyticsContext.tsx'; -import { REFRESH_OPTIONS, TIME_PRESETS, type TimePresetId } from '../context/timePresets.ts'; -import { formatRelativeUpdate, useAnalyticsFreshness } from '../hooks/useAnalyticsFreshness.ts'; +import { useAnalyticsContext } from '../context/AnalyticsContext'; +import { REFRESH_OPTIONS, TIME_PRESETS, type TimePresetId } from '../context/timePresets'; +import { formatRelativeUpdate, useAnalyticsFreshness } from '../hooks/useAnalyticsFreshness'; interface Props { presetId: TimePresetId; diff --git a/src/features/instance/status/analytics/context/AnalyticsContext.tsx b/src/features/instance/status/analytics/context/AnalyticsContext.tsx index a5d4d8da5..1820e955f 100644 --- a/src/features/instance/status/analytics/context/AnalyticsContext.tsx +++ b/src/features/instance/status/analytics/context/AnalyticsContext.tsx @@ -1,18 +1,10 @@ -import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig.ts'; +import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig'; import { createContext, type ReactNode, useContext, useMemo } from 'react'; -import type { TimeRange } from '../types/analytics.ts'; - -export type AnalyticsTheme = 'light' | 'dark'; +import type { TimeRange } from '../types/analytics'; export interface AnalyticsContextValue { timeRange: TimeRange; bucketMs: number; - /** Resolved app theme. Chart primitives no longer read this — they theme - * via `--chart-*` CSS tokens (and useResolvedTheme() for the few JS - * branches). Kept only for StorageTab / ConnectionsPanel, which still - * pass it into the pipeline-owned renderer signatures; remove once that - * parallel refactor drops their `theme` props. */ - theme: AnalyticsTheme; instanceParams: InstanceClientIdConfig & InstanceTypeConfig; } @@ -28,7 +20,6 @@ export function AnalyticsProvider({ value, children }: ProviderProps) { value.timeRange.startTime, value.timeRange.endTime, value.bucketMs, - value.theme, value.instanceParams.entityId, ]); return {children}; diff --git a/src/features/instance/status/analytics/context/timePresets.test.ts b/src/features/instance/status/analytics/context/timePresets.test.ts index a1483edac..2b4a5c46c 100644 --- a/src/features/instance/status/analytics/context/timePresets.test.ts +++ b/src/features/instance/status/analytics/context/timePresets.test.ts @@ -6,7 +6,7 @@ import { REFRESH_OPTIONS, TIME_PRESETS, type TimePresetId, -} from './timePresets.ts'; +} from './timePresets'; describe('timePresets', () => { it('exposes all five preset ids in stable order', () => { diff --git a/src/features/instance/status/analytics/hooks/useAnalyticsCapability.test.ts b/src/features/instance/status/analytics/hooks/useAnalyticsCapability.test.ts index 556d5ac30..d5a174d08 100644 --- a/src/features/instance/status/analytics/hooks/useAnalyticsCapability.test.ts +++ b/src/features/instance/status/analytics/hooks/useAnalyticsCapability.test.ts @@ -1,10 +1,10 @@ // @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 { act, renderHook, waitFor } from '@testing-library/react'; import { createElement, type ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { useAnalyticsCapability } from './useAnalyticsCapability.ts'; +import { useAnalyticsCapability } from './useAnalyticsCapability'; function makeParams(post: ReturnType): InstanceClientIdConfig & InstanceTypeConfig { return { diff --git a/src/features/instance/status/analytics/hooks/useAnalyticsCapability.ts b/src/features/instance/status/analytics/hooks/useAnalyticsCapability.ts index d0a869212..f3eac926d 100644 --- a/src/features/instance/status/analytics/hooks/useAnalyticsCapability.ts +++ b/src/features/instance/status/analytics/hooks/useAnalyticsCapability.ts @@ -1,4 +1,4 @@ -import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig.ts'; +import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig'; import { useQuery } from '@tanstack/react-query'; export interface AnalyticsCapability { diff --git a/src/features/instance/status/analytics/hooks/useAnalyticsFreshness.test.ts b/src/features/instance/status/analytics/hooks/useAnalyticsFreshness.test.ts index 98ca6719d..6e1a0868b 100644 --- a/src/features/instance/status/analytics/hooks/useAnalyticsFreshness.test.ts +++ b/src/features/instance/status/analytics/hooks/useAnalyticsFreshness.test.ts @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; import { createElement, type ReactNode } from 'react'; import { afterEach, describe, expect, it } from 'vitest'; -import { formatRelativeUpdate, useAnalyticsFreshness } from './useAnalyticsFreshness.ts'; +import { formatRelativeUpdate, useAnalyticsFreshness } from './useAnalyticsFreshness'; /** Panel-shaped key per getAnalytics.ts: [prefix, entityId, metric, start, end, bucket, conditions]. */ const analyticsKey = (entityId: string) => ['get_analytics_raw', entityId, 'cpu-usage', 0, 60_000, null, null]; diff --git a/src/features/instance/status/analytics/hooks/useAnalyticsFreshness.ts b/src/features/instance/status/analytics/hooks/useAnalyticsFreshness.ts index f9b68b6c0..92c34f481 100644 --- a/src/features/instance/status/analytics/hooks/useAnalyticsFreshness.ts +++ b/src/features/instance/status/analytics/hooks/useAnalyticsFreshness.ts @@ -1,5 +1,5 @@ import type { EntityIds } from '@/features/auth/store/authStore'; -import { ANALYTICS_QUERY_KEY_PREFIX } from '@/integrations/api/instance/status/getAnalytics.ts'; +import { ANALYTICS_QUERY_KEY_PREFIX } from '@/integrations/api/instance/status/getAnalytics'; import { useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; diff --git a/src/features/instance/status/analytics/hooks/useAnalyticsRecords.test.ts b/src/features/instance/status/analytics/hooks/useAnalyticsRecords.test.ts index 723112dbc..797bbc111 100644 --- a/src/features/instance/status/analytics/hooks/useAnalyticsRecords.test.ts +++ b/src/features/instance/status/analytics/hooks/useAnalyticsRecords.test.ts @@ -1,10 +1,10 @@ // @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 { renderHook, waitFor } from '@testing-library/react'; import { createElement, type ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { useAnalyticsRecords } from './useAnalyticsRecords.ts'; +import { useAnalyticsRecords } from './useAnalyticsRecords'; interface AxiosLike { post: ReturnType; diff --git a/src/features/instance/status/analytics/hooks/useAnalyticsRecords.ts b/src/features/instance/status/analytics/hooks/useAnalyticsRecords.ts index e407287a7..5f92516e9 100644 --- a/src/features/instance/status/analytics/hooks/useAnalyticsRecords.ts +++ b/src/features/instance/status/analytics/hooks/useAnalyticsRecords.ts @@ -1,11 +1,8 @@ -import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig.ts'; -import { - type AnalyticsCondition, - getRawAnalyticsQueryOptions, -} from '@/integrations/api/instance/status/getAnalytics.ts'; +import type { InstanceClientIdConfig, InstanceTypeConfig } from '@/config/instanceClientConfig'; +import { type AnalyticsCondition, getRawAnalyticsQueryOptions } from '@/integrations/api/instance/status/getAnalytics'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useEffect, useMemo } from 'react'; -import type { AnalyticsDataPoint } from '../types/analytics.ts'; +import type { AnalyticsDataPoint } from '../types/analytics'; export interface UseAnalyticsRecordsArgs { metric: string; diff --git a/src/features/instance/status/analytics/hooks/useNodeFilteredSeries.ts b/src/features/instance/status/analytics/hooks/useNodeFilteredSeries.ts index e1bc84e90..3ea132622 100644 --- a/src/features/instance/status/analytics/hooks/useNodeFilteredSeries.ts +++ b/src/features/instance/status/analytics/hooks/useNodeFilteredSeries.ts @@ -1,8 +1,8 @@ import { useMemo } from 'react'; -import { getNodeColor } from '../lib/nodeColors.ts'; -import { shortenNodeLabel } from '../lib/nodeLabels.ts'; -import type { SeriesData } from '../types/analytics.ts'; -import { useNodeSelection } from './useNodeSelection.ts'; +import { getNodeColor } from '../lib/nodeColors'; +import { shortenNodeLabel } from '../lib/nodeLabels'; +import type { SeriesData } from '../types/analytics'; +import { useNodeSelection } from './useNodeSelection'; /** Shared per-node scaffolding for chip-selector panels (connection, memory, * main-thread, DimensionSelectorRenderer): relabels each per-node series diff --git a/src/features/instance/status/analytics/hooks/useNodeSelection.ts b/src/features/instance/status/analytics/hooks/useNodeSelection.ts index 02e7463eb..6c8a31538 100644 --- a/src/features/instance/status/analytics/hooks/useNodeSelection.ts +++ b/src/features/instance/status/analytics/hooks/useNodeSelection.ts @@ -2,7 +2,7 @@ // wrapper because many chart/renderer call sites destructure // `handleLegendClick`. -import { useSoloToggleSelection } from './useSoloToggleSelection.ts'; +import { useSoloToggleSelection } from './useSoloToggleSelection'; export function useNodeSelection(nodeIds: string[]) { const { isActive, handleClick, activeSet } = useSoloToggleSelection(nodeIds); diff --git a/src/features/instance/status/analytics/lib/specRequiredFields.test.ts b/src/features/instance/status/analytics/lib/specRequiredFields.test.ts index eb7ac8516..afa456989 100644 --- a/src/features/instance/status/analytics/lib/specRequiredFields.test.ts +++ b/src/features/instance/status/analytics/lib/specRequiredFields.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { recomputeErrorRate } from '../pipeline/derived/error-rate.tsx'; -import type { AnalyticsDataPoint, TimeRange } from '../types/analytics.ts'; -import { getSpecRequiredFields } from './specRequiredFields.ts'; +import { recomputeErrorRate } from '../pipeline/derived/error-rate'; +import type { AnalyticsDataPoint, TimeRange } from '../types/analytics'; +import { getSpecRequiredFields } from './specRequiredFields'; describe('getSpecRequiredFields', () => { it('returns [] for an unknown metric', () => { diff --git a/src/features/instance/status/analytics/lib/specRequiredFields.ts b/src/features/instance/status/analytics/lib/specRequiredFields.ts index 38f5ecb96..ed9c0141d 100644 --- a/src/features/instance/status/analytics/lib/specRequiredFields.ts +++ b/src/features/instance/status/analytics/lib/specRequiredFields.ts @@ -1,6 +1,6 @@ -import { derivedRegistry } from '../pipeline/derived/index.ts'; -import { specRegistry } from '../pipeline/index.ts'; -import type { FieldExpr, FieldSpec, MetricSpec, Transform } from '../types/analytics.ts'; +import { derivedRegistry } from '../pipeline/derived/index'; +import { specRegistry } from '../pipeline/index'; +import type { FieldExpr, FieldSpec, MetricSpec, Transform } from '../types/analytics'; /** Per-derived-metric required-field overrides. Derived specs read raw * source-metric columns directly (their `recompute` doesn't go through diff --git a/src/features/instance/status/analytics/lib/tableSize.ts b/src/features/instance/status/analytics/lib/tableSize.ts index f3ac0e63c..376ea1ad1 100644 --- a/src/features/instance/status/analytics/lib/tableSize.ts +++ b/src/features/instance/status/analytics/lib/tableSize.ts @@ -1,4 +1,4 @@ -import type { TableSizeRecord, TimeRange } from '../types/analytics.ts'; +import type { TableSizeRecord, TimeRange } from '../types/analytics'; export type RankBy = 'bytes' | 'percent'; export type EmptyCause = 'upstream-empty' | 'all-other' | null; diff --git a/src/features/instance/status/analytics/pipeline/aggregators.ts b/src/features/instance/status/analytics/pipeline/aggregators.ts index 3f3a413c0..f63352d57 100644 --- a/src/features/instance/status/analytics/pipeline/aggregators.ts +++ b/src/features/instance/status/analytics/pipeline/aggregators.ts @@ -3,7 +3,7 @@ // p50 of [10, 20] = 10 (the lower of two medians). No interpolation. If a // future spec needs interpolated quantiles, add `p50-interp` etc. to the // Aggregator union rather than silently changing this behavior. -import type { Aggregator } from '../types/analytics.ts'; +import type { Aggregator } from '../types/analytics'; export interface AggInput { value: number | null; diff --git a/src/features/instance/status/analytics/pipeline/approxLabel.ts b/src/features/instance/status/analytics/pipeline/approxLabel.ts index 0b7a291a6..ee1079f38 100644 --- a/src/features/instance/status/analytics/pipeline/approxLabel.ts +++ b/src/features/instance/status/analytics/pipeline/approxLabel.ts @@ -1,4 +1,4 @@ -import type { Aggregator } from '../types/analytics.ts'; +import type { Aggregator } from '../types/analytics'; export function isApproxAggregator(op: Aggregator): boolean { return op === 'count-weighted-mean'; diff --git a/src/features/instance/status/analytics/pipeline/bytes-received.tsx b/src/features/instance/status/analytics/pipeline/bytes-received.ts similarity index 84% rename from src/features/instance/status/analytics/pipeline/bytes-received.tsx rename to src/features/instance/status/analytics/pipeline/bytes-received.ts index 27430944d..6ab1e8067 100644 --- a/src/features/instance/status/analytics/pipeline/bytes-received.tsx +++ b/src/features/instance/status/analytics/pipeline/bytes-received.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 bytesReceivedSpec = wrapperMetrics['bytes-received'].spec; export const BytesReceivedRenderer = wrapperMetrics['bytes-received'].Renderer; diff --git a/src/features/instance/status/analytics/pipeline/bytes-sent.tsx b/src/features/instance/status/analytics/pipeline/bytes-sent.ts similarity index 83% rename from src/features/instance/status/analytics/pipeline/bytes-sent.tsx rename to src/features/instance/status/analytics/pipeline/bytes-sent.ts index 4db9712e3..a86930af2 100644 --- a/src/features/instance/status/analytics/pipeline/bytes-sent.tsx +++ b/src/features/instance/status/analytics/pipeline/bytes-sent.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 bytesSentSpec = wrapperMetrics['bytes-sent'].spec; export const BytesSentRenderer = wrapperMetrics['bytes-sent'].Renderer; diff --git a/src/features/instance/status/analytics/pipeline/cache-hit.tsx b/src/features/instance/status/analytics/pipeline/cache-hit.ts similarity index 83% rename from src/features/instance/status/analytics/pipeline/cache-hit.tsx rename to src/features/instance/status/analytics/pipeline/cache-hit.ts index 75ec34c76..5fc78a00c 100644 --- a/src/features/instance/status/analytics/pipeline/cache-hit.tsx +++ b/src/features/instance/status/analytics/pipeline/cache-hit.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 cacheHitSpec = wrapperMetrics['cache-hit'].spec; export const CacheHitRenderer = wrapperMetrics['cache-hit'].Renderer; diff --git a/src/features/instance/status/analytics/pipeline/cache-resolution.tsx b/src/features/instance/status/analytics/pipeline/cache-resolution.ts similarity index 85% rename from src/features/instance/status/analytics/pipeline/cache-resolution.tsx rename to src/features/instance/status/analytics/pipeline/cache-resolution.ts index 7be48b91f..b171372eb 100644 --- a/src/features/instance/status/analytics/pipeline/cache-resolution.tsx +++ b/src/features/instance/status/analytics/pipeline/cache-resolution.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 cacheResolutionSpec = wrapperMetrics['cache-resolution'].spec; export const CacheResolutionRenderer = wrapperMetrics['cache-resolution'].Renderer; diff --git a/src/features/instance/status/analytics/pipeline/confidence.ts b/src/features/instance/status/analytics/pipeline/confidence.ts index 545d57a1d..6fc9cd572 100644 --- a/src/features/instance/status/analytics/pipeline/confidence.ts +++ b/src/features/instance/status/analytics/pipeline/confidence.ts @@ -1,4 +1,4 @@ -import type { MetricSpec } from '../types/analytics.ts'; +import type { MetricSpec } from '../types/analytics'; type Confidence = NonNullable; export type ConfidenceClass = 'ok' | 'grey' | 'suppress'; diff --git a/src/features/instance/status/analytics/pipeline/connection.tsx b/src/features/instance/status/analytics/pipeline/connection.tsx index a12746851..ad84c4bbe 100644 --- a/src/features/instance/status/analytics/pipeline/connection.tsx +++ b/src/features/instance/status/analytics/pipeline/connection.tsx @@ -1,10 +1,10 @@ import { useMemo, useState } from 'react'; -import { NodeLegend } from '../charts/NodeLegend.tsx'; -import { useNodeFilteredSeries } from '../hooks/useNodeFilteredSeries.ts'; -import { DimensionChipRow } from '../primitives/DimensionChipRow.tsx'; -import { LineChart } from '../primitives/LineChart.tsx'; -import type { AnalyticsDataPoint, MetricSpec, SeriesData, Threshold, TimeRange } from '../types/analytics.ts'; -import { runPipeline } from './pipeline.ts'; +import { NodeLegend } from '../charts/NodeLegend'; +import { useNodeFilteredSeries } from '../hooks/useNodeFilteredSeries'; +import { DimensionChipRow } from '../primitives/DimensionChipRow'; +import { LineChart } from '../primitives/LineChart'; +import type { AnalyticsDataPoint, MetricSpec, SeriesData, Threshold, TimeRange } from '../types/analytics'; +import { runPipeline } from './pipeline'; const COMPOSITE_FIELD = 'pathMethod'; const SEPARATOR = ' · '; @@ -49,7 +49,6 @@ interface RendererProps { records: AnalyticsDataPoint[]; timeRange: TimeRange; nodes: string[]; - theme: 'light' | 'dark'; viewMode?: 'per-node' | 'aggregate'; fillParent?: boolean; } @@ -90,7 +89,7 @@ function preprocess(records: AnalyticsDataPoint[]): Preprocessed { } export function ConnectionRenderer( - { records, timeRange, nodes, theme, viewMode = 'per-node', fillParent }: RendererProps, + { records, timeRange, nodes, viewMode = 'per-node', fillParent }: RendererProps, ) { const perNode = viewMode === 'per-node'; const { records: processed, dimParts } = useMemo(() => preprocess(records), [records]); @@ -146,7 +145,6 @@ export function ConnectionRenderer(
= { 'mqtt-traffic-sent': mqttTrafficSentDerived, diff --git a/src/features/instance/status/analytics/pipeline/derived/mqtt-traffic-received.tsx b/src/features/instance/status/analytics/pipeline/derived/mqtt-traffic-received.ts similarity index 65% rename from src/features/instance/status/analytics/pipeline/derived/mqtt-traffic-received.tsx rename to src/features/instance/status/analytics/pipeline/derived/mqtt-traffic-received.ts index 59a342dc3..39e2a25cb 100644 --- a/src/features/instance/status/analytics/pipeline/derived/mqtt-traffic-received.tsx +++ b/src/features/instance/status/analytics/pipeline/derived/mqtt-traffic-received.ts @@ -1,3 +1,3 @@ // 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.tsx'; +export { mqttTrafficReceivedDerived } from './mqtt-traffic'; diff --git a/src/features/instance/status/analytics/pipeline/derived/mqtt-traffic-sent.tsx b/src/features/instance/status/analytics/pipeline/derived/mqtt-traffic-sent.ts similarity index 67% rename from src/features/instance/status/analytics/pipeline/derived/mqtt-traffic-sent.tsx rename to src/features/instance/status/analytics/pipeline/derived/mqtt-traffic-sent.ts index ea2bf67fa..eef63a463 100644 --- a/src/features/instance/status/analytics/pipeline/derived/mqtt-traffic-sent.tsx +++ b/src/features/instance/status/analytics/pipeline/derived/mqtt-traffic-sent.ts @@ -1,3 +1,3 @@ // 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.tsx'; +export { mqttTrafficSentDerived } from './mqtt-traffic'; diff --git a/src/features/instance/status/analytics/pipeline/derived/mqtt-traffic.tsx b/src/features/instance/status/analytics/pipeline/derived/mqtt-traffic.tsx index 5606f5922..491766891 100644 --- a/src/features/instance/status/analytics/pipeline/derived/mqtt-traffic.tsx +++ b/src/features/instance/status/analytics/pipeline/derived/mqtt-traffic.tsx @@ -1,6 +1,6 @@ -import { TrafficByTypeRenderer } from '../../primitives/TrafficByTypeRenderer.tsx'; -import type { DerivedMetricSpec, MetricSpec } from '../../types/analytics.ts'; -import { runPipeline } from '../pipeline.ts'; +import { TrafficByTypeRenderer } from '../../primitives/TrafficByTypeRenderer'; +import type { DerivedMetricSpec, MetricSpec } from '../../types/analytics'; +import { runPipeline } from '../pipeline'; interface MqttTrafficConfig { id: string; diff --git a/src/features/instance/status/analytics/pipeline/derived/request-rate.tsx b/src/features/instance/status/analytics/pipeline/derived/request-rate.tsx index f53e8b682..1ce09674d 100644 --- a/src/features/instance/status/analytics/pipeline/derived/request-rate.tsx +++ b/src/features/instance/status/analytics/pipeline/derived/request-rate.tsx @@ -1,5 +1,5 @@ -import { PerPathRateRenderer } from '../../primitives/PerPathRateRenderer.tsx'; -import type { AnalyticsDataPoint, DerivedMetricSpec, Series, SeriesData, TimeRange } from '../../types/analytics.ts'; +import { PerPathRateRenderer } from '../../primitives/PerPathRateRenderer'; +import type { AnalyticsDataPoint, DerivedMetricSpec, Series, SeriesData, TimeRange } from '../../types/analytics'; /** * Derived: request-rate per (path, time) bucket. Reads raw `count` and `period` @@ -104,12 +104,11 @@ export const requestRateDerived: DerivedMetricSpec = { tab: 'requests', sourceMetric: 'duration', recompute: recomputeRequestRate, - Renderer: ({ records, timeRange, nodes, theme, viewMode }) => ( + Renderer: ({ records, timeRange, nodes, viewMode }) => ( = { 'replication-latency': { spec: replicationLatencySpec, Renderer: ReplicationLatencyRenderer }, 'bytes-sent': wrapperMetrics['bytes-sent'], 'bytes-received': wrapperMetrics['bytes-received'], 'resource-usage': { spec: resourceUsageSpec }, + // 'connections' = active MQTT/WS session counts; distinct from + // 'connection' below (per-path/method connect-success ratios). 'connections': wrapperMetrics['connections'], 'duration': wrapperMetrics['duration'], 'success': wrapperMetrics['success'], @@ -30,6 +32,8 @@ export const specRegistry: Record = { 'db-read': wrapperMetrics['db-read'], 'db-write': wrapperMetrics['db-write'], 'db-message': wrapperMetrics['db-message'], + // snake_case matches Harper's wire metric name (response_200) — the one + // exception to the kebab-case registry keys; do not normalize. 'response_200': wrapperMetrics['response_200'], 'utilization': { spec: utilizationSpec }, 'database-size': wrapperMetrics['database-size'], diff --git a/src/features/instance/status/analytics/pipeline/main-thread-utilization.tsx b/src/features/instance/status/analytics/pipeline/main-thread-utilization.tsx index 2fcb570b9..9a7568acf 100644 --- a/src/features/instance/status/analytics/pipeline/main-thread-utilization.tsx +++ b/src/features/instance/status/analytics/pipeline/main-thread-utilization.tsx @@ -1,10 +1,10 @@ import { useMemo, useState } from 'react'; -import { NodeLegend } from '../charts/NodeLegend.tsx'; -import { useNodeFilteredSeries } from '../hooks/useNodeFilteredSeries.ts'; -import { DimensionChipRow } from '../primitives/DimensionChipRow.tsx'; -import { LineChart } from '../primitives/LineChart.tsx'; -import type { AnalyticsDataPoint, AxisSpec, FieldSpec, MetricSpec, SeriesData, TimeRange } from '../types/analytics.ts'; -import { runPipeline } from './pipeline.ts'; +import { NodeLegend } from '../charts/NodeLegend'; +import { useNodeFilteredSeries } from '../hooks/useNodeFilteredSeries'; +import { DimensionChipRow } from '../primitives/DimensionChipRow'; +import { LineChart } from '../primitives/LineChart'; +import type { AnalyticsDataPoint, AxisSpec, FieldSpec, MetricSpec, SeriesData, TimeRange } from '../types/analytics'; +import { runPipeline } from './pipeline'; // Field-selector pattern (mirrors memory.tsx). Records expose // `active`, `idle`, and `taskQueueLatency`; we surface 4 operator- @@ -82,13 +82,12 @@ interface RendererProps { records: AnalyticsDataPoint[]; timeRange: TimeRange; nodes: string[]; - theme: 'light' | 'dark'; viewMode?: 'per-node' | 'aggregate'; fillParent?: boolean; } export function MainThreadRenderer( - { records, timeRange, nodes, theme, viewMode = 'per-node', fillParent }: RendererProps, + { records, timeRange, nodes, viewMode = 'per-node', fillParent }: RendererProps, ) { const perNode = viewMode === 'per-node'; const [selectedKey, setSelectedKey] = useState(FIELDS[0].key); @@ -120,7 +119,6 @@ export function MainThreadRenderer(
f.label); export function MemoryRenderer( - { records, timeRange, nodes, theme, viewMode = 'per-node', fillParent }: RendererProps, + { records, timeRange, nodes, viewMode = 'per-node', fillParent }: RendererProps, ) { const perNode = viewMode === 'per-node'; const [selectedLabel, setSelectedLabel] = useState(MEMORY_FIELDS[0].label); @@ -75,7 +74,6 @@ export function MemoryRenderer(
('p95'); @@ -337,7 +337,7 @@ export function ReplicationLatencyRenderer(props: SpecRegistryRendererProps): JS if (data.rows.length === 0 || data.cols.length === 0) { return (
- +
No data in window
@@ -364,7 +364,7 @@ export function ReplicationLatencyRenderer(props: SpecRegistryRendererProps): JS 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', } as const; @@ -383,7 +383,7 @@ export function ReplicationLatencyRenderer(props: SpecRegistryRendererProps): JS > Showing as lines
- + { /* 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
{`No source-destination pairs cleared the confidence threshold (${greyBelow}+ samples). All ${omittedPairsCount} ${ pluralize(omittedPairsCount, 'pair', 'pairs') @@ -497,14 +480,7 @@ function RecognitionBanner({ data }: RecognitionBannerProps) { key={`${unparseable}-${missingValue}-${unrecognized.length}`} role="status" aria-atomic="true" - style={{ - 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', - }} + style={warningBannerStyle} > {parts.join(' ')}
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( } diff --git a/src/features/instance/status/analytics/lib/chartExport.ts b/src/features/instance/status/analytics/lib/chartExport.ts index 95d54bf85..fa019179c 100644 --- a/src/features/instance/status/analytics/lib/chartExport.ts +++ b/src/features/instance/status/analytics/lib/chartExport.ts @@ -39,8 +39,9 @@ export async function captureChartAsBlob( return blob; } -export async function downloadChart(chartContainer: HTMLElement, filename: string): Promise { - const blob = await captureChartAsBlob(chartContainer); +/** Trigger a browser download of `blob` as `filename` via a transient anchor. + * Shared by the PNG export and the CSV export so they can't drift. */ +export function triggerBlobDownload(blob: Blob, filename: string): void { const url = URL.createObjectURL(blob); try { const a = document.createElement('a'); @@ -52,6 +53,11 @@ export async function downloadChart(chartContainer: HTMLElement, filename: strin } } +export async function downloadChart(chartContainer: HTMLElement, filename: string): Promise { + const blob = await captureChartAsBlob(chartContainer); + triggerBlobDownload(blob, filename); +} + /** Capture the chart and write it to the clipboard as a PNG ClipboardItem. * Resolves to `true` on success, `false` if either the capture failed or * the browser denied clipboard access (Safari + non-secure contexts are @@ -71,9 +77,17 @@ export async function copyChartToClipboard(chartContainer: HTMLElement): Promise } } +/** Slugify a metric/title for use in a download filename. */ +export function slugifyForFilename(prefix: string): string { + return prefix.replace(/[^a-z0-9-]+/gi, '-').replace(/(^-|-$)/g, '').toLowerCase(); +} + +/** ISO-8601 timestamp with filename-hostile characters (`:` `.`) dashed. */ +export function filenameTimestamp(ms: number): string { + return new Date(ms).toISOString().replace(/[:.]/g, '-'); +} + /** Slugify a metric/title for use as a download filename. */ export function makeExportFilename(prefix: string, range: { startTime: number; endTime: number }): string { - const safe = prefix.replace(/[^a-z0-9-]+/gi, '-').replace(/(^-|-$)/g, '').toLowerCase(); - const stamp = new Date(range.endTime).toISOString().replace(/[:.]/g, '-'); - return `${safe}-${stamp}.png`; + return `${slugifyForFilename(prefix)}-${filenameTimestamp(range.endTime)}.png`; } diff --git a/src/features/instance/status/analytics/lib/csvExport.test.ts b/src/features/instance/status/analytics/lib/csvExport.test.ts new file mode 100644 index 000000000..dcc33501d --- /dev/null +++ b/src/features/instance/status/analytics/lib/csvExport.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest'; +import type { AnalyticsDataPoint, HeatmapData, SeriesData } from '../types/analytics'; +import { computeMetricCsvData, heatmapToCsv, makeCsvFilename, seriesToCsv } from './csvExport'; + +function lines(csv: string): string[] { + // Trailing CRLF terminates the last record — strip it before splitting. + expect(csv.endsWith('\r\n')).toBe(true); + return csv.slice(0, -2).split('\r\n'); +} + +describe('seriesToCsv', () => { + it('emits an ISO-8601 timestamp column plus one column per series, in series order', () => { + const data: SeriesData = { + series: [ + { key: 'a', label: 'alpha', points: [{ x: 0, y: 1 }, { x: 60_000, y: 2 }] }, + { key: 'b', label: 'beta', points: [{ x: 0, y: 10 }, { x: 60_000, y: 20 }] }, + ], + }; + expect(lines(seriesToCsv(data))).toEqual([ + 'timestamp,alpha,beta', + '1970-01-01T00:00:00.000Z,1,10', + '1970-01-01T00:01:00.000Z,2,20', + ]); + }); + + it('unions x values across series and leaves empty cells for gaps and null samples', () => { + const data: SeriesData = { + series: [ + // null y = explicit gap; missing x = no sample at that timestamp. + { key: 'a', label: 'alpha', points: [{ x: 0, y: null }, { x: 120_000, y: 3 }] }, + { key: 'b', label: 'beta', points: [{ x: 60_000, y: 5 }] }, + ], + }; + expect(lines(seriesToCsv(data))).toEqual([ + 'timestamp,alpha,beta', + '1970-01-01T00:00:00.000Z,,', + '1970-01-01T00:01:00.000Z,,5', + '1970-01-01T00:02:00.000Z,3,', + ]); + }); + + it('escapes labels containing commas, quotes, and newlines per RFC 4180', () => { + const data: SeriesData = { + series: [ + { key: 'a', label: 'a,b', points: [{ x: 0, y: 1 }] }, + { key: 'b', label: 'say "hi"', points: [{ x: 0, y: 2 }] }, + { key: 'c', label: 'two\nlines', points: [{ x: 0, y: 3 }] }, + ], + }; + expect(lines(seriesToCsv(data))[0]).toBe('timestamp,"a,b","say ""hi""","two\nlines"'); + }); + + it('neutralizes formula-leading labels so spreadsheets import them as text', () => { + const data: SeriesData = { + series: [ + { key: 'a', label: '=SUM(A1:A9)', points: [{ x: 0, y: 1 }] }, + { key: 'b', label: '+p95', points: [{ x: 0, y: 2 }] }, + { key: 'c', label: '@cmd', points: [{ x: 0, y: 3 }] }, + { key: 'd', label: '-neg', points: [{ x: 0, y: -4 }] }, + ], + }; + const out = lines(seriesToCsv(data)); + expect(out[0]).toBe(`timestamp,'=SUM(A1:A9),'+p95,'@cmd,'-neg`); + // Numeric cells keep their sign — only string cells are neutralized. + expect(out[1]).toBe('1970-01-01T00:00:00.000Z,1,2,3,-4'); + }); + + it('dedupes duplicate labels by appending the series key', () => { + const data: SeriesData = { + series: [ + { key: 'db1|n1', label: 'db1', points: [{ x: 0, y: 1 }] }, + { key: 'db1|n2', label: 'db1', points: [{ x: 0, y: 2 }] }, + ], + }; + expect(lines(seriesToCsv(data))[0]).toBe('timestamp,db1 (db1|n1),db1 (db1|n2)'); + }); + + it('includes the ceiling series as a trailing column when present', () => { + const data: SeriesData = { + series: [{ key: 'heap', label: 'heap', points: [{ x: 0, y: 1 }] }], + ceiling: { key: 'rss', label: 'rss', points: [{ x: 0, y: 8 }] }, + }; + expect(lines(seriesToCsv(data))).toEqual([ + 'timestamp,heap,rss', + '1970-01-01T00:00:00.000Z,1,8', + ]); + }); + + it('returns only the header for empty series data', () => { + expect(lines(seriesToCsv({ series: [] }))).toEqual(['timestamp']); + }); +}); + +describe('heatmapToCsv', () => { + it('emits row,col,value,count with empty cells for absent values/counts', () => { + const data: HeatmapData = { + rows: ['n1', 'n2'], + cols: ['n2'], + cells: [ + { row: 'n1', col: 'n2', value: 12.5, count: 40 }, + { row: 'n2', col: 'n2', value: null }, + ], + skippedRecordsCount: 0, + }; + expect(lines(heatmapToCsv(data))).toEqual([ + 'row,col,value,count', + 'n1,n2,12.5,40', + 'n2,n2,,', + ]); + }); + + it('escapes row/col labels per RFC 4180', () => { + const data: HeatmapData = { + rows: ['a,b'], + cols: ['c"d'], + cells: [{ row: 'a,b', col: 'c"d', value: 1, count: 2 }], + skippedRecordsCount: 0, + }; + expect(lines(heatmapToCsv(data))[1]).toBe('"a,b","c""d",1,2'); + }); +}); + +describe('makeCsvFilename', () => { + it('produces --.csv with a sanitized metric slug', () => { + const name = makeCsvFilename('CPU usage %', { startTime: 0, endTime: 60_000 }); + expect(name).toBe('cpu-usage-1970-01-01T00-00-00-000Z-1970-01-01T00-01-00-000Z.csv'); + }); +}); + +describe('computeMetricCsvData', () => { + const window = { startTime: 0, endTime: 120_000 }; + + it('returns null for empty records and for unknown metrics', () => { + expect(computeMetricCsvData('cpu-usage', [], window, [])).toBeNull(); + const rows: AnalyticsDataPoint[] = [{ time: 0, node: 'n1' }]; + expect(computeMetricCsvData('no-such-metric', rows, window, ['n1'])).toBeNull(); + }); + + it('runs the spec pipeline for a Renderer-backed groupBy metric (connections)', () => { + const rows: AnalyticsDataPoint[] = [ + { time: 60_000, node: 'n1', type: 'mqtt', connections: 5, period: 60_000, count: 1 }, + { time: 60_000, node: 'n1', type: 'ws', connections: 3, period: 60_000, count: 1 }, + ]; + const out = computeMetricCsvData('connections', rows, window, ['n1']); + expect(out?.kind).toBe('series'); + if (out?.kind !== 'series') { return; } + expect(out.data.series.map((s) => s.dim).sort()).toEqual(['mqtt', 'ws']); + }); + + it('preprocesses connection records so the pathMethod groupBy is not empty', () => { + const rows: AnalyticsDataPoint[] = [ + { + time: 60_000, + node: 'n1', + path: 'mqtt', + method: 'connect', + ratio: 0.995, + total: 199, + count: 200, + period: 60_000, + }, + { + time: 60_000, + node: 'n1', + path: 'mqtt', + method: 'disconnect', + ratio: 0.5, + total: 300, + count: 600, + period: 60_000, + }, + ]; + const out = computeMetricCsvData('connection', rows, window, ['n1']); + expect(out?.kind).toBe('series'); + if (out?.kind !== 'series') { return; } + expect(out.data.series.map((s) => s.dim).sort()).toEqual(['mqtt · connect', 'mqtt · disconnect']); + }); + + it('uses the derived recompute for derived metrics (request-rate)', () => { + const rows: AnalyticsDataPoint[] = [ + { time: 60_000, node: 'n1', path: '/dogs', count: 30, period: 60_000 }, + ]; + const out = computeMetricCsvData('request-rate', rows, window, ['n1']); + expect(out?.kind).toBe('series'); + if (out?.kind !== 'series') { return; } + expect(out.data.series).toHaveLength(1); + expect(out.data.series[0].label).toBe('/dogs'); + expect(out.data.series[0].points).toEqual([{ x: 60_000, y: 0.5, count: 30 }]); + }); + + it('returns heatmap data for replication-latency', () => { + const rows: AnalyticsDataPoint[] = [ + { time: 60_000, node: 'n2', path: 'not-a-replication-path', count: 1 }, + ]; + const out = computeMetricCsvData('replication-latency', rows, window, ['n1', 'n2']); + expect(out?.kind).toBe('heatmap'); + }); +}); diff --git a/src/features/instance/status/analytics/lib/csvExport.ts b/src/features/instance/status/analytics/lib/csvExport.ts new file mode 100644 index 000000000..412a18b47 --- /dev/null +++ b/src/features/instance/status/analytics/lib/csvExport.ts @@ -0,0 +1,236 @@ +// CSV export for Status chart data — serializes the pipeline output shapes +// (SeriesData / HeatmapData) that the chart primitives render, so the CSV a +// user downloads carries the same numbers as the chart on screen. No network +// calls: everything recomputes from the records the panel already fetched. + +import { derivedRegistry } from '../pipeline/derived/index'; +import { specRegistry } from '../pipeline/index'; +import { runPipeline } from '../pipeline/pipeline'; +import { aggregateReplicationMatrix } from '../pipeline/replication-latency'; +import type { AnalyticsDataPoint, HeatmapData, MetricSpec, Series, SeriesData, TimeRange } from '../types/analytics'; +import { filenameTimestamp, slugifyForFilename, triggerBlobDownload } from './chartExport'; + +/** Discriminated union the CSV button consumes — a panel supplies whichever + * shape its primitive renders. */ +export type ChartCsvData = + | { kind: 'series'; data: SeriesData } + | { kind: 'heatmap'; data: HeatmapData }; + +// ───────────────────────────────────────────────────────────────────────────── +// RFC-4180 serialization +// ───────────────────────────────────────────────────────────────────────────── + +/** Quote a field per RFC 4180: wrap in double quotes when it contains a + * comma, quote, or line break; double any embedded quotes. Cells starting + * with `=`, `+`, `-`, `@`, tab, or CR are prefixed with an apostrophe so + * spreadsheets import them as text instead of evaluating them as formulas + * (labels come from telemetry paths/identifiers — CSV-injection guard). + * Numeric cells go through csvNumber and keep their sign. */ +function csvField(value: string): string { + const neutralized = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value; + if (/[",\r\n]/.test(neutralized)) { + return `"${neutralized.replace(/"/g, '""')}"`; + } + return neutralized; +} + +function csvNumber(value: number | null | undefined): string { + return value === null || value === undefined ? '' : String(value); +} + +const CRLF = '\r\n'; + +/** Column headers for a series list — series labels, deduped by appending + * the (unique) series key when two series share a label (e.g. the same + * dimension value on two nodes). */ +function seriesHeaders(series: Series[]): string[] { + const labelCounts = new Map(); + for (const s of series) { + labelCounts.set(s.label, (labelCounts.get(s.label) ?? 0) + 1); + } + return series.map(( + s, + ) => ((labelCounts.get(s.label) ?? 0) > 1 && s.key !== s.label ? `${s.label} (${s.key})` : s.label)); +} + +/** Serialize SeriesData to CSV: an ISO-8601 `timestamp` column plus one + * column per series (ceiling included when present). Rows are the sorted + * union of every series' x values; a series without a sample (or with a + * null gap) at a given timestamp yields an empty cell. */ +export function seriesToCsv(data: SeriesData): string { + const allSeries = data.ceiling ? [...data.series, data.ceiling] : [...data.series]; + const header = ['timestamp', ...seriesHeaders(allSeries)].map(csvField).join(','); + + const xs = new Set(); + const bySeries: Map[] = allSeries.map((s) => { + const byX = new Map(); + for (const p of s.points) { + xs.add(p.x); + byX.set(p.x, p.y); + } + return byX; + }); + + const rows = [...xs].sort((a, b) => a - b).map((x) => { + const cells = [csvField(new Date(x).toISOString())]; + for (const byX of bySeries) { + cells.push(csvNumber(byX.get(x))); + } + return cells.join(','); + }); + + return [header, ...rows].join(CRLF) + CRLF; +} + +/** Serialize HeatmapData to CSV as `row,col,value,count` — one line per + * cell, in the primitive's row-major cell order. Absent values/counts are + * empty cells. */ +export function heatmapToCsv(data: HeatmapData): string { + const header = ['row', 'col', 'value', 'count'].join(','); + const rows = data.cells.map((c) => + [csvField(c.row), csvField(c.col), csvNumber(c.value), csvNumber(c.count)].join(',') + ); + return [header, ...rows].join(CRLF) + CRLF; +} + +export function chartCsv(data: ChartCsvData): string { + return data.kind === 'heatmap' ? heatmapToCsv(data.data) : seriesToCsv(data.data); +} + +/** `--.csv`, metric slugified and timestamps ISO-8601 + * (with `:`/`.` dashed for filename safety). */ +export function makeCsvFilename(metric: string, range: TimeRange): string { + return `${slugifyForFilename(metric)}-${filenameTimestamp(range.startTime)}-${filenameTimestamp(range.endTime)}.csv`; +} + +export function downloadCsv(csv: string, filename: string): void { + triggerBlobDownload(new Blob([csv], { type: 'text/csv;charset=utf-8' }), filename); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Metric → chart-data recompute for the CSV button. Mirrors MetricRenderer's +// dispatch (primitives/MetricRenderer.tsx — keep the two in sync) minus the +// renderer-internal UI state: metrics whose custom renderer offers chip +// filters / quantile selectors export their spec's default, unfiltered view. +// ───────────────────────────────────────────────────────────────────────────── + +/** The `connection` spec groups on a synthetic `pathMethod` field that only + * exists after ConnectionRenderer's preprocessing (pipeline/connection.tsx + * — keep in sync): without it every record misses the groupBy dimension and + * the CSV would be empty. Mirrors the renderer's compositing and its + * total===0/count>0 → null ratio gap. */ +function preprocessConnectionRecords(records: AnalyticsDataPoint[]): AnalyticsDataPoint[] { + const out: AnalyticsDataPoint[] = []; + for (const r of records) { + const { path, method, total, count } = r; + if (typeof path !== 'string' && typeof path !== 'number') { continue; } + if (typeof method !== 'string' && typeof method !== 'number') { continue; } + const nullGap = total === 0 && typeof count === 'number' && count > 0; + out.push({ ...r, pathMethod: `${path} · ${method}`, ratio: nullGap ? null : r.ratio }); + } + return out; +} + +/** See MetricRenderer.wantsStackedAreaNodeRemap. */ +function stackedAreaNodeRemap(spec: MetricSpec, isPerNodeMode: boolean): boolean { + return spec.primitive === 'stacked-area' + && isPerNodeMode + && spec.series.kind === 'groupBy' + && spec.series.dimension !== 'node'; +} + +/** See MetricRenderer.wantsClusterLineFold. */ +function clusterLineFold(spec: MetricSpec, isPerNodeMode: boolean): boolean { + return spec.primitive === 'line' + && !isPerNodeMode + && spec.series.kind === 'groupBy' + && spec.series.dimension === 'node'; +} + +/** See MetricRenderer.wantsDimensionLineSplit. */ +function dimensionLineSplit(spec: MetricSpec): boolean { + return spec.primitive === 'line' + && spec.series.kind === 'groupBy' + && spec.series.dimension !== 'node'; +} + +/** Recompute the chart data a panel renders, for CSV serialization. + * Returns null when the metric is unknown or there is nothing to export. */ +export function computeMetricCsvData( + metric: string, + records: AnalyticsDataPoint[], + timeRange: TimeRange, + nodes: string[], + viewMode?: 'per-node' | 'aggregate', +): ChartCsvData | null { + if (records.length === 0) { return null; } + + const derived = derivedRegistry[metric]; + if (derived) { + return { kind: 'series', data: derived.recompute(records, timeRange, nodes, viewMode) }; + } + + const entry = specRegistry[metric]; + const spec = entry?.spec; + if (!spec) { return null; } + + if (spec.primitive === 'heatmap') { + // The only heatmap metric is replication-latency; export its default + // (p95) matrix — the renderer's quantile selector state is internal. + return { kind: 'heatmap', data: aggregateReplicationMatrix(records, nodes) }; + } + + if (entry.Renderer) { + // Custom renderers (DimensionSelectorRenderer, TrafficByTypeRenderer, + // memory, connection, …) layer chip/quantile UI over the spec's own + // pipeline. Export the spec's natural grouping — every dimension value, + // cluster-aggregated — which is the full dataset behind those views. + const rows = metric === 'connection' ? preprocessConnectionRecords(records) : records; + return { kind: 'series', data: runPipeline(spec, rows, timeRange, nodes, { snapToPeriod: true }) }; + } + + const isPerNodeMode = (viewMode ?? 'per-node') === 'per-node'; + + if (spec.primitive === 'small-multiples') { + if (spec.series.kind !== 'field') { return null; } + const series: Series[] = []; + for (const field of spec.series.fields) { + const innerSpec: MetricSpec = { + ...spec, + series: { kind: 'field', fields: [field] }, + aggregator: { + temporal: field.aggregator?.temporal ?? spec.aggregator.temporal, + crossNode: field.aggregator?.crossNode ?? spec.aggregator.crossNode, + }, + }; + const out = runPipeline(innerSpec, records, timeRange, nodes, { perNode: isPerNodeMode, snapToPeriod: true }); + series.push(...out.series); + // Each panel's ceiling (if any) becomes a regular column — SeriesData + // has a single ceiling slot, which can't carry one per panel. + if (out.ceiling) { series.push(out.ceiling); } + } + return { kind: 'series', data: { series } }; + } + + if (stackedAreaNodeRemap(spec, isPerNodeMode) && spec.series.kind === 'groupBy') { + const remapped: MetricSpec = { ...spec, series: { ...spec.series, dimension: 'node' } }; + return { kind: 'series', data: runPipeline(remapped, records, timeRange, nodes, { snapToPeriod: true }) }; + } + if (clusterLineFold(spec, isPerNodeMode) && spec.series.kind === 'groupBy') { + const inner: MetricSpec = { + ...spec, + series: { kind: 'field', fields: [{ ...spec.series.field, label: 'cluster' }] }, + }; + return { kind: 'series', data: runPipeline(inner, records, timeRange, nodes, { snapToPeriod: true }) }; + } + if (dimensionLineSplit(spec)) { + return { + kind: 'series', + data: runPipeline(spec, records, timeRange, nodes, { perNode: false, snapToPeriod: true }), + }; + } + return { + kind: 'series', + data: runPipeline(spec, records, timeRange, nodes, { perNode: isPerNodeMode, snapToPeriod: true }), + }; +} diff --git a/src/features/instance/status/analytics/tabs/ConnectionsPanel.tsx b/src/features/instance/status/analytics/tabs/ConnectionsPanel.tsx index 7b9110c6d..d05e5466f 100644 --- a/src/features/instance/status/analytics/tabs/ConnectionsPanel.tsx +++ b/src/features/instance/status/analytics/tabs/ConnectionsPanel.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useAnalyticsContext } from '../context/AnalyticsContext'; import { useAnalyticsRecords } from '../hooks/useAnalyticsRecords'; +import { computeMetricCsvData } from '../lib/csvExport'; import { wrapperMetrics } from '../pipeline/wrapperMetrics'; import type { AnalyticsDataPoint } from '../types/analytics'; import { PanelErrorBoundary } from './PanelErrorBoundary'; @@ -61,6 +62,7 @@ function ConnectionsPanelInner() { const nodes = useMemo(() => collectNodes(merged), [merged]); const canExport = !isLoading && !isError && !isEmpty; + const getCsvData = () => computeMetricCsvData('connections', merged, timeRange, nodes); const renderChart = (opts: { fillParent: boolean } = { fillParent: false }) => ( collectNodes(data), [data]); const canExport = !isLoading && !isError && !isEmpty; + const getCsvData = () => computeMetricCsvData(metric, data, timeRange, nodes); + const renderChart = (opts: { fillParent: boolean } = { fillParent: false }) => ( ReactNode; + /** Lazily produces the rendered data for the CSV download button. When + * omitted the CSV button is not rendered (panel has no tabular data). */ + getCsvData?: () => ChartCsvData | null; children: ReactNode; } @@ -35,6 +40,7 @@ export function PanelCard({ exportSlug, canExport, renderChart, + getCsvData, children, }: PanelCardProps) { // Capture only the chart body, not the whole Card. Otherwise the exported @@ -54,9 +60,11 @@ export function PanelCard({ title={title} description={expandDescription} renderChart={renderChart} + getCsvData={getCsvData} /> + {getCsvData && }
)} diff --git a/src/features/instance/status/analytics/tabs/__tests__/PanelCard.csv.test.tsx b/src/features/instance/status/analytics/tabs/__tests__/PanelCard.csv.test.tsx new file mode 100644 index 000000000..5b4e0871a --- /dev/null +++ b/src/features/instance/status/analytics/tabs/__tests__/PanelCard.csv.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { ChartCsvData } from '../../lib/csvExport'; +import { PanelCard } from '../PanelShell'; +import { AnalyticsTestWrapper } from './testHelpers'; + +afterEach(cleanup); + +const csvData: ChartCsvData = { + kind: 'series', + data: { series: [{ key: 'a', label: 'alpha', points: [{ x: 0, y: 1 }] }] }, +}; + +function renderCard({ canExport, getCsvData }: { canExport: boolean; getCsvData?: () => ChartCsvData | null }) { + return render( + +
chart
} + getCsvData={getCsvData} + > +
chart
+
+
, + ); +} + +describe('PanelCard CSV button', () => { + it('renders the CSV button in the same action row as the PNG copy/export buttons', () => { + renderCard({ canExport: true, getCsvData: () => csvData }); + const csv = screen.getByLabelText(/Download cpu-usage as CSV/i); + const png = screen.getByLabelText(/Download cpu-usage as PNG/i); + const copy = screen.getByLabelText(/Copy cpu-usage chart to clipboard/i); + expect(csv.parentElement).toBe(png.parentElement); + expect(csv.parentElement).toBe(copy.parentElement); + }); + + it('hides the CSV button alongside the other export actions when there is nothing to export (loading/error/empty)', () => { + renderCard({ canExport: false, getCsvData: () => csvData }); + expect(screen.queryByLabelText(/Download cpu-usage as CSV/i)).toBeNull(); + expect(screen.queryByLabelText(/Download cpu-usage as PNG/i)).toBeNull(); + }); + + it('omits the CSV button when the panel supplies no getCsvData', () => { + renderCard({ canExport: true }); + expect(screen.queryByLabelText(/Download cpu-usage as CSV/i)).toBeNull(); + // The PNG buttons still render — CSV gating is additive. + expect(screen.getByLabelText(/Download cpu-usage as PNG/i)).toBeTruthy(); + }); + + it('offers the CSV download inside the expand dialog too', () => { + renderCard({ canExport: true, getCsvData: () => csvData }); + fireEvent.click(screen.getByLabelText(/Expand cpu-usage/i)); + // One in the (hidden-behind-dialog) header, one in the dialog. + expect(screen.getAllByLabelText(/Download cpu-usage as CSV/i)).toHaveLength(2); + }); +}); From a9cf34af2d8f137e68070fdb8f233ff2218079d7 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Tue, 14 Jul 2026 22:12:54 -0600 Subject: [PATCH 21/92] fix(status): gate CSV export on settled data; self-describing heatmap CSV; share renderer dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on the CSV-export feature: 1. Stale-window export: useAnalyticsRecords now exposes React Query's isPlaceholderData, and MetricPanel/ConnectionsPanel fold it into canExport — during an in-flight window change keepPreviousData serves the PREVIOUS window's rows with isLoading=false, so a click exported old rows under a filename (and PNG title) claiming the new window. The whole export action row now hides until the requested window's rows settle. 2. Heatmap CSV vs chart drift: the exporter always exports the spec default quantile while the renderer has a user-selected one, and it emitted confidence-suppressed cells as bare values the chart hides. The CSV is now self-describing: the value column is named for the quantile actually exported (p95) and a trailing confidence column mirrors the chart's classifyCell tiers (ok/grey/suppress/absent), keeping the numeric value so consumers can filter. 3. Duplicated dispatch logic: csvExport re-implemented MetricRenderer's wantsStackedAreaNodeRemap / wantsClusterLineFold / wantsDimensionLineSplit predicates and ConnectionRenderer's record preprocessing token-for-token. Those are now exported from their owning modules and imported, deleting the copies and the keep-in-sync comments. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV --- .../status/analytics/lib/csvExport.test.ts | 53 ++++++- .../status/analytics/lib/csvExport.ts | 145 ++++++++++-------- .../status/analytics/pipeline/connection.tsx | 9 +- .../analytics/primitives/MetricRenderer.tsx | 9 +- .../analytics/tabs/ConnectionsPanel.tsx | 6 +- .../status/analytics/tabs/MetricPanel.tsx | 8 +- .../tabs/__tests__/ConnectionsPanel.test.tsx | 17 ++ .../tabs/__tests__/MetricPanel.test.tsx | 29 ++++ 8 files changed, 202 insertions(+), 74 deletions(-) diff --git a/src/features/instance/status/analytics/lib/csvExport.test.ts b/src/features/instance/status/analytics/lib/csvExport.test.ts index dcc33501d..25ca0adcb 100644 --- a/src/features/instance/status/analytics/lib/csvExport.test.ts +++ b/src/features/instance/status/analytics/lib/csvExport.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { AnalyticsDataPoint, HeatmapData, SeriesData } from '../types/analytics'; -import { computeMetricCsvData, heatmapToCsv, makeCsvFilename, seriesToCsv } from './csvExport'; +import { chartCsv, computeMetricCsvData, heatmapToCsv, makeCsvFilename, seriesToCsv } from './csvExport'; function lines(csv: string): string[] { // Trailing CRLF terminates the last record — strip it before splitting. @@ -118,6 +118,41 @@ describe('heatmapToCsv', () => { }; expect(lines(heatmapToCsv(data))[1]).toBe('"a,b","c""d",1,2'); }); + + it('names the value column after the exported field when one is supplied', () => { + const data: HeatmapData = { + rows: ['n1'], + cols: ['n2'], + cells: [{ row: 'n1', col: 'n2', value: 3, count: 4 }], + skippedRecordsCount: 0, + }; + expect(lines(heatmapToCsv(data, 'p95'))[0]).toBe('row,col,p95,count'); + }); + + it('adds a confidence column mirroring the chart tiers, keeping suppressed values', () => { + // Tier boundaries mirror classifyCell in primitives/HeatmapMatrix.tsx: + // suppress when count < greyBelow, grey when greyBelow ≤ count < + // suppressBelow, absent when the cell has no value. + const data: HeatmapData = { + rows: ['n1', 'n2'], + cols: ['n3'], + cells: [ + { row: 'n1', col: 'n3', value: 12.5, count: 39 }, // below greyBelow + { row: 'n2', col: 'n3', value: 8, count: 40 }, // grey band + { row: 'n3', col: 'n3', value: 5, count: 100 }, // settled + { row: 'n4', col: 'n3', value: null, count: 0 }, // no data + ], + confidence: { greyBelow: 40, suppressBelow: 100 }, + skippedRecordsCount: 0, + }; + expect(lines(heatmapToCsv(data))).toEqual([ + 'row,col,value,count,confidence', + 'n1,n3,12.5,39,suppress', + 'n2,n3,8,40,grey', + 'n3,n3,5,100,ok', + 'n4,n3,,0,absent', + ]); + }); }); describe('makeCsvFilename', () => { @@ -195,4 +230,20 @@ describe('computeMetricCsvData', () => { const out = computeMetricCsvData('replication-latency', rows, window, ['n1', 'n2']); expect(out?.kind).toBe('heatmap'); }); + + it('serializes replication-latency with a p95 value column and confidence tiers', () => { + const rows: AnalyticsDataPoint[] = [ + // count 20 < greyBelow (40) → the chart suppresses this cell; the + // CSV keeps the number and flags the row instead. + { time: 60_000, node: 'n2', path: 'n1.db.dogs', p95: 12, count: 20 }, + ]; + const out = computeMetricCsvData('replication-latency', rows, window, ['n1', 'n2']); + expect(out?.kind).toBe('heatmap'); + if (out?.kind !== 'heatmap') { return; } + expect(out.valueColumn).toBe('p95'); + expect(lines(chartCsv(out))).toEqual([ + 'row,col,p95,count,confidence', + 'n1,n2,12,20,suppress', + ]); + }); }); diff --git a/src/features/instance/status/analytics/lib/csvExport.ts b/src/features/instance/status/analytics/lib/csvExport.ts index 412a18b47..c0c9f107f 100644 --- a/src/features/instance/status/analytics/lib/csvExport.ts +++ b/src/features/instance/status/analytics/lib/csvExport.ts @@ -1,12 +1,20 @@ // CSV export for Status chart data — serializes the pipeline output shapes -// (SeriesData / HeatmapData) that the chart primitives render, so the CSV a -// user downloads carries the same numbers as the chart on screen. No network -// calls: everything recomputes from the records the panel already fetched. - +// (SeriesData / HeatmapData) that the chart primitives render. Series CSVs +// carry the same numbers as the chart on screen. The heatmap CSV is +// self-describing where the renderer has UI-local state the exporter can't +// see: the value column is named for the quantile actually exported (the +// spec default, regardless of the on-screen quantile selector), and a +// `confidence` column mirrors the chart's suppress/grey tiers — cells the +// chart hides or dims keep their numeric value in the CSV, flagged so the +// consumer can filter. No network calls: everything recomputes from the +// records the panel already fetched. + +import { preprocessConnectionRecords } from '../pipeline/connection'; import { derivedRegistry } from '../pipeline/derived/index'; import { specRegistry } from '../pipeline/index'; import { runPipeline } from '../pipeline/pipeline'; -import { aggregateReplicationMatrix } from '../pipeline/replication-latency'; +import { aggregateReplicationMatrix, type ReplicationQuantileField } from '../pipeline/replication-latency'; +import { wantsClusterLineFold, wantsDimensionLineSplit, wantsStackedAreaNodeRemap } from '../primitives/MetricRenderer'; import type { AnalyticsDataPoint, HeatmapData, MetricSpec, Series, SeriesData, TimeRange } from '../types/analytics'; import { filenameTimestamp, slugifyForFilename, triggerBlobDownload } from './chartExport'; @@ -14,7 +22,13 @@ import { filenameTimestamp, slugifyForFilename, triggerBlobDownload } from './ch * shape its primitive renders. */ export type ChartCsvData = | { kind: 'series'; data: SeriesData } - | { kind: 'heatmap'; data: HeatmapData }; + | { + kind: 'heatmap'; + data: HeatmapData; + /** Header for the value column (e.g. the quantile the exporter chose, + * 'p95'). Defaults to 'value'. */ + valueColumn?: string; + }; // ───────────────────────────────────────────────────────────────────────────── // RFC-4180 serialization @@ -82,19 +96,49 @@ export function seriesToCsv(data: SeriesData): string { return [header, ...rows].join(CRLF) + CRLF; } -/** Serialize HeatmapData to CSV as `row,col,value,count` — one line per - * cell, in the primitive's row-major cell order. Absent values/counts are - * empty cells. */ -export function heatmapToCsv(data: HeatmapData): string { - const header = ['row', 'col', 'value', 'count'].join(','); - const rows = data.cells.map((c) => - [csvField(c.row), csvField(c.col), csvNumber(c.value), csvNumber(c.count)].join(',') - ); +/** Confidence tier for one heatmap cell. Mirrors classifyCell in + * primitives/HeatmapMatrix.tsx exactly — including the inverted-sounding + * boundaries: 'suppress' when count < greyBelow, 'grey' when + * greyBelow ≤ count < suppressBelow, 'absent' when the cell has no value. + * (Duplicated rather than imported: HeatmapMatrix.tsx is owned by an + * in-flight PR — fold this into an import once that lands.) */ +function heatmapConfidenceTier( + value: number | null | undefined, + count: number | undefined, + greyBelow: number, + suppressBelow: number, +): 'ok' | 'grey' | 'suppress' | 'absent' { + if (value === null || value === undefined) { return 'absent'; } + const n = count ?? 0; + if (n < greyBelow) { return 'suppress'; } + if (n < suppressBelow) { return 'grey'; } + return 'ok'; +} + +/** Serialize HeatmapData to CSV as `row,col,,count` — one line + * per cell, in the primitive's row-major cell order. Absent values/counts + * are empty cells. When the data carries confidence thresholds, a trailing + * `confidence` column reports the chart's tier for each cell ('ok' / 'grey' + * / 'suppress' / 'absent'); suppressed and grey cells keep their numeric + * value — the chart hides/dims them, the CSV flags them instead. */ +export function heatmapToCsv(data: HeatmapData, valueColumn = 'value'): string { + const confidence = data.confidence; + const headerCells = ['row', 'col', csvField(valueColumn), 'count']; + if (confidence) { headerCells.push('confidence'); } + const header = headerCells.join(','); + const rows = data.cells.map((c) => { + const cells = [csvField(c.row), csvField(c.col), csvNumber(c.value), csvNumber(c.count)]; + if (confidence) { + // Missing thresholds default to 0, matching HeatmapMatrix. + cells.push(heatmapConfidenceTier(c.value, c.count, confidence.greyBelow ?? 0, confidence.suppressBelow ?? 0)); + } + return cells.join(','); + }); return [header, ...rows].join(CRLF) + CRLF; } export function chartCsv(data: ChartCsvData): string { - return data.kind === 'heatmap' ? heatmapToCsv(data.data) : seriesToCsv(data.data); + return data.kind === 'heatmap' ? heatmapToCsv(data.data, data.valueColumn) : seriesToCsv(data.data); } /** `--.csv`, metric slugified and timestamps ISO-8601 @@ -108,51 +152,17 @@ export function downloadCsv(csv: string, filename: string): void { } // ───────────────────────────────────────────────────────────────────────────── -// Metric → chart-data recompute for the CSV button. Mirrors MetricRenderer's -// dispatch (primitives/MetricRenderer.tsx — keep the two in sync) minus the -// renderer-internal UI state: metrics whose custom renderer offers chip -// filters / quantile selectors export their spec's default, unfiltered view. +// Metric → chart-data recompute for the CSV button. Dispatches through the +// same predicates MetricRenderer does (imported from +// primitives/MetricRenderer.tsx) minus the renderer-internal UI state: +// metrics whose custom renderer offers chip filters / quantile selectors +// export their spec's default, unfiltered view. // ───────────────────────────────────────────────────────────────────────────── -/** The `connection` spec groups on a synthetic `pathMethod` field that only - * exists after ConnectionRenderer's preprocessing (pipeline/connection.tsx - * — keep in sync): without it every record misses the groupBy dimension and - * the CSV would be empty. Mirrors the renderer's compositing and its - * total===0/count>0 → null ratio gap. */ -function preprocessConnectionRecords(records: AnalyticsDataPoint[]): AnalyticsDataPoint[] { - const out: AnalyticsDataPoint[] = []; - for (const r of records) { - const { path, method, total, count } = r; - if (typeof path !== 'string' && typeof path !== 'number') { continue; } - if (typeof method !== 'string' && typeof method !== 'number') { continue; } - const nullGap = total === 0 && typeof count === 'number' && count > 0; - out.push({ ...r, pathMethod: `${path} · ${method}`, ratio: nullGap ? null : r.ratio }); - } - return out; -} - -/** See MetricRenderer.wantsStackedAreaNodeRemap. */ -function stackedAreaNodeRemap(spec: MetricSpec, isPerNodeMode: boolean): boolean { - return spec.primitive === 'stacked-area' - && isPerNodeMode - && spec.series.kind === 'groupBy' - && spec.series.dimension !== 'node'; -} - -/** See MetricRenderer.wantsClusterLineFold. */ -function clusterLineFold(spec: MetricSpec, isPerNodeMode: boolean): boolean { - return spec.primitive === 'line' - && !isPerNodeMode - && spec.series.kind === 'groupBy' - && spec.series.dimension === 'node'; -} - -/** See MetricRenderer.wantsDimensionLineSplit. */ -function dimensionLineSplit(spec: MetricSpec): boolean { - return spec.primitive === 'line' - && spec.series.kind === 'groupBy' - && spec.series.dimension !== 'node'; -} +/** The quantile the heatmap CSV exports — the spec default. The renderer's + * quantile selector is component-local state the exporter can't observe, so + * the CSV always exports this field and names its value column after it. */ +const HEATMAP_CSV_QUANTILE: ReplicationQuantileField = 'p95'; /** Recompute the chart data a panel renders, for CSV serialization. * Returns null when the metric is unknown or there is nothing to export. */ @@ -176,8 +186,13 @@ export function computeMetricCsvData( if (spec.primitive === 'heatmap') { // The only heatmap metric is replication-latency; export its default - // (p95) matrix — the renderer's quantile selector state is internal. - return { kind: 'heatmap', data: aggregateReplicationMatrix(records, nodes) }; + // quantile's matrix and name the value column after it so the CSV + // states what it contains even when the on-screen selector differs. + return { + kind: 'heatmap', + data: aggregateReplicationMatrix(records, nodes, HEATMAP_CSV_QUANTILE), + valueColumn: HEATMAP_CSV_QUANTILE, + }; } if (entry.Renderer) { @@ -185,7 +200,9 @@ export function computeMetricCsvData( // memory, connection, …) layer chip/quantile UI over the spec's own // pipeline. Export the spec's natural grouping — every dimension value, // cluster-aggregated — which is the full dataset behind those views. - const rows = metric === 'connection' ? preprocessConnectionRecords(records) : records; + // `connection` groups on the synthetic pathMethod field, so it needs + // the renderer's own preprocessing first. + const rows = metric === 'connection' ? preprocessConnectionRecords(records).records : records; return { kind: 'series', data: runPipeline(spec, rows, timeRange, nodes, { snapToPeriod: true }) }; } @@ -212,18 +229,18 @@ export function computeMetricCsvData( return { kind: 'series', data: { series } }; } - if (stackedAreaNodeRemap(spec, isPerNodeMode) && spec.series.kind === 'groupBy') { + if (wantsStackedAreaNodeRemap(spec, isPerNodeMode) && spec.series.kind === 'groupBy') { const remapped: MetricSpec = { ...spec, series: { ...spec.series, dimension: 'node' } }; return { kind: 'series', data: runPipeline(remapped, records, timeRange, nodes, { snapToPeriod: true }) }; } - if (clusterLineFold(spec, isPerNodeMode) && spec.series.kind === 'groupBy') { + if (wantsClusterLineFold(spec, isPerNodeMode) && spec.series.kind === 'groupBy') { const inner: MetricSpec = { ...spec, series: { kind: 'field', fields: [{ ...spec.series.field, label: 'cluster' }] }, }; return { kind: 'series', data: runPipeline(inner, records, timeRange, nodes, { snapToPeriod: true }) }; } - if (dimensionLineSplit(spec)) { + if (wantsDimensionLineSplit(spec)) { return { kind: 'series', data: runPipeline(spec, records, timeRange, nodes, { perNode: false, snapToPeriod: true }), diff --git a/src/features/instance/status/analytics/pipeline/connection.tsx b/src/features/instance/status/analytics/pipeline/connection.tsx index ad84c4bbe..1dcc17536 100644 --- a/src/features/instance/status/analytics/pipeline/connection.tsx +++ b/src/features/instance/status/analytics/pipeline/connection.tsx @@ -67,7 +67,12 @@ interface Preprocessed { dimParts: Map>; } -function preprocess(records: AnalyticsDataPoint[]): Preprocessed { +/** Composite the synthetic `pathMethod` groupBy field onto each record (and + * apply the total===0/count>0 → null ratio gap). Exported so the CSV + * exporter (lib/csvExport.ts) feeds the pipeline the exact records the + * renderer does; CSV callers only need `.records` — `dimParts` is + * chip-selector plumbing. */ +export function preprocessConnectionRecords(records: AnalyticsDataPoint[]): Preprocessed { const out: AnalyticsDataPoint[] = []; const dimParts = new Map>(); for (const r of records) { @@ -92,7 +97,7 @@ export function ConnectionRenderer( { records, timeRange, nodes, viewMode = 'per-node', fillParent }: RendererProps, ) { const perNode = viewMode === 'per-node'; - const { records: processed, dimParts } = useMemo(() => preprocess(records), [records]); + const { records: processed, dimParts } = useMemo(() => preprocessConnectionRecords(records), [records]); const fullData = useMemo( () => runPipeline(connectionSpec, processed, timeRange, nodes, { perNode, snapToPeriod: true }), diff --git a/src/features/instance/status/analytics/primitives/MetricRenderer.tsx b/src/features/instance/status/analytics/primitives/MetricRenderer.tsx index 390b807f7..4ae0604be 100644 --- a/src/features/instance/status/analytics/primitives/MetricRenderer.tsx +++ b/src/features/instance/status/analytics/primitives/MetricRenderer.tsx @@ -38,11 +38,12 @@ function renderPrimitive( // ─── Generic-dispatch predicates ───────────────────────────────────────────── // Named guards for the spec-dispatch branches below, so each condition reads -// as intent instead of a wall of `&&`s. +// as intent instead of a wall of `&&`s. Exported so the CSV exporter +// (lib/csvExport.ts) dispatches through the same predicates. /** Stacked-area spec grouped on a non-node dimension while the user asked for * per-node view — restack the same field by node instead. */ -function wantsStackedAreaNodeRemap(spec: MetricSpec, isPerNodeMode: boolean): boolean { +export function wantsStackedAreaNodeRemap(spec: MetricSpec, isPerNodeMode: boolean): boolean { return spec.primitive === 'stacked-area' && isPerNodeMode && spec.series.kind === 'groupBy' @@ -51,7 +52,7 @@ function wantsStackedAreaNodeRemap(spec: MetricSpec, isPerNodeMode: boolean): bo /** Line spec grouped BY node while the user asked for the aggregate view — * fold the nodes into a single cluster-wide line. */ -function wantsClusterLineFold(spec: MetricSpec, isPerNodeMode: boolean): boolean { +export function wantsClusterLineFold(spec: MetricSpec, isPerNodeMode: boolean): boolean { return spec.primitive === 'line' && !isPerNodeMode && spec.series.kind === 'groupBy' @@ -67,7 +68,7 @@ function wantsClusterLineFold(spec: MetricSpec, isPerNodeMode: boolean): boolean * then renders one legend chip per dimension value (colored via * getNodeColor's deterministic hash). The crossNode aggregator on the spec * folds nodes into the cluster-wide line per dimension. */ -function wantsDimensionLineSplit(spec: MetricSpec): boolean { +export function wantsDimensionLineSplit(spec: MetricSpec): boolean { return spec.primitive === 'line' && spec.series.kind === 'groupBy' && spec.series.dimension !== 'node'; diff --git a/src/features/instance/status/analytics/tabs/ConnectionsPanel.tsx b/src/features/instance/status/analytics/tabs/ConnectionsPanel.tsx index d05e5466f..e29785939 100644 --- a/src/features/instance/status/analytics/tabs/ConnectionsPanel.tsx +++ b/src/features/instance/status/analytics/tabs/ConnectionsPanel.tsx @@ -61,7 +61,11 @@ function ConnectionsPanelInner() { const isEmpty = merged.length === 0; const nodes = useMemo(() => collectNodes(merged), [merged]); - const canExport = !isLoading && !isError && !isEmpty; + // Placeholder rows are the PREVIOUS window's data held on screen during a + // window change — exporting them would pair old rows with a filename (and + // PNG title) claiming the new window, so hide the export actions until + // both sources' requested-window rows arrive. + const canExport = !isLoading && !isError && !isEmpty && !mqtt.isPlaceholderData && !ws.isPlaceholderData; const getCsvData = () => computeMetricCsvData('connections', merged, timeRange, nodes); const renderChart = (opts: { fillParent: boolean } = { fillParent: false }) => ( collectNodes(data), [data]); - const canExport = !isLoading && !isError && !isEmpty; + // Placeholder rows are the PREVIOUS window's data held on screen during a + // window change — exporting them would pair old rows with a filename (and + // PNG title) claiming the new window, so hide the export actions until the + // requested window's rows arrive. + const canExport = !isLoading && !isError && !isEmpty && !isPlaceholderData; const getCsvData = () => computeMetricCsvData(metric, data, timeRange, nodes); 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 d7b1aa226..17fe1a65d 100644 --- a/src/features/instance/status/analytics/tabs/__tests__/ConnectionsPanel.test.tsx +++ b/src/features/instance/status/analytics/tabs/__tests__/ConnectionsPanel.test.tsx @@ -91,6 +91,23 @@ describe('ConnectionsPanel', () => { expect(screen.getByText(/No active sessions in the selected time range/)).toBeTruthy(); }); + it('hides the export actions while either source serves placeholder rows', () => { + setHookResults({ + 'mqtt-connections': makeResult({ data: makeRows(), isEmpty: false }), + 'ws-connections': makeResult({ isPlaceholderData: true }), + }); + render( + + + , + ); + // The chart still renders, but the export row is gated off until both + // sources carry the requested window's rows. + expect(screen.getByText('Connections')).toBeTruthy(); + expect(screen.queryByLabelText('Expand connections')).toBeNull(); + expect(screen.queryByLabelText('Download connections as CSV')).toBeNull(); + }); + it('renders the merged chart when either source has data', () => { setHookResults({ 'mqtt-connections': makeResult({ data: makeRows(), isEmpty: false }), 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 a7194a800..cf42eea9d 100644 --- a/src/features/instance/status/analytics/tabs/__tests__/MetricPanel.test.tsx +++ b/src/features/instance/status/analytics/tabs/__tests__/MetricPanel.test.tsx @@ -85,6 +85,35 @@ describe('MetricPanel', () => { expect(container.textContent).toMatch(/CPU/i); }); + it('hides the export actions while placeholder (previous-window) rows are shown', () => { + // keepPreviousData keeps the old window's rows on screen (isLoading is + // false) during a window change — exporting them would label old rows + // with the new window's filename/title. + setHookResult({ data: makeRows(), isEmpty: false, isPlaceholderData: true }); + const { container } = render( + + + , + ); + // The chart still renders… + expect(container.textContent).toMatch(/CPU/i); + // …but none of the export actions do. + expect(screen.queryByLabelText('Download cpu-usage as CSV')).toBeNull(); + expect(screen.queryByLabelText('Download cpu-usage as PNG')).toBeNull(); + expect(screen.queryByLabelText('Copy cpu-usage chart to clipboard')).toBeNull(); + }); + + it("shows the export actions once the requested window's rows have settled", () => { + setHookResult({ data: makeRows(), isEmpty: false, isPlaceholderData: false }); + render( + + + , + ); + expect(screen.getByLabelText('Download cpu-usage as CSV')).toBeTruthy(); + expect(screen.getByLabelText('Download cpu-usage as PNG')).toBeTruthy(); + }); + it('uses the metric id as a fallback title when neither spec nor derived registry has it', () => { setHookResult({ isEmpty: true }); render( From e91dccf581a73266d1a4c80263d19d825cc9b8a9 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Tue, 14 Jul 2026 22:20:49 -0600 Subject: [PATCH 22/92] fix(status): deferred blob-URL revoke, BOM for Excel, tolerant csvField (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini review: revoke the download URL on a delayed timer (a synchronous revoke can abort Firefox's async download start), prepend a UTF-8 BOM so Excel renders the ' · ' separator and node names correctly, and let csvField coerce non-string identifiers instead of assuming strings. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV --- .../instance/status/analytics/lib/chartExport.ts | 15 +++++++-------- .../instance/status/analytics/lib/csvExport.ts | 10 +++++++--- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/features/instance/status/analytics/lib/chartExport.ts b/src/features/instance/status/analytics/lib/chartExport.ts index fa019179c..f51ed632d 100644 --- a/src/features/instance/status/analytics/lib/chartExport.ts +++ b/src/features/instance/status/analytics/lib/chartExport.ts @@ -43,14 +43,13 @@ export async function captureChartAsBlob( * Shared by the PNG export and the CSV export so they can't drift. */ export function triggerBlobDownload(blob: Blob, filename: string): void { const url = URL.createObjectURL(blob); - try { - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - } finally { - URL.revokeObjectURL(url); - } + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + // Revoke on a later tick: some browsers (Firefox) start the download + // asynchronously, and a synchronous revoke can abort it. + window.setTimeout(() => URL.revokeObjectURL(url), 10_000); } export async function downloadChart(chartContainer: HTMLElement, filename: string): Promise { diff --git a/src/features/instance/status/analytics/lib/csvExport.ts b/src/features/instance/status/analytics/lib/csvExport.ts index c0c9f107f..8155814b8 100644 --- a/src/features/instance/status/analytics/lib/csvExport.ts +++ b/src/features/instance/status/analytics/lib/csvExport.ts @@ -40,8 +40,10 @@ export type ChartCsvData = * spreadsheets import them as text instead of evaluating them as formulas * (labels come from telemetry paths/identifiers — CSV-injection guard). * Numeric cells go through csvNumber and keep their sign. */ -function csvField(value: string): string { - const neutralized = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value; +function csvField(value: string | number | null | undefined): string { + if (value === null || value === undefined) { return ''; } + const asString = String(value); + const neutralized = /^[=+\-@\t\r]/.test(asString) ? `'${asString}` : asString; if (/[",\r\n]/.test(neutralized)) { return `"${neutralized.replace(/"/g, '""')}"`; } @@ -148,7 +150,9 @@ export function makeCsvFilename(metric: string, range: TimeRange): string { } export function downloadCsv(csv: string, filename: string): void { - triggerBlobDownload(new Blob([csv], { type: 'text/csv;charset=utf-8' }), filename); + // UTF-8 BOM so Excel detects the encoding (the ' · ' connection-path + // separator and node names garble without it). + triggerBlobDownload(new Blob(['\uFEFF', csv], { type: 'text/csv;charset=utf-8' }), filename); } // ───────────────────────────────────────────────────────────────────────────── From cd0d26edf73b255edf7f0bb2ff323aee79e00055 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Tue, 14 Jul 2026 21:15:08 -0600 Subject: [PATCH 23/92] fix(status): stop the freshness watcher updating TimeRangePicker during sibling renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useAnalyticsFreshness called setState synchronously from its QueryCache subscription. useQuery registers new queries during render and RQ fires the cache event synchronously, so any component whose render introduced a new analytics query (a KPI tile's previous-window fetch, a MetricPanel on a fresh refresh window) made React log 'Cannot update a component (TimeRangePicker) while rendering a different component'. Reimplement on useSyncExternalStore with the notification deferred through notifyManager.batchCalls — the same bridge RQ's useIsFetching uses. A bare uSES onStoreChange still schedules the cross-fiber update mid-render; the regression test fails on both the old setState version and the bare-uSES version, passes only with the deferral. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV --- .../hooks/useAnalyticsFreshness.test.ts | 60 +++++++++++++ .../analytics/hooks/useAnalyticsFreshness.ts | 88 ++++++++++++------- 2 files changed, 114 insertions(+), 34 deletions(-) diff --git a/src/features/instance/status/analytics/hooks/useAnalyticsFreshness.test.ts b/src/features/instance/status/analytics/hooks/useAnalyticsFreshness.test.ts index 6e1a0868b..5ca5e33ca 100644 --- a/src/features/instance/status/analytics/hooks/useAnalyticsFreshness.test.ts +++ b/src/features/instance/status/analytics/hooks/useAnalyticsFreshness.test.ts @@ -91,3 +91,63 @@ describe('formatRelativeUpdate', () => { expect(formatRelativeUpdate(now + 1_000, now)).toBe('just now'); }); }); + +describe('useAnalyticsFreshness — mid-render query registration (setState-in-render regression)', () => { + afterEach(cleanup); + + it('does not warn when a sibling mounts a new analytics query while the hook is subscribed', async () => { + const { render } = await import('@testing-library/react'); + const { useQuery } = await import('@tanstack/react-query'); + const { useState } = await import('react'); + const { vi } = await import('vitest'); + + const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + const errorSpy = vi.spyOn(console, 'error'); + + let latest: ReturnType | undefined; + function Picker() { + latest = useAnalyticsFreshness('inst-A' as EntityIds); + return null; + } + // Mounting this registers a NEW query in the cache during ITS render. + // `initialData` matters: it stamps dataUpdatedAt at build time, so the + // 'added' event carries a lastFetchedAt CHANGE (null → ts) — the + // mid-render value flip that used to make the picker setState while + // this component renders. Without a value change the old code's + // functional setState bailed out and React never warned. + function LatePanel() { + useQuery({ + queryKey: analyticsKey('inst-A'), + queryFn: () => Promise.resolve([] as unknown[]), + initialData: [] as unknown[], + staleTime: Infinity, + }); + return null; + } + let mountPanel!: () => void; + function Harness() { + const [showPanel, setShowPanel] = useState(false); + mountPanel = () => setShowPanel(true); + return createElement( + QueryClientProvider, + { client }, + createElement(Picker), + showPanel ? createElement(LatePanel) : null, + ); + } + + render(createElement(Harness)); + expect(latest?.isFetching).toBe(false); + + // Picker is subscribed (post-mount); now a sibling render adds a + // query whose initialData changes lastFetchedAt mid-render. + act(() => mountPanel()); + await waitFor(() => expect(latest?.lastFetchedAt).not.toBeNull()); + + const renderPhaseWarnings = errorSpy.mock.calls.filter((call) => + String(call[0]).includes('Cannot update a component') + ); + expect(renderPhaseWarnings).toEqual([]); + errorSpy.mockRestore(); + }); +}); diff --git a/src/features/instance/status/analytics/hooks/useAnalyticsFreshness.ts b/src/features/instance/status/analytics/hooks/useAnalyticsFreshness.ts index 92c34f481..533a6023e 100644 --- a/src/features/instance/status/analytics/hooks/useAnalyticsFreshness.ts +++ b/src/features/instance/status/analytics/hooks/useAnalyticsFreshness.ts @@ -1,7 +1,7 @@ import type { EntityIds } from '@/features/auth/store/authStore'; import { ANALYTICS_QUERY_KEY_PREFIX } from '@/integrations/api/instance/status/getAnalytics'; -import { useQueryClient } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; +import { notifyManager, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; const PREFIX = ANALYTICS_QUERY_KEY_PREFIX; @@ -16,48 +16,68 @@ export interface AnalyticsFreshness { now: number; } +interface FreshnessSnapshot { + isFetching: boolean; + lastFetchedAt: number | null; +} + /** Watches the React Query cache for activity on the `get_analytics` * prefix and exposes a busy flag + most-recent-success timestamp. The * TimeRangePicker uses these to show an active/spinning refresh icon * and a "last updated" relative-time label. Scoped to `entityId` (key * position 1, per getAnalytics.ts) so two instance Status pages open - * at once don't reflect each other's fetches. */ + * at once don't reflect each other's fetches. + * + * Implemented like RQ's own useIsFetching — `useSyncExternalStore` + * with the store notification routed through `notifyManager.batchCalls`: + * `useQuery` registers new queries *during render* and RQ dispatches + * the cache event synchronously, so notifying React synchronously here + * (setState OR a bare uSES onStoreChange — both schedule a cross-fiber + * update) trips React's "Cannot update a component while rendering a + * different component" error while the mounting component (MetricPanel, + * a KPI tile) renders. batchCalls defers the notification past the + * render; the regression test proves both halves. */ export function useAnalyticsFreshness(entityId: EntityIds): AnalyticsFreshness { const client = useQueryClient(); - const [isFetching, setIsFetching] = useState(false); - const [lastFetchedAt, setLastFetchedAt] = useState(null); - const [now, setNow] = useState(() => Date.now()); + // getSnapshot must return a referentially-stable object while the + // underlying values are unchanged, or uSES loops; cache the last one. + const snapshotRef = useRef({ isFetching: false, lastFetchedAt: null }); - useEffect(() => { - const cache = client.getQueryCache(); - const isOurs = (q: { queryKey: unknown }) => - Array.isArray(q.queryKey) && q.queryKey[0] === PREFIX && q.queryKey[1] === entityId; - const sync = () => { - let fetching = false; - let mostRecent: number | null = null; - for (const q of cache.getAll()) { - if (!isOurs(q)) { continue; } - if (q.state.fetchStatus === 'fetching') { fetching = true; } - if (q.state.dataUpdatedAt > 0 && (mostRecent === null || q.state.dataUpdatedAt > mostRecent)) { - mostRecent = q.state.dataUpdatedAt; - } - } - // Only setState when the value actually changed — RQ fires the - // subscription on every cache event in the entire app, and a - // no-op setState would still trigger a re-render of the picker. - setIsFetching((prev) => (prev === fetching ? prev : fetching)); - setLastFetchedAt((prev) => (prev === mostRecent ? prev : mostRecent)); - }; - sync(); - // Subscribe filter: we only care about events on `get_analytics_raw` - // queries. Filter the *event* (not just the post-aggregate) so we - // don't recompute the cache scan for every unrelated query mutation. - const unsub = cache.subscribe((event) => { + const isOurs = useCallback( + (q: { queryKey: unknown }) => Array.isArray(q.queryKey) && q.queryKey[0] === PREFIX && q.queryKey[1] === entityId, + [entityId], + ); + + const subscribe = useCallback((onStoreChange: () => void) => { + // Subscribe filter: only events on `get_analytics_raw` queries for + // this entity wake the picker — not every query mutation in the app. + const deferredNotify = notifyManager.batchCalls(onStoreChange); + return client.getQueryCache().subscribe((event) => { if (!event?.query || !isOurs(event.query)) { return; } - sync(); + deferredNotify(); }); - return () => unsub(); - }, [client, entityId]); + }, [client, isOurs]); + + const getSnapshot = useCallback((): FreshnessSnapshot => { + let fetching = false; + let mostRecent: number | null = null; + for (const q of client.getQueryCache().getAll()) { + if (!isOurs(q)) { continue; } + if (q.state.fetchStatus === 'fetching') { fetching = true; } + if (q.state.dataUpdatedAt > 0 && (mostRecent === null || q.state.dataUpdatedAt > mostRecent)) { + mostRecent = q.state.dataUpdatedAt; + } + } + const prev = snapshotRef.current; + if (prev.isFetching !== fetching || prev.lastFetchedAt !== mostRecent) { + snapshotRef.current = { isFetching: fetching, lastFetchedAt: mostRecent }; + } + return snapshotRef.current; + }, [client, isOurs]); + + const { isFetching, lastFetchedAt } = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + const [now, setNow] = useState(() => Date.now()); useEffect(() => { // Tick rate adapts to age so we don't burn a 1Hz timer per picker From 2b34cf2fe3580de75f5c2c8c87b671363bcb2ec1 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Tue, 14 Jul 2026 22:14:31 -0600 Subject: [PATCH 24/92] refactor(status): freshness snapshots as two primitive stores (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini review: splitting isFetching / lastFetchedAt into separate useSyncExternalStore calls returning primitives removes the ref-cached object snapshot and the FreshnessSnapshot interface entirely — Object.is handles primitive stability for free. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV --- .../analytics/hooks/useAnalyticsFreshness.ts | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/src/features/instance/status/analytics/hooks/useAnalyticsFreshness.ts b/src/features/instance/status/analytics/hooks/useAnalyticsFreshness.ts index 533a6023e..b1196d04d 100644 --- a/src/features/instance/status/analytics/hooks/useAnalyticsFreshness.ts +++ b/src/features/instance/status/analytics/hooks/useAnalyticsFreshness.ts @@ -1,7 +1,7 @@ import type { EntityIds } from '@/features/auth/store/authStore'; import { ANALYTICS_QUERY_KEY_PREFIX } from '@/integrations/api/instance/status/getAnalytics'; import { notifyManager, useQueryClient } from '@tanstack/react-query'; -import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'; const PREFIX = ANALYTICS_QUERY_KEY_PREFIX; @@ -16,11 +16,6 @@ export interface AnalyticsFreshness { now: number; } -interface FreshnessSnapshot { - isFetching: boolean; - lastFetchedAt: number | null; -} - /** Watches the React Query cache for activity on the `get_analytics` * prefix and exposes a busy flag + most-recent-success timestamp. The * TimeRangePicker uses these to show an active/spinning refresh icon @@ -39,9 +34,6 @@ interface FreshnessSnapshot { * render; the regression test proves both halves. */ export function useAnalyticsFreshness(entityId: EntityIds): AnalyticsFreshness { const client = useQueryClient(); - // getSnapshot must return a referentially-stable object while the - // underlying values are unchanged, or uSES loops; cache the last one. - const snapshotRef = useRef({ isFetching: false, lastFetchedAt: null }); const isOurs = useCallback( (q: { queryKey: unknown }) => Array.isArray(q.queryKey) && q.queryKey[0] === PREFIX && q.queryKey[1] === entityId, @@ -58,24 +50,29 @@ export function useAnalyticsFreshness(entityId: EntityIds): AnalyticsFreshness { }); }, [client, isOurs]); - const getSnapshot = useCallback((): FreshnessSnapshot => { - let fetching = false; + // Two stores returning primitives: uSES compares snapshots by Object.is, + // so primitive snapshots need no referential-stability bookkeeping (an + // object snapshot would have to be ref-cached to avoid render loops). + const getIsFetching = useCallback((): boolean => { + for (const q of client.getQueryCache().getAll()) { + if (isOurs(q) && q.state.fetchStatus === 'fetching') { return true; } + } + return false; + }, [client, isOurs]); + + const getLastFetchedAt = useCallback((): number | null => { let mostRecent: number | null = null; for (const q of client.getQueryCache().getAll()) { if (!isOurs(q)) { continue; } - if (q.state.fetchStatus === 'fetching') { fetching = true; } if (q.state.dataUpdatedAt > 0 && (mostRecent === null || q.state.dataUpdatedAt > mostRecent)) { mostRecent = q.state.dataUpdatedAt; } } - const prev = snapshotRef.current; - if (prev.isFetching !== fetching || prev.lastFetchedAt !== mostRecent) { - snapshotRef.current = { isFetching: fetching, lastFetchedAt: mostRecent }; - } - return snapshotRef.current; + return mostRecent; }, [client, isOurs]); - const { isFetching, lastFetchedAt } = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const isFetching = useSyncExternalStore(subscribe, getIsFetching, getIsFetching); + const lastFetchedAt = useSyncExternalStore(subscribe, getLastFetchedAt, getLastFetchedAt); const [now, setNow] = useState(() => Date.now()); From 0d40308c004f3c034af1b6587420ff00cd984a50 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Wed, 15 Jul 2026 09:33:37 -0600 Subject: [PATCH 25/92] fix(status): keep rotated heatmap column labels clear of the cell grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rotated destination (column) axis labels were partly hidden behind the top row of cells. They anchored at y = HEADER_HEIGHT - 8 (only 8px above the grid) and rendered with textAnchor="end" + rotate(-45, cx, cy), which sent each label's far end DOWN-and-left — 30-45px below the anchor for an 8-20 char label, inside the first cell row. Because the header paints before the data rows, the cells painted over the overlap. Fix the geometry so a label can never enter the cell region: - Flip the rotation to +45 (still textAnchor="end") so labels lean UP-and- left, away from the grid. The anchored END glyph is now the label's LOWEST point, so its lowest extent is fixed at the anchor baseline regardless of label length — extra length only pushes the far (top) end higher, never down into the cells. - Anchor that near end just above the first cell row (headerHeight - HEADER_BOTTOM_PAD) so it clears the grid. - Replace the fixed HEADER_HEIGHT constant with computeHeaderHeight(cols, cellSize): it sizes the header to the longest rendered (post-truncation) label's diagonal vertical extent, floored at 72px, so the up-left far end is never clipped at the top of the SVG. svgHeight/viewBox derive from it, so the taller header is always contained. Extends the HeatmapMatrix tests with a column-label geometry block (labels anchored above the first cell row, +45 not -45, viewBox contains the header) and unit tests for computeHeaderHeight (floor, growth for long labels, monotonicity, per-cellSize truncation cap). Fixes #1518 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV --- .../primitives/heatmap-matrix.test.tsx | 79 ++++++++++++++++++- .../analytics/primitives/HeatmapMatrix.tsx | 66 ++++++++++++++-- 2 files changed, 136 insertions(+), 9 deletions(-) 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 0a3541bde..a0ee9ecbd 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,7 +1,7 @@ // @vitest-environment happy-dom import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { HeatmapMatrix } from '../../primitives/HeatmapMatrix'; +import { computeHeaderHeight, HeatmapMatrix } from '../../primitives/HeatmapMatrix'; import type { HeatmapData } from '../../types/analytics'; const data: HeatmapData = { @@ -306,6 +306,83 @@ describe('HeatmapMatrix primitive', () => { }); }); + // Regression #1518: the rotated destination (column) labels used to anchor + // only 8px above the grid and rotate −45°, sending each label's far end DOWN + // into the first cell row where the cells painted over it. The fix anchors the + // label's END glyph just above the grid and leans it UP-and-left (+45°), so the + // anchor baseline is the label's lowest point and it never enters the cells; + // the header is sized so the far (top) end is never clipped either. + describe('column-label geometry (#1518)', () => { + const firstCellY = () => { + const rect = screen.getAllByRole('gridcell')[0].querySelector('rect')!; + return Number(rect.getAttribute('y')); + }; + const colHeaderTexts = () => screen.getAllByRole('columnheader').map((g) => g.querySelector('text')!); + + it('anchors every column label above the first cell row (no overlap)', () => { + render(); + const cellY = firstCellY(); + for (const t of colHeaderTexts()) { + // The label's near-end baseline (its lowest point once rotated +45°) + // sits strictly above where the cells begin. + expect(Number(t.getAttribute('y'))).toBeLessThan(cellY); + } + }); + + it('rotates the labels UP-left (+45°), not the old down-left −45°', () => { + render(); + for (const t of colHeaderTexts()) { + const transform = t.getAttribute('transform') ?? ''; + expect(transform).toMatch(/rotate\(\s*45\s*,/); + expect(transform).not.toMatch(/rotate\(\s*-45/); + } + }); + + it('reserves header room that matches computeHeaderHeight, and the viewBox contains it', () => { + const { container } = render(); + const svg = container.querySelector('svg[role="grid"]')!; + const cellSize = Number(svg.getAttribute('data-cell-size')); + // The first cell row starts exactly at the reserved header height. + expect(firstCellY()).toBe(computeHeaderHeight(data.cols, cellSize)); + // viewBox is "0 0 W H"; H must be tall enough to contain the header + // plus at least the first cell row — nothing clipped top or bottom. + const [minX, minY, , vbHeight] = (svg.getAttribute('viewBox') ?? '').split(/\s+/).map(Number); + expect(minX).toBe(0); + expect(minY).toBe(0); + expect(vbHeight).toBeGreaterThan(firstCellY()); + }); + }); + + describe('computeHeaderHeight', () => { + it('floors short-label grids at the minimum header height (72)', () => { + expect(computeHeaderHeight(['a', 'bb', 'ccc'], 40)).toBe(72); + expect(computeHeaderHeight([], 80)).toBe(72); + }); + + it('grows to contain long labels rotated 45° so the far end is not clipped', () => { + const longCols = ['node-alpha.us-east-1.example.internal', 'n2.example.internal']; + const wide = computeHeaderHeight(longCols, 80); // wide cells → 20-char truncation + expect(wide).toBeGreaterThan(72); + // Must reserve at least the diagonal's vertical extent of a 20-char label + // (20 · 6.6 · sin45 ≈ 93px) so the up-left far end clears y=0. + expect(wide).toBeGreaterThanOrEqual(93); + }); + + it('is monotonic in the longest label length', () => { + const short = computeHeaderHeight(['abcdefghijklmnop'], 80); + const longer = computeHeaderHeight(['abcdefghijklmnopqrst'], 80); + expect(longer).toBeGreaterThanOrEqual(short); + }); + + it('caps growth via the per-cellSize truncation (narrow cells truncate to 8 chars)', () => { + const label = ['a-very-long-destination-label-that-exceeds-limits']; + // Narrow cells truncate to 8 chars, so the header stays at the floor. + expect(computeHeaderHeight(label, 40)).toBe(72); + // Wide cells truncate to 20 chars, so the header grows past the floor. + expect(computeHeaderHeight(label, 80)).toBeGreaterThan(72); + }); + }); + 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/primitives/HeatmapMatrix.tsx b/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx index a16df8bd4..18107df27 100644 --- a/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx +++ b/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx @@ -242,9 +242,54 @@ function HeatmapColorLegend({ stops, vmin, vmax, width, axis, approx, measureLab const MIN_CELL_SIZE = 40; const MAX_CELL_SIZE = 80; const CELL_GAP = 4; -const HEADER_HEIGHT = 72; // reserved for rotated column headers const ROW_LABEL_WIDTH = 200; +// ── Rotated column-header geometry ─────────────────────────────────────────── +// Destination labels are drawn diagonally above the grid. They anchor at their +// END glyph (nearest the cell), sit just above the first cell row, and lean +// UP-and-to-the-LEFT (rotate +45° with textAnchor="end"). Anchoring the near +// end means the label's LOWEST point is the anchor baseline — independent of the +// label's length — so no matter how long a label is it can never dip into the +// cells; extra length only pushes the far (top) end higher. The header height is +// sized from the longest rendered label so that far end is never clipped at the +// top of the SVG. (Was rotate −45° anchored 8px above the grid, which sent the +// far end DOWN into the first cell row and hid it behind the cells — #1518.) +const COL_LABEL_FONT_SIZE = 11; +const COL_LABEL_ANGLE_DEG = 45; +// Approximate glyph advance at COL_LABEL_FONT_SIZE. Set on the generous side of a +// mixed-case hostname's average so we over- rather than under-reserve vertical +// room for the diagonal's top end. It's only an estimate (no DOM text +// measurement, to keep computeHeaderHeight pure/deterministic), so a label of +// unusually wide glyphs (all-caps, CJK) could still exceed it — but the near-end +// anchoring means that only lifts the label's TOP, never its bottom into the +// cells, and the SVG's `overflow: visible` renders any excess rather than +// clipping it. +const COL_LABEL_CHAR_W = 7.2; +const HEADER_TOP_PAD = 8; // gap above the tallest label's top end +const HEADER_BOTTOM_PAD = 6; // gap between the label's near end and the cells +const MIN_HEADER_HEIGHT = 72; // floor, so short-label grids keep their proportions + +/** Chars a column label is truncated to before rendering — tighter for narrow + * cells so long FQDNs don't crowd the diagonal. */ +function colTruncateLength(cellSize: number): number { + return cellSize < 56 ? 8 : 20; +} + +/** Vertical space the rotated column headers need, sized to the longest label + * that will actually render (post-truncation). The diagonal's vertical extent + * is `width·sin θ + fontSize·cos θ`; we add top/bottom padding and floor it at + * MIN_HEADER_HEIGHT. Kept pure (no DOM text measurement) so it's deterministic + * in tests and SSR. */ +export function computeHeaderHeight(cols: string[], cellSize: number): number { + const maxChars = cols.reduce( + (m, c) => Math.max(m, Math.min(c.length, colTruncateLength(cellSize))), + 0, + ); + const rad = (COL_LABEL_ANGLE_DEG * Math.PI) / 180; + const vExtent = maxChars * COL_LABEL_CHAR_W * Math.sin(rad) + COL_LABEL_FONT_SIZE * Math.cos(rad); + return Math.max(MIN_HEADER_HEIGHT, Math.ceil(vExtent) + HEADER_TOP_PAD + HEADER_BOTTOM_PAD); +} + export function HeatmapMatrix({ data, title, height, onCellSelect }: Props) { const titleId = useId(); const descId = useId(); @@ -321,8 +366,9 @@ export function HeatmapMatrix({ data, title, height, onCellSelect }: Props) { const gridWidth = cols.length * cellSize + (cols.length - 1) * CELL_GAP; const gridHeight = rows.length * cellSize + (rows.length - 1) * CELL_GAP; + const headerHeight = computeHeaderHeight(cols, cellSize); const svgWidth = ROW_LABEL_WIDTH + gridWidth + 8; - const svgHeight = HEADER_HEIGHT + gridHeight + 8; + const svgHeight = headerHeight + gridHeight + 8; // Roving tabindex state: [rowIdx, colIdx]. Clamped at render time so the // active cell stays in range when rows/cols shrink after a data refresh @@ -499,22 +545,26 @@ export function HeatmapMatrix({ data, title, height, onCellSelect }: Props) { x={0} y={0} width={ROW_LABEL_WIDTH} - height={HEADER_HEIGHT} + height={headerHeight} fill="transparent" aria-hidden="true" /> {cols.map((col, ci) => { const cx = ROW_LABEL_WIDTH + ci * (cellSize + CELL_GAP) + cellSize / 2; - const cy = HEADER_HEIGHT - 8; - const truncateLength = cellSize < 56 ? 8 : 20; + // Anchor the label's END glyph just above the first cell row and + // lean it up-and-left. The near-end baseline is the label's + // lowest point, so it can never reach into the cells regardless + // of length; see computeHeaderHeight. + const cy = headerHeight - HEADER_BOTTOM_PAD; + const truncateLength = colTruncateLength(cellSize); return ( {truncate(col, truncateLength)} @@ -527,7 +577,7 @@ export function HeatmapMatrix({ data, title, height, onCellSelect }: Props) { {/* Data rows */} {rows.map((row, ri) => { - const y = HEADER_HEIGHT + ri * (cellSize + CELL_GAP); + const y = headerHeight + ri * (cellSize + CELL_GAP); return ( {/* Row header */} From 13874f0d4bda87fb82ce78f2ef1da43ac2cfc83f Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Wed, 15 Jul 2026 10:45:57 -0600 Subject: [PATCH 26/92] fix(status): shorten heatmap axis node names instead of char-truncating (#1515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The replication heatmap's source/destination axis labels char-truncated the full FQDN mid-string (hpr-us-e…); show the short node name (first DNS segment) like the charts and tooltip. Header height sizes to the shortened labels; the full FQDN stays on each label's title and aria-label. Collision-aware shortNodeLabelMap follows once #1522 lands it on stage (noted in a TODO). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV --- .../primitives/heatmap-matrix.test.tsx | 23 +++++++++++++++++++ .../analytics/primitives/HeatmapMatrix.tsx | 14 ++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) 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 a0ee9ecbd..749434bbe 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 @@ -70,6 +70,29 @@ describe('HeatmapMatrix primitive', () => { expect(screen.getByRole('columnheader', { name: /dest-a/i })).toBeTruthy(); }); + it('displays the short node name while keeping the full FQDN in a11y + title', () => { + const fqdnData = { + ...data, + rows: ['src-1.us-west.acme.harperfabric.com'], + cols: ['dest-a.us-east.acme.harperfabric.com'], + cells: [{ + row: 'src-1.us-west.acme.harperfabric.com', + col: 'dest-a.us-east.acme.harperfabric.com', + value: 5, + count: 200, + }], + }; + render(); + // Displayed label text (the node's own value, not the nested + // ) is the first DNS segment… + const col = screen.getByRole('columnheader', { name: 'dest-a.us-east.acme.harperfabric.com' }); + expect(col.querySelector('text')?.firstChild?.textContent).toBe('dest-a'); + // …while the <title> and aria-label preserve the full FQDN. + expect(col.querySelector('title')?.textContent).toBe('dest-a.us-east.acme.harperfabric.com'); + const row = screen.getByRole('rowheader', { name: 'src-1.us-west.acme.harperfabric.com' }); + expect(row.querySelector('text')?.firstChild?.textContent).toBe('src-1'); + }); + it('renders a color-scale legend', () => { render(<HeatmapMatrix data={data} />); const legend = screen.getByRole('img', { name: /color scale|p95 latency/i }); diff --git a/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx b/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx index 18107df27..76b82ce3c 100644 --- a/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx +++ b/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx @@ -2,6 +2,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 { shortenNodeLabel } from '../lib/nodeLabels'; import type { AxisSpec, HeatmapCell, HeatmapData } from '../types/analytics'; import { warningBannerStyle } from './bannerStyle'; import { computeCellSize } from './computeCellSize'; @@ -332,6 +333,13 @@ export function HeatmapMatrix({ data, title, height, onCellSelect }: Props) { const rows = data.rows; const cols = data.cols; + // Source/destination axis labels are node FQDNs; show the short node name + // (first DNS segment) instead of a blunt mid-string character truncation. + // The full FQDN stays on each label's <title> and aria-label. (#1515) + // TODO: once shortNodeLabelMap (collision-aware, added in #1522) lands on + // stage, swap to it so two sources sharing a first segment stay distinct. + const shortColLabels = useMemo(() => cols.map(shortenNodeLabel), [cols]); + // Responsive cell sizing: measure wrapper width, clamp to [MIN, MAX]. const wrapperRef = useRef<HTMLDivElement>(null); const [cellSize, setCellSize] = useState<number>(MAX_CELL_SIZE); @@ -366,7 +374,7 @@ export function HeatmapMatrix({ data, title, height, onCellSelect }: Props) { const gridWidth = cols.length * cellSize + (cols.length - 1) * CELL_GAP; const gridHeight = rows.length * cellSize + (rows.length - 1) * CELL_GAP; - const headerHeight = computeHeaderHeight(cols, cellSize); + const headerHeight = computeHeaderHeight(shortColLabels, cellSize); const svgWidth = ROW_LABEL_WIDTH + gridWidth + 8; const svgHeight = headerHeight + gridHeight + 8; @@ -567,7 +575,7 @@ export function HeatmapMatrix({ data, title, height, onCellSelect }: Props) { transform={`rotate(${COL_LABEL_ANGLE_DEG}, ${cx}, ${cy})`} fill="currentColor" > - {truncate(col, truncateLength)} + {truncate(shortenNodeLabel(col), truncateLength)} <title>{col} @@ -589,7 +597,7 @@ export function HeatmapMatrix({ data, title, height, onCellSelect }: Props) { textAnchor="end" fill="currentColor" > - {truncate(row, 24)} + {truncate(shortenNodeLabel(row), 24)} {row} From 1b7a52b60b7a2a9c704fbfbf112bb856af14d640 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Wed, 15 Jul 2026 10:53:51 -0600 Subject: [PATCH 27/92] refactor(status): factor sin/cos out of computeHeaderHeight at the fixed 45deg (review) Gemini: COL_LABEL_ANGLE_DEG is 45deg, so sin = cos = SQRT1_2; use it directly and drop the per-render deg-to-rad + sin/cos. Comment documents the coupling so a future angle change restores the general form. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV --- .../instance/status/analytics/primitives/HeatmapMatrix.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx b/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx index 76b82ce3c..a9dafcfbd 100644 --- a/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx +++ b/src/features/instance/status/analytics/primitives/HeatmapMatrix.tsx @@ -286,8 +286,10 @@ export function computeHeaderHeight(cols: string[], cellSize: number): number { (m, c) => Math.max(m, Math.min(c.length, colTruncateLength(cellSize))), 0, ); - const rad = (COL_LABEL_ANGLE_DEG * Math.PI) / 180; - const vExtent = maxChars * COL_LABEL_CHAR_W * Math.sin(rad) + COL_LABEL_FONT_SIZE * Math.cos(rad); + // COL_LABEL_ANGLE_DEG is 45°, so sin θ = cos θ = √½ — factor it out of the + // per-render trig. If the label angle ever stops being 45°, restore the + // general `sin(rad)`/`cos(rad)` form. + const vExtent = (maxChars * COL_LABEL_CHAR_W + COL_LABEL_FONT_SIZE) * Math.SQRT1_2; return Math.max(MIN_HEADER_HEIGHT, Math.ceil(vExtent) + HEADER_TOP_PAD + HEADER_BOTTOM_PAD); } From 4d1b2fcdcaf6e325485fda6913813980d7e4d4a1 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Wed, 15 Jul 2026 09:27:28 -0600 Subject: [PATCH 28/92] fix(status): one shared chart tooltip with short node labels Replace the three divergent tooltip implementations (LineChart's default formatter, StackedAreaChart's bespoke StackedAreaTooltip, TableSizeTrend's default variant) with one shared ChartTooltip content component: consistent formatTooltipTime header, formatValue values (with per-entry axis resolution for dual-axis charts), and the stacked charts' Total row kept behind a showTotal prop. Displayed series names shorten node FQDNs via a new collision-aware shortNodeLabelMap (colliding first segments extend until unique). Display-layer only: pipeline series label fields are untouched, so CSV exports and legends keep full names. Fixes #1515 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV --- .../analytics/__tests__/nodeLabels.test.ts | 46 +++++++ .../primitives/chart-tooltip.test.tsx | 122 ++++++++++++++++++ .../primitives/stacked-area.test.tsx | 45 +------ .../analytics/charts/TableSizeTrend.tsx | 12 +- .../status/analytics/lib/nodeLabels.ts | 37 ++++++ .../analytics/primitives/ChartTooltip.tsx | 116 +++++++++++++++++ .../status/analytics/primitives/LineChart.tsx | 29 +++-- .../analytics/primitives/StackedAreaChart.tsx | 74 +++-------- 8 files changed, 359 insertions(+), 122 deletions(-) create mode 100644 src/features/instance/status/analytics/__tests__/nodeLabels.test.ts create mode 100644 src/features/instance/status/analytics/__tests__/primitives/chart-tooltip.test.tsx create mode 100644 src/features/instance/status/analytics/primitives/ChartTooltip.tsx diff --git a/src/features/instance/status/analytics/__tests__/nodeLabels.test.ts b/src/features/instance/status/analytics/__tests__/nodeLabels.test.ts new file mode 100644 index 000000000..984b7f0a2 --- /dev/null +++ b/src/features/instance/status/analytics/__tests__/nodeLabels.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { shortenNodeLabel, shortNodeLabelMap } from '../lib/nodeLabels'; + +describe('shortenNodeLabel', () => { + it('keeps the first FQDN segment', () => { + expect(shortenNodeLabel('xb6-us-west-1.prod.ibm.harperfabric.com')).toBe('xb6-us-west-1'); + }); + + it('falls back to the full string without a dot', () => { + expect(shortenNodeLabel('localhost')).toBe('localhost'); + }); +}); + +describe('shortNodeLabelMap', () => { + it('shortens non-colliding nodes to their first segment (same as shortenNodeLabel)', () => { + const map = shortNodeLabelMap(['a.acme.com', 'b.acme.com']); + expect(map.get('a.acme.com')).toBe('a'); + expect(map.get('b.acme.com')).toBe('b'); + }); + + it('extends colliding labels with enough segments to disambiguate', () => { + const map = shortNodeLabelMap(['node1.us.acme.com', 'node1.eu.acme.com', 'node2.us.acme.com']); + expect(map.get('node1.us.acme.com')).toBe('node1.us'); + expect(map.get('node1.eu.acme.com')).toBe('node1.eu'); + // Unique first segment stays short. + expect(map.get('node2.us.acme.com')).toBe('node2'); + }); + + it('keeps deep collisions extending until they split', () => { + const map = shortNodeLabelMap(['n.us.east.acme.com', 'n.us.west.acme.com']); + expect(map.get('n.us.east.acme.com')).toBe('n.us.east'); + expect(map.get('n.us.west.acme.com')).toBe('n.us.west'); + }); + + it('falls back to the full name when a node exhausts its segments', () => { + const map = shortNodeLabelMap(['web', 'web.acme.com']); + expect(map.get('web')).toBe('web'); + expect(map.get('web.acme.com')).toBe('web.acme'); + }); + + it('dedupes repeated inputs and handles empties', () => { + const map = shortNodeLabelMap(['a.acme.com', 'a.acme.com']); + expect(map.size).toBe(1); + expect(shortNodeLabelMap([]).size).toBe(0); + }); +}); diff --git a/src/features/instance/status/analytics/__tests__/primitives/chart-tooltip.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/chart-tooltip.test.tsx new file mode 100644 index 000000000..7666fc8ee --- /dev/null +++ b/src/features/instance/status/analytics/__tests__/primitives/chart-tooltip.test.tsx @@ -0,0 +1,122 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { ChartTooltip } from '../../primitives/ChartTooltip'; + +describe('ChartTooltip (shared cartesian chart tooltip)', () => { + afterEach(() => cleanup()); + + it('returns null when inactive', () => { + const { container } = render(); + expect(container.firstChild).toBe(null); + }); + + it('returns null for an empty payload', () => { + const { container } = render(); + expect(container.firstChild).toBe(null); + }); + + it('formats the time header via formatTooltipTime', () => { + const payload = [{ dataKey: 'a', name: 'A', value: 1, color: '#f00' }]; + const { container } = render(); + // Nov 14 2023 in every locale's short-month rendering carries the year. + expect(container.textContent ?? '').toMatch(/2023/); + }); + + it('formats values with the default formatter/unit', () => { + const payload = [{ dataKey: 'a', name: 'A', value: 1500, color: '#f00' }]; + render(); + expect(screen.getByText(/1\.5 KB\/s/)).toBeTruthy(); + }); + + it('resolveEntryFormat overrides the default per entry (dual-axis charts)', () => { + const payload = [ + { dataKey: 'y', name: 'util', value: 0.5, color: '#f00' }, + { dataKey: 'y', name: 'lag', value: 12, color: '#0f0' }, + ]; + render( + entry.name === 'util' ? { formatter: 'percent' } : { formatter: 'ms' }} + />, + ); + expect(screen.getByText(/50\.0%/)).toBeTruthy(); + expect(screen.getByText(/12\.0 ms/)).toBeTruthy(); + }); + + it('renders — for a missing (null) value instead of a fake 0', () => { + const payload = [{ dataKey: 'a', name: 'A', value: null, color: '#f00' }]; + render(); + expect(screen.getByText('—')).toBeTruthy(); + }); + + it('shortens full node FQDNs in series names (display only)', () => { + const node = 'hpr-us-east1-b-1.anvils.acme-inc.stage.harperfabric.com'; + const payload = [ + { dataKey: 'y', name: `cache hits — ${node}`, value: 5, color: '#f00' }, + { dataKey: 'y', name: node, value: 7, color: '#0f0' }, + ]; + render( + , + ); + expect(screen.getByText('cache hits — hpr-us-east1-b-1')).toBeTruthy(); + expect(screen.getByText('hpr-us-east1-b-1')).toBeTruthy(); + expect(screen.queryByText(new RegExp('anvils'))).toBe(null); + }); + + it('disambiguates nodes whose short labels collide', () => { + const nodes = ['node1.us.acme.com', 'node1.eu.acme.com']; + const payload = nodes.map((n, i) => ({ dataKey: 'y', name: n, value: i, color: '#f00' })); + render( + , + ); + expect(screen.getByText('node1.us')).toBeTruthy(); + expect(screen.getByText('node1.eu')).toBeTruthy(); + }); + + it('renders a Total row summing the payload when showTotal is set', () => { + const payload = [ + { dataKey: 'a', name: 'A', value: 20, color: '#f00' }, + { dataKey: 'b', name: 'B', value: 15, color: '#0f0' }, + ]; + render(); + expect(screen.getByText(/Total/)).toBeTruthy(); + expect(screen.getByText(/35/)).toBeTruthy(); + }); + + it('renders no Total row without showTotal (line charts)', () => { + const payload = [ + { dataKey: 'a', name: 'A', value: 20, color: '#f00' }, + { dataKey: 'b', name: 'B', value: 15, color: '#0f0' }, + ]; + render(); + expect(screen.queryByText(/Total/)).toBe(null); + }); + + it('suppresses the Total row for single-series payloads', () => { + const payload = [{ dataKey: 'a', name: 'A', value: 42, color: '#f00' }]; + render(); + expect(screen.queryByText(/Total/)).toBe(null); + }); + + it('uses raw count formatter for Total on count-si axis (preserve precision)', () => { + const payload = [ + { dataKey: 'a', name: 'A', value: 12345, color: '#f00' }, + { dataKey: 'b', name: 'B', value: 67890, color: '#0f0' }, + ]; + render( + , + ); + // Total = 80235; rendered raw, not "80k". + expect(screen.getByText(/80235\s*msg\/s/)).toBeTruthy(); + }); +}); diff --git a/src/features/instance/status/analytics/__tests__/primitives/stacked-area.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/stacked-area.test.tsx index 4819088d3..ea17c3768 100644 --- a/src/features/instance/status/analytics/__tests__/primitives/stacked-area.test.tsx +++ b/src/features/instance/status/analytics/__tests__/primitives/stacked-area.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { cleanup, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it } from 'vitest'; -import { StackedAreaChart, StackedAreaTooltip } from '../../primitives/StackedAreaChart'; +import { StackedAreaChart } from '../../primitives/StackedAreaChart'; import type { SeriesData } from '../../types/analytics'; const stacked: SeriesData = { @@ -137,46 +137,3 @@ describe('StackedAreaChart Step 3.5 polish', () => { document.documentElement.classList.remove('dark'); }); }); - -// TODO: the it.skip() cases below are Recharts visual-rendering assertions -// that depend on real layout (computed width/height, stroke geometry, axis -// tick text). Both jsdom and happy-dom fall short here even with the -// getBoundingClientRect / ResizeObserver shim in __tests__/setup.ts. Math is -// covered by the pipeline + aggregator suites; revisit these visual checks -// with a Playwright smoke pass once studio adopts E2E. -describe('StackedAreaTooltip', () => { - afterEach(() => cleanup()); - - it('renders Total row summing payload values', () => { - const payload = [ - { dataKey: 'a', name: 'A', value: 20, color: '#f00' }, - { dataKey: 'b', name: 'B', value: 15, color: '#0f0' }, - ]; - render(); - expect(screen.getByText(/Total/)).toBeTruthy(); - expect(screen.getByText(/35/)).toBeTruthy(); - }); - - it('suppresses Total row for single-series payload', () => { - const payload = [{ dataKey: 'a', name: 'A', value: 42, color: '#f00' }]; - render(); - expect(screen.queryByText(/Total/)).toBe(null); - }); - - it('uses raw count formatter for Total on count-si axis (preserve precision)', () => { - const payload = [ - { dataKey: 'a', name: 'A', value: 12345, color: '#f00' }, - { dataKey: 'b', name: 'B', value: 67890, color: '#0f0' }, - ]; - render( - , - ); - // Total = 80235; rendered raw not "80k". - expect(screen.getByText(/80235\s*msg\/s/)).toBeTruthy(); - }); - - it('returns null when inactive', () => { - const { container } = render(); - expect(container.firstChild).toBe(null); - }); -}); diff --git a/src/features/instance/status/analytics/charts/TableSizeTrend.tsx b/src/features/instance/status/analytics/charts/TableSizeTrend.tsx index ff99bfa34..7b61bf462 100644 --- a/src/features/instance/status/analytics/charts/TableSizeTrend.tsx +++ b/src/features/instance/status/analytics/charts/TableSizeTrend.tsx @@ -6,8 +6,8 @@ import { useNodeSelection } from '../hooks/useNodeSelection'; import { getNodeColor } from '../lib/nodeColors'; import { computeGrowthAnnotation, type RankBy, type TableSizeDerived } from '../lib/tableSize'; import { getChartColors } from '../lib/theme'; -import { formatAxisTick, formatTooltipTime } from '../lib/time'; -import { tooltipContentStyle, tooltipItemStyle, tooltipLabelStyle } from '../primitives/tooltipStyle'; +import { formatAxisTick } from '../lib/time'; +import { ChartTooltip } from '../primitives/ChartTooltip'; import type { TimeRange, ViewMode } from '../types/analytics'; import { NodeLegend } from './NodeLegend'; import { TableSizeChipRow } from './TableSizeChipRow'; @@ -181,13 +181,7 @@ export function TableSizeTrend({ domain={viewMode === 'per-node' ? [1, 'auto'] : ['auto', 'auto']} allowDataOverflow /> - formatTooltipTime(Number(label))} - formatter={(value) => formatBytes(Number(value))} - /> + } /> {nodesWithData .filter((n) => isActive(n)) .map((node) => ( diff --git a/src/features/instance/status/analytics/lib/nodeLabels.ts b/src/features/instance/status/analytics/lib/nodeLabels.ts index cf860ba27..72c3cb9f3 100644 --- a/src/features/instance/status/analytics/lib/nodeLabels.ts +++ b/src/features/instance/status/analytics/lib/nodeLabels.ts @@ -5,3 +5,40 @@ export function shortenNodeLabel(node: string): string { const dot = node.indexOf('.'); return dot === -1 ? node : node.slice(0, dot); } + +/** Collision-aware short labels for a set of node FQDNs (display-layer only — + * callers must never write these back into series `label` fields, which CSV + * export and legends elsewhere rely on). + * + * Each node starts at its first segment (same as shortenNodeLabel); when two + * distinct nodes share that segment (e.g. 'node1.us.acme.com' / + * 'node1.eu.acme.com'), the colliding group keeps one more segment + * ('node1.us' / 'node1.eu') until the labels disambiguate, falling back to + * the full name when a node's segments are exhausted. */ +export function shortNodeLabelMap(nodes: readonly string[]): Map { + const labels = new Map(); + let pending = [...new Set(nodes)]; + for (let depth = 1; pending.length > 0; depth++) { + const byPrefix = new Map(); + for (const node of pending) { + const prefix = node.split('.').slice(0, depth).join('.'); + const group = byPrefix.get(prefix); + if (group) { group.push(node); } + else { byPrefix.set(prefix, [node]); } + } + pending = []; + for (const [prefix, group] of byPrefix) { + if (group.length === 1) { + labels.set(group[0], prefix); + continue; + } + for (const node of group) { + // A node whose whole name is the shared prefix can't extend + // further — keep it in full; the rest retry one segment deeper. + if (node.split('.').length <= depth) { labels.set(node, node); } + else { pending.push(node); } + } + } + } + return labels; +} diff --git a/src/features/instance/status/analytics/primitives/ChartTooltip.tsx b/src/features/instance/status/analytics/primitives/ChartTooltip.tsx new file mode 100644 index 000000000..f7416886b --- /dev/null +++ b/src/features/instance/status/analytics/primitives/ChartTooltip.tsx @@ -0,0 +1,116 @@ +import { formatValue, type ValueFormatter } from '@/lib/formatValue'; +import { useMemo } from 'react'; +import { shortNodeLabelMap } from '../lib/nodeLabels'; +import { formatTooltipTime } from '../lib/time'; +import { tooltipContentStyle, tooltipItemStyle, tooltipLabelStyle } from './tooltipStyle'; + +/** The subset of Recharts' tooltip payload entry the shared content reads. + * Kept structural (not Recharts' own type) so the component stays trivially + * testable and independent of Recharts' generics. */ +export interface ChartTooltipEntry { + dataKey?: string | number; + name?: string | number; + value?: number | string | Array | null; + color?: string; +} + +export interface ChartTooltipEntryFormat { + formatter?: ValueFormatter; + unitSuffix?: string; +} + +interface Props { + /** Injected by Recharts when passed as . */ + active?: boolean; + payload?: readonly ChartTooltipEntry[]; + label?: number | string; + /** Default value format applied to every row. */ + formatter?: ValueFormatter; + unitSuffix?: string; + /** Per-entry format override for dual-axis charts (LineChart resolves the + * entry's series to its left/right axis spec). Wins over + * `formatter`/`unitSuffix` when provided. */ + resolveEntryFormat?: (entry: ChartTooltipEntry) => ChartTooltipEntryFormat; + /** Full node FQDNs present on the chart. Occurrences inside displayed + * series names are shortened (collision-aware, see shortNodeLabelMap). + * Display-layer only: the series `label` fields are untouched, so CSV + * exports and legends keep the full names. */ + nodeNames?: readonly string[]; + /** Append the stacked charts' summed Total row (ported from the old + * StackedAreaTooltip; hidden for single-entry payloads). */ + showTotal?: boolean; +} + +/** THE tooltip content for every synced cartesian chart on the Status tabs + * (LineChart, StackedAreaChart, TableSizeTrend). One implementation so the + * time header, node-name shortening, and value formatting can't drift + * between chart types. */ +export function ChartTooltip( + { active, payload, label, formatter, unitSuffix, resolveEntryFormat, nodeNames, showTotal }: Props, +) { + const shortLabels = useMemo(() => { + const map = shortNodeLabelMap(nodeNames ?? []); + // Longest-first so a node name that contains another node's full name + // as a substring can't be half-replaced by the shorter match. + const fullNames = [...map.keys()].sort((a, b) => b.length - a.length); + return { map, fullNames }; + }, [nodeNames]); + + if (!active || !payload || payload.length === 0) { return null; } + + const displayName = (raw: string): string => { + let out = raw; + for (const full of shortLabels.fullNames) { + const short = shortLabels.map.get(full)!; + if (short !== full && out.includes(full)) { out = out.split(full).join(short); } + } + return out; + }; + + const entryFormat = (entry: ChartTooltipEntry): ChartTooltipEntryFormat => + resolveEntryFormat?.(entry) ?? { formatter, unitSuffix }; + + const numericValues = payload.map((p) => (typeof p.value === 'number' ? p.value : null)); + const total = numericValues.reduce((s, v) => s + (v ?? 0), 0); + // count-si rounds at tick level; use raw 'count' for the Total so the sum + // keeps its precision (ported from StackedAreaTooltip). + const totalFormatter: ValueFormatter | undefined = formatter === 'count-si' ? 'count' : formatter; + + return ( +
+
+ {label !== undefined ? formatTooltipTime(Number(label)) : ''} +
+ {payload.map((p, i) => { + const fmt = entryFormat(p); + return ( +
+ {displayName(String(p.name ?? p.dataKey ?? ''))} + {formatValue(numericValues[i], fmt.formatter, fmt.unitSuffix)} +
+ ); + })} + {showTotal && payload.length > 1 + ? ( +
+ Total + {formatValue(total, totalFormatter, unitSuffix)} +
+ ) + : null} +
+ ); +} diff --git a/src/features/instance/status/analytics/primitives/LineChart.tsx b/src/features/instance/status/analytics/primitives/LineChart.tsx index 14b82dc9e..5409612f8 100644 --- a/src/features/instance/status/analytics/primitives/LineChart.tsx +++ b/src/features/instance/status/analytics/primitives/LineChart.tsx @@ -13,9 +13,9 @@ import { import { useChartSyncProps } from '../context/AnalyticsContext'; import { NODE_PALETTE } from '../lib/nodeColors'; import { getChartColors } from '../lib/theme'; -import { formatAxisTick, formatTooltipTime } from '../lib/time'; +import { formatAxisTick } from '../lib/time'; import type { AxisSpec, SeriesData, Threshold } from '../types/analytics'; -import { tooltipContentStyle, tooltipItemStyle, tooltipLabelStyle } from './tooltipStyle'; +import { ChartTooltip, type ChartTooltipEntry } from './ChartTooltip'; interface Props { data: SeriesData; @@ -90,6 +90,10 @@ export function LineChart( const chartColors = getChartColors(); + // 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 (
formatTooltipTime(Number(label))} - formatter={(val, name) => { - const nameStr = String(name); - const series = data.series.find((s) => s.label === nameStr || s.key === nameStr); - const axisSpec = series?.axis === 'right' ? rightAxis : leftAxis; - return [formatValue(Number(val), axisSpec?.formatter, axisSpec?.unit), nameStr]; - }} - contentStyle={tooltipContentStyle} - labelStyle={tooltipLabelStyle} - itemStyle={tooltipItemStyle} + content={ + { + const nameStr = String(entry.name ?? ''); + const series = data.series.find((s) => s.label === nameStr || s.key === nameStr); + const axisSpec = series?.axis === 'right' ? rightAxis : leftAxis; + return { formatter: axisSpec?.formatter, unitSuffix: axisSpec?.unit }; + }} + /> + } /> {!hideLegend && } {data.thresholds?.map((t: Threshold, i: number) => { diff --git a/src/features/instance/status/analytics/primitives/StackedAreaChart.tsx b/src/features/instance/status/analytics/primitives/StackedAreaChart.tsx index 9cae7fd06..2afab301d 100644 --- a/src/features/instance/status/analytics/primitives/StackedAreaChart.tsx +++ b/src/features/instance/status/analytics/primitives/StackedAreaChart.tsx @@ -5,10 +5,10 @@ import { Area, AreaChart, CartesianGrid, Legend, Line, ResponsiveContainer, Tool import { useChartSyncProps } from '../context/AnalyticsContext'; import { NODE_PALETTE } from '../lib/nodeColors'; import { getChartColors } from '../lib/theme'; -import { formatAxisTick, formatTooltipTime } from '../lib/time'; -import type { AxisFormatter, AxisSpec, SeriesData } from '../types/analytics'; +import { formatAxisTick } from '../lib/time'; +import type { AxisSpec, SeriesData } from '../types/analytics'; +import { ChartTooltip } from './ChartTooltip'; import { sortByMagnitude } from './sortByMagnitude'; -import { tooltipContentStyle, tooltipLabelStyle } from './tooltipStyle'; interface Props { data: SeriesData; @@ -39,59 +39,6 @@ function composeAriaLabel(data: SeriesData): string { }`; } -interface TooltipPayloadEntry { - dataKey: string; - name: string; - value: number | null; - color: string; -} - -interface StackedAreaTooltipProps { - active?: boolean; - payload?: readonly TooltipPayloadEntry[]; - label?: number; - formatter?: AxisFormatter; - unitSuffix?: string; -} - -export function StackedAreaTooltip({ active, payload, label, formatter, unitSuffix }: StackedAreaTooltipProps) { - if (!active || !payload || payload.length === 0) { return null; } - const total = payload.reduce((s, p) => s + (typeof p.value === 'number' ? p.value : 0), 0); - // count-si rounds at tick level; use raw 'count' for tooltip total to preserve precision. - const totalFormatter: AxisFormatter | undefined = formatter === 'count-si' ? 'count' : formatter; - return ( -
-
- {label !== undefined ? formatTooltipTime(Number(label)) : ''} -
- {payload.map((p) => ( -
- {p.name} - {formatValue(typeof p.value === 'number' ? p.value : 0, formatter, unitSuffix)} -
- ))} - {payload.length > 1 - ? ( -
- Total - {formatValue(total, totalFormatter, unitSuffix)} -
- ) - : null} -
- ); -} - export function StackedAreaChart( { data, yAxis, height = 240, ariaLabel, xDomain, fillParent, lineOnly }: Props, ) { @@ -155,6 +102,10 @@ 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 (
- } /> + + } + /> {sortedSeries.map((s, idx) => ( Date: Wed, 15 Jul 2026 09:35:46 -0600 Subject: [PATCH 29/92] fix(status): tooltip only on the hovered chart; disable hover animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recharts' syncId renders every synced chart's full tooltip and animates it on each mouse-move, so a Status tab with stacked panels showed a screenful of jerking tooltips. Two-part fix: - isAnimationActive={false} on the chart Tooltips kills the move easing. - A per-chart hover gate (useTooltipGate: onMouseEnter/onMouseLeave on the chart container) renders the shared ChartTooltip as null on charts not under the pointer. Recharts still draws the Tooltip cursor when content returns null, so non-hovered synced charts keep the crosshair line — verified end-to-end with two synced real-Recharts charts in happy-dom (tooltip-gating.test.tsx) plus prop-capture tests for the charts whose unconditional Legend defeats plot-area layout under the test shim. Fixes #1513 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UZEaYdKFXuQw2Hf6XpPdEV --- .../primitives/chart-tooltip.test.tsx | 8 ++ .../primitives/tooltip-gating-props.test.tsx | 126 ++++++++++++++++++ .../primitives/tooltip-gating.test.tsx | 83 ++++++++++++ .../analytics/charts/TableSizeTrend.tsx | 14 +- .../analytics/primitives/ChartTooltip.tsx | 53 +++++++- .../status/analytics/primitives/LineChart.tsx | 10 +- .../analytics/primitives/StackedAreaChart.tsx | 10 +- 7 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 src/features/instance/status/analytics/__tests__/primitives/tooltip-gating-props.test.tsx create mode 100644 src/features/instance/status/analytics/__tests__/primitives/tooltip-gating.test.tsx diff --git a/src/features/instance/status/analytics/__tests__/primitives/chart-tooltip.test.tsx b/src/features/instance/status/analytics/__tests__/primitives/chart-tooltip.test.tsx index 7666fc8ee..2bc6028dc 100644 --- a/src/features/instance/status/analytics/__tests__/primitives/chart-tooltip.test.tsx +++ b/src/features/instance/status/analytics/__tests__/primitives/chart-tooltip.test.tsx @@ -11,6 +11,14 @@ describe('ChartTooltip (shared cartesian chart tooltip)', () => { expect(container.firstChild).toBe(null); }); + it('returns null when hidden, even while Recharts reports active (sync gating)', () => { + const payload = [{ dataKey: 'a', name: 'A', value: 1, color: '#f00' }]; + const { container } = render( +
-
+
@@ -181,7 +184,12 @@ export function TableSizeTrend({ domain={viewMode === 'per-node' ? [1, 'auto'] : ['auto', 'auto']} allowDataOverflow /> - } /> +