diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 8230aa9b8..41d72e28b 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -145,7 +145,7 @@ work extends shared components instead of creating new local variants. 5. Keep shared platform-connections shell state on the reusable settings boundary: `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`, `frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`, and `frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx` must continue to derive provider counts, availability, and shared subtab copy from one infrastructure-settings source instead of creating provider-local summary fetches or VMware-only shell vocabulary. 6. Keep shared storage feature presenters on canonical platform truth. When reusable storage presenters under `frontend-modern/src/features/storageBackups/` classify canonical resources for the shared storage route, API-backed virtualization datastores such as VMware must stay inventory-only datastores instead of inheriting PBS-specific backup-repository or protected-target copy from older fallback branches. 7. Keep top-of-page summary interaction on shared primitives. Infrastructure, workloads, and storage summary cards must route sticky-shell behavior through `frontend-modern/src/components/shared/StickySummarySection.tsx` and route row-hover or focused-series rendering through shared chart primitives such as `frontend-modern/src/components/shared/InteractiveSparkline.tsx` and `frontend-modern/src/components/shared/DensityMap.tsx`, rather than page-local sticky wrappers or metric-card-specific hover logic. -8. Keep summary chart interaction identity on one shared helper. Summary surfaces that expose hover- or focus-driven chart emphasis must derive the active series ID through `frontend-modern/src/components/shared/summaryCardInteraction.ts` and pass the same resolved ID into card-state, sparkline, and density-map primitives, rather than letting cards read `hovered || focused` while charts listen to a different page-local ID source. +8. Keep summary chart interaction identity on one shared helper. Summary surfaces that expose row-hover, chart-hover, or route-focus-driven chart emphasis must derive the active series ID through `frontend-modern/src/components/shared/summaryCardInteraction.ts` and pass the same resolved ID into card-state, sparkline, and density-map primitives, rather than letting cards read `hovered || focused` while charts listen to a different page-local ID source. Hovering one summary chart must promote that series into the shared active entity so sibling cards highlight the same object instead of keeping chart-local hover islands. 9. Keep page summaries page-scoped when table rows enter contextual focus. Route-backed row selection may add a focused label and shared series emphasis, but infrastructure, workloads, and storage summary cards must continue to render the page-level series set instead of collapsing the summary down to the selected row or replacing the global trend view with row-local empty states. 10. Keep contextual row focus on the shared summary primitive. Summary surfaces and same-route table drill-ins must reuse `frontend-modern/src/components/shared/contextualFocus.ts` for interactive-series filtering, focused-name lookup, active-series derivation, and local scroll preservation instead of rebuilding page-local `Set` filters, focused-label scans, or ad hoc scroll restoration in each surface. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 24b6f6a92..8811f2e92 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -196,10 +196,13 @@ are now part of the protected performance surface rather than proof-only context. Future hot-path filter/group/sort/windowing changes must route through the explicit dashboard performance proof policy in the subsystem registry. That same hot-path ownership now covers top-of-page summary emphasis: infrastructure -and workloads summary cards must treat hover and focus as one shared active-series -contract so time-range switches and row scrubbing reuse one existing chart path -instead of repainting page-local “selected row” overlays on top of already -downsampled summary history. +and workloads summary cards must treat row hover, chart hover, and route focus +as one shared active-series contract so time-range switches, row scrubbing, and +chart cross-highlighting reuse one existing chart path instead of repainting +page-local “selected row” overlays on top of already downsampled summary +history. Hovering a sparkline or density map for one entity must promote that +entity into the shared active series so sibling cards highlight the same object +at once rather than maintaining chart-local hover state. For shared line charts on that hot path, the shared sparkline primitive may isolate the selected series inside the existing render budget, but that isolation must still reuse the same summary series set and timeline data rather diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 71defed87..201480055 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -222,6 +222,11 @@ the shared summary contract so pool usage, used capacity, and available space cards can isolate the active row through the shared sparkline primitive while non-matching cards such as disk temperature demote to inactive context instead of rebuilding a row-local summary surface. +That same shared summary contract now also owns chart-driven emphasis. +Hovering one storage summary chart must promote the same canonical metrics +target ID through sibling cards, so pool charts cross-highlight the same pool +while non-matching cards such as disk temperature demote to inactive context +instead of keeping chart-local hover state. That same storage summary contract now uses the shared contextual-focus owner. `frontend-modern/src/components/Storage/StorageSummary.tsx` must route interactive-series filtering, focused-label lookup, and active-series diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 2fc499d93..4a7d28092 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -174,10 +174,10 @@ assembly branch. canonical `agent`, `vm`, and `storage` resources instead of creating provider-only resource kinds, identities, or history schemas. 9. Keep summary-surface emphasis on canonical resource IDs. Infrastructure - summary hover/focus behavior must keep using the same unified-resource IDs - that power the table rows, chart series, and detail-route handoffs instead - of introducing page-local summary IDs or provider-local hover aliases when - the selected series is highlighted. + summary row-hover, chart-hover, and route-focus behavior must keep using the + same unified-resource IDs that power the table rows, chart series, and + detail-route handoffs instead of introducing page-local summary IDs or + provider-local hover aliases when the selected series is highlighted. 10. Keep infrastructure contextual focus route-backed and page-scoped. When an infrastructure row opens its detail drawer, the selection must stay on the same route through canonical resource query state, preserve scroll via the diff --git a/frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx b/frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx index 325a22442..947016673 100644 --- a/frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx +++ b/frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx @@ -59,8 +59,11 @@ export const InfrastructureSummary: Component = (pro activeSeriesDisplay="isolate" yMode="percent" highlightNearestSeriesOnHover + hoverSourceKey="cpu" + hoverSync={state.chartHoverSync()} highlightSeriesId={state.activeSeriesId()} interactionState={state.interactionStateFor(state.seriesFor('cpu'))} + onHoverSyncChange={state.setChartHoverSync} /> @@ -79,8 +82,11 @@ export const InfrastructureSummary: Component = (pro activeSeriesDisplay="isolate" yMode="percent" highlightNearestSeriesOnHover + hoverSourceKey="memory" + hoverSync={state.chartHoverSync()} highlightSeriesId={state.activeSeriesId()} interactionState={state.interactionStateFor(state.seriesFor('memory'))} + onHoverSyncChange={state.setChartHoverSync} /> @@ -106,8 +112,11 @@ export const InfrastructureSummary: Component = (pro rangeLabel={rangeLabel()} timeRange={props.timeRange} formatValue={formatThroughputRate} + hoverSourceKey="diskio" + hoverSync={state.chartHoverSync()} highlightSeriesId={state.activeSeriesId()} interactionState={state.interactionStateFor(state.diskioSeries())} + onHoverSyncChange={state.setChartHoverSync} /> @@ -178,8 +187,11 @@ export const InfrastructureSummary: Component = (pro rangeLabel={rangeLabel()} timeRange={props.timeRange} formatValue={formatThroughputRate} + hoverSourceKey="network" + hoverSync={state.chartHoverSync()} highlightSeriesId={state.activeSeriesId()} interactionState={state.interactionStateFor(state.networkSeries())} + onHoverSyncChange={state.setChartHoverSync} /> diff --git a/frontend-modern/src/components/Infrastructure/__tests__/infrastructureSummaryModel.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/infrastructureSummaryModel.test.ts index c479aad7d..c2d14ceb8 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/infrastructureSummaryModel.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/infrastructureSummaryModel.test.ts @@ -173,6 +173,13 @@ describe('infrastructureSummaryModel', () => { }); it('uses one canonical active series id across hover and focused summary selection', () => { + expect( + resolveSummaryActiveSeriesId({ + chartHoveredSeriesId: 'agent-3', + hoveredSeriesId: 'agent-1', + focusedSeriesId: 'agent-2', + }), + ).toBe('agent-3'); expect( resolveSummaryActiveSeriesId({ hoveredSeriesId: 'agent-1', diff --git a/frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts b/frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts index 1f6f110a5..86642ccb7 100644 --- a/frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts +++ b/frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts @@ -1,7 +1,10 @@ import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js'; import type { ChartData, TimeRange } from '@/api/charts'; import { useResources } from '@/hooks/useResources'; -import { useSummaryContextualFocusState } from '@/components/shared/contextualFocus'; +import { + useSummaryContextualFocusState, + type SummaryChartHoverSync, +} from '@/components/shared/contextualFocus'; import { fetchInfrastructureSummaryAndCache, readInfrastructureSummaryCache, @@ -59,6 +62,7 @@ export function useInfrastructureSummaryState(props: InfrastructureSummaryProps) const [loadedRange, setLoadedRange] = createSignal(null); const [oldestDataTimestamp, setOldestDataTimestamp] = createSignal(null); const [fetchFailed, setFetchFailed] = createSignal(false); + const [chartHoverSync, setChartHoverSync] = createSignal(null); const selectedRange = createMemo(() => props.timeRange || '1h'); const hasCurrentRangeCharts = createMemo(() => chartRange() === selectedRange()); const isCurrentRangeLoaded = createMemo(() => loadedRange() === selectedRange()); @@ -229,6 +233,7 @@ export function useInfrastructureSummaryState(props: InfrastructureSummaryProps) : buildInfrastructureSummarySeries(props.resources, new Map(), agentResources()), ); const summaryFocus = useSummaryContextualFocusState({ + chartHoveredSeriesId: () => chartHoverSync()?.seriesId ?? null, interactiveSeries: resourceSeries, hoveredSeriesId: () => props.hoveredResourceId, focusedSeriesId: () => props.focusedResourceId, @@ -244,7 +249,15 @@ export function useInfrastructureSummaryState(props: InfrastructureSummaryProps) }); const focusedResourceName = createMemo(() => { - return summaryFocus.getFocusedSeriesName(resourceSeries()); + return summaryFocus.getActiveSeriesName(resourceSeries()); + }); + + createEffect(() => { + const hovered = chartHoverSync(); + if (!hovered) return; + if (!summaryFocus.hasInteractiveSeriesId(hovered.seriesId)) { + setChartHoverSync(null); + } }); const singleDisplayedOnlineResource = createMemo(() => { @@ -291,10 +304,12 @@ export function useInfrastructureSummaryState(props: InfrastructureSummaryProps) return { selectedRange, + chartHoverSync, isCurrentRangeLoaded, emptyHistoryLabel, emptyMessage, activeSeriesId: summaryFocus.activeSeriesId, + setChartHoverSync, effectiveFocusedResourceId: summaryFocus.effectiveFocusedSeriesId, focusedResourceName, hasInteractiveResourceId: summaryFocus.hasInteractiveSeriesId, diff --git a/frontend-modern/src/components/Storage/StorageSummary.tsx b/frontend-modern/src/components/Storage/StorageSummary.tsx index 4a4420a46..6b55a9667 100644 --- a/frontend-modern/src/components/Storage/StorageSummary.tsx +++ b/frontend-modern/src/components/Storage/StorageSummary.tsx @@ -1,7 +1,10 @@ import { Component, Show, createEffect, createMemo, createSignal, onCleanup } from 'solid-js'; import { InteractiveSparkline } from '@/components/shared/InteractiveSparkline'; import type { InteractiveSparklineSeries } from '@/components/shared/InteractiveSparkline'; -import { useSummaryContextualFocusState } from '@/components/shared/contextualFocus'; +import { + useSummaryContextualFocusState, + type SummaryChartHoverSync, +} from '@/components/shared/contextualFocus'; import { SummaryPanel } from '@/components/shared/SummaryPanel'; import { SummaryMetricCard } from '@/components/shared/SummaryMetricCard'; import { @@ -64,6 +67,7 @@ const StorageSummary: Component = (props) => { const [data, setData] = createSignal(null); const [loaded, setLoaded] = createSignal(false); const [fetchFailed, setFetchFailed] = createSignal(false); + const [chartHoverSync, setChartHoverSync] = createSignal(null); // Track org switches so the effect re-runs when the org changes. const [orgVersion, setOrgVersion] = createSignal(0); @@ -230,11 +234,20 @@ const StorageSummary: Component = (props) => { ...diskTempSeries(), ]); const summaryFocus = useSummaryContextualFocusState({ + chartHoveredSeriesId: () => chartHoverSync()?.seriesId ?? null, interactiveSeries: interactiveSummarySeries, hoveredSeriesId: () => props.hoveredResourceId, focusedSeriesId: () => props.focusedResourceId, }); + createEffect(() => { + const hovered = chartHoverSync(); + if (!hovered) return; + if (!summaryFocus.hasInteractiveSeriesId(hovered.seriesId)) { + setChartHoverSync(null); + } + }); + const hasPoolUsage = () => poolUsageSeries().length > 0; const hasDiskTemp = () => diskTempSeries().length > 0; const hasPoolUsed = () => poolUsedSeries().length > 0; @@ -248,7 +261,7 @@ const StorageSummary: Component = (props) => { const showComponent = () => props.poolCount > 0 || props.diskCount > 0; const getFocusedSeriesName = (series: InteractiveSparklineSeries[]): string | null => - summaryFocus.getFocusedSeriesName(series); + summaryFocus.getActiveSeriesName(series); const focusedLabel = (series: InteractiveSparklineSeries[]) => { const name = getFocusedSeriesName(series); if (!name) return undefined; @@ -290,8 +303,11 @@ const StorageSummary: Component = (props) => { activeSeriesDisplay="isolate" yMode="percent" highlightNearestSeriesOnHover + hoverSourceKey="pool-usage" + hoverSync={chartHoverSync()} highlightSeriesId={summaryFocus.activeSeriesId()} interactionState={summaryFocus.interactionStateFor(poolUsageSeries())} + onHoverSyncChange={setChartHoverSync} /> @@ -312,8 +328,11 @@ const StorageSummary: Component = (props) => { formatValue={formatTemp} formatTopLabel={(max) => `${max.toFixed(0)}°C`} highlightNearestSeriesOnHover + hoverSourceKey="disk-temperature" + hoverSync={chartHoverSync()} highlightSeriesId={summaryFocus.activeSeriesId()} interactionState={summaryFocus.interactionStateFor(diskTempSeries())} + onHoverSyncChange={setChartHoverSync} /> @@ -334,8 +353,11 @@ const StorageSummary: Component = (props) => { formatValue={(v) => formatBytes(v)} formatTopLabel={(max) => formatBytes(max)} highlightNearestSeriesOnHover + hoverSourceKey="used-capacity" + hoverSync={chartHoverSync()} highlightSeriesId={summaryFocus.activeSeriesId()} interactionState={summaryFocus.interactionStateFor(poolUsedSeries())} + onHoverSyncChange={setChartHoverSync} /> @@ -356,8 +378,11 @@ const StorageSummary: Component = (props) => { formatValue={(v) => formatBytes(v)} formatTopLabel={(max) => formatBytes(max)} highlightNearestSeriesOnHover + hoverSourceKey="available-space" + hoverSync={chartHoverSync()} highlightSeriesId={summaryFocus.activeSeriesId()} interactionState={summaryFocus.interactionStateFor(poolAvailSeries())} + onHoverSyncChange={setChartHoverSync} /> diff --git a/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx b/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx index f093be05a..dc08971e3 100644 --- a/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx @@ -467,6 +467,51 @@ describe('Storage', () => { expect(summary.querySelectorAll('[data-summary-card-state="inactive"]').length).toBe(0); }); + const poolUsageChart = screen + .getByText('Pool Usage') + .closest('[data-summary-card-state]') + ?.querySelector('svg'); + expect(poolUsageChart).not.toBeNull(); + if (!poolUsageChart) { + storageSummarySpy.mockRestore(); + return; + } + + ( + poolUsageChart as unknown as { + getBoundingClientRect: () => DOMRect; + } + ).getBoundingClientRect = () => + ({ + left: 0, + top: 0, + width: 200, + height: 50, + right: 200, + bottom: 50, + x: 0, + y: 0, + toJSON: () => ({}), + }) as unknown as DOMRect; + + fireEvent.mouseMove(poolUsageChart, { clientX: 199, clientY: 26 }); + + await waitFor(() => { + expect( + summary.querySelectorAll( + '[data-highlight-series-active="true"][data-highlight-series-id="pool:alpha"]', + ).length, + ).toBe(3); + expect(summary.querySelectorAll('[data-summary-card-state="inactive"]').length).toBe(1); + }); + + fireEvent.mouseLeave(poolUsageChart); + + await waitFor(() => { + expect(summary.querySelectorAll('[data-highlight-series-active="true"]').length).toBe(0); + expect(summary.querySelectorAll('[data-summary-card-state="inactive"]').length).toBe(0); + }); + storageSummarySpy.mockRestore(); }); diff --git a/frontend-modern/src/components/Storage/__tests__/StorageSummary.test.tsx b/frontend-modern/src/components/Storage/__tests__/StorageSummary.test.tsx index 82052a11e..9b6b1d27e 100644 --- a/frontend-modern/src/components/Storage/__tests__/StorageSummary.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/StorageSummary.test.tsx @@ -30,17 +30,44 @@ vi.mock('@/components/shared/InteractiveSparkline', () => ({ highlightSeriesId?: string | null; interactionState?: string; activeSeriesDisplay?: string; + hoverSourceKey?: string; + hoverSync?: { seriesId: string } | null; + onHoverSyncChange?: (value: { + sourceKey: string; + seriesId: string; + timestamp: number; + } | null) => void; }) => { const series = props.series ?? []; + const triggerHover = () => { + const seriesId = series[0]?.id; + if (!props.onHoverSyncChange || !seriesId || !props.hoverSourceKey) { + return; + } + props.onHoverSyncChange({ + sourceKey: props.hoverSourceKey, + seriesId, + timestamp: Date.now(), + }); + }; return ( -
current.id || '').join('|')} - data-highlight-series-id={props.highlightSeriesId || ''} - data-interaction-state={props.interactionState || 'default'} - data-active-series-display={props.activeSeriesDisplay || ''} - /> + <> +