fix(frontend): sync summary chart hover across cards

This commit is contained in:
rcourtman 2026-04-01 13:09:58 +01:00
parent 5dc644c650
commit b5349716c9
24 changed files with 916 additions and 87 deletions

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -59,8 +59,11 @@ export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (pro
activeSeriesDisplay="isolate"
yMode="percent"
highlightNearestSeriesOnHover
hoverSourceKey="cpu"
hoverSync={state.chartHoverSync()}
highlightSeriesId={state.activeSeriesId()}
interactionState={state.interactionStateFor(state.seriesFor('cpu'))}
onHoverSyncChange={state.setChartHoverSync}
/>
</SummaryMetricCard>
@ -79,8 +82,11 @@ export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (pro
activeSeriesDisplay="isolate"
yMode="percent"
highlightNearestSeriesOnHover
hoverSourceKey="memory"
hoverSync={state.chartHoverSync()}
highlightSeriesId={state.activeSeriesId()}
interactionState={state.interactionStateFor(state.seriesFor('memory'))}
onHoverSyncChange={state.setChartHoverSync}
/>
</SummaryMetricCard>
@ -106,8 +112,11 @@ export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (pro
rangeLabel={rangeLabel()}
timeRange={props.timeRange}
formatValue={formatThroughputRate}
hoverSourceKey="diskio"
hoverSync={state.chartHoverSync()}
highlightSeriesId={state.activeSeriesId()}
interactionState={state.interactionStateFor(state.diskioSeries())}
onHoverSyncChange={state.setChartHoverSync}
/>
</SummaryMetricCard>
@ -178,8 +187,11 @@ export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (pro
rangeLabel={rangeLabel()}
timeRange={props.timeRange}
formatValue={formatThroughputRate}
hoverSourceKey="network"
hoverSync={state.chartHoverSync()}
highlightSeriesId={state.activeSeriesId()}
interactionState={state.interactionStateFor(state.networkSeries())}
onHoverSyncChange={state.setChartHoverSync}
/>
</SummaryMetricCard>
</Show>

View file

@ -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',

View file

@ -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<TimeRange | null>(null);
const [oldestDataTimestamp, setOldestDataTimestamp] = createSignal<number | null>(null);
const [fetchFailed, setFetchFailed] = createSignal(false);
const [chartHoverSync, setChartHoverSync] = createSignal<SummaryChartHoverSync | null>(null);
const selectedRange = createMemo<TimeRange>(() => 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,

View file

@ -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<StorageSummaryProps> = (props) => {
const [data, setData] = createSignal<StorageSummaryChartsResponse | null>(null);
const [loaded, setLoaded] = createSignal(false);
const [fetchFailed, setFetchFailed] = createSignal(false);
const [chartHoverSync, setChartHoverSync] = createSignal<SummaryChartHoverSync | null>(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<StorageSummaryProps> = (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<StorageSummaryProps> = (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<StorageSummaryProps> = (props) => {
activeSeriesDisplay="isolate"
yMode="percent"
highlightNearestSeriesOnHover
hoverSourceKey="pool-usage"
hoverSync={chartHoverSync()}
highlightSeriesId={summaryFocus.activeSeriesId()}
interactionState={summaryFocus.interactionStateFor(poolUsageSeries())}
onHoverSyncChange={setChartHoverSync}
/>
</SummaryMetricCard>
@ -312,8 +328,11 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
formatValue={formatTemp}
formatTopLabel={(max) => `${max.toFixed(0)}°C`}
highlightNearestSeriesOnHover
hoverSourceKey="disk-temperature"
hoverSync={chartHoverSync()}
highlightSeriesId={summaryFocus.activeSeriesId()}
interactionState={summaryFocus.interactionStateFor(diskTempSeries())}
onHoverSyncChange={setChartHoverSync}
/>
</SummaryMetricCard>
@ -334,8 +353,11 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
formatValue={(v) => formatBytes(v)}
formatTopLabel={(max) => formatBytes(max)}
highlightNearestSeriesOnHover
hoverSourceKey="used-capacity"
hoverSync={chartHoverSync()}
highlightSeriesId={summaryFocus.activeSeriesId()}
interactionState={summaryFocus.interactionStateFor(poolUsedSeries())}
onHoverSyncChange={setChartHoverSync}
/>
</SummaryMetricCard>
@ -356,8 +378,11 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
formatValue={(v) => formatBytes(v)}
formatTopLabel={(max) => formatBytes(max)}
highlightNearestSeriesOnHover
hoverSourceKey="available-space"
hoverSync={chartHoverSync()}
highlightSeriesId={summaryFocus.activeSeriesId()}
interactionState={summaryFocus.interactionStateFor(poolAvailSeries())}
onHoverSyncChange={setChartHoverSync}
/>
</SummaryMetricCard>
</SummaryPanel>

View file

@ -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();
});

View file

@ -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 (
<div
data-testid="sparkline"
data-series-count={series.length}
data-series-ids={series.map((current) => current.id || '').join('|')}
data-highlight-series-id={props.highlightSeriesId || ''}
data-interaction-state={props.interactionState || 'default'}
data-active-series-display={props.activeSeriesDisplay || ''}
/>
<>
<button
type="button"
data-testid={`chart-hover-${props.hoverSourceKey || 'unknown'}`}
onClick={triggerHover}
/>
<div
data-testid="sparkline"
data-series-count={series.length}
data-series-ids={series.map((current) => current.id || '').join('|')}
data-highlight-series-id={props.highlightSeriesId || ''}
data-hover-source-key={props.hoverSourceKey || ''}
data-hover-sync-series-id={props.hoverSync?.seriesId || ''}
data-interaction-state={props.interactionState || 'default'}
data-active-series-display={props.activeSeriesDisplay || ''}
/>
</>
);
},
}));
@ -97,4 +124,62 @@ describe('StorageSummary', () => {
}
});
});
it('treats chart hover as the shared active storage entity across cards', async () => {
mockGetStorageSummaryCharts.mockResolvedValueOnce({
pools: {
'pool:alpha': {
name: 'Alpha Pool',
usage: twoPointSeries,
used: twoPointSeries,
avail: twoPointSeries,
},
'pool:beta': {
name: 'Beta Pool',
usage: twoPointSeries,
used: twoPointSeries,
avail: twoPointSeries,
},
},
disks: {
'disk:serial-1': {
name: 'Disk 1',
temperature: twoPointSeries,
},
},
stats: {
oldestDataTimestamp: now - 60_000,
},
});
render(() => <StorageSummary poolCount={2} diskCount={1} timeRange="1h" />);
await waitFor(() => {
expect(screen.getAllByTestId('sparkline')).toHaveLength(4);
});
screen.getByTestId('chart-hover-pool-usage').click();
await waitFor(() => {
const sparklines = screen.getAllByTestId('sparkline');
const poolCards = sparklines.filter((sparkline) =>
['pool-usage', 'used-capacity', 'available-space'].includes(
sparkline.getAttribute('data-hover-source-key') || '',
),
);
const diskTempCard = sparklines.find(
(sparkline) => sparkline.getAttribute('data-hover-source-key') === 'disk-temperature',
);
expect(poolCards).toHaveLength(3);
for (const sparkline of sparklines) {
expect(sparkline.getAttribute('data-highlight-series-id')).toBe('pool:alpha');
expect(sparkline.getAttribute('data-hover-sync-series-id')).toBe('pool:alpha');
}
for (const sparkline of poolCards) {
expect(sparkline.getAttribute('data-interaction-state')).toBe('active');
}
expect(diskTempCard?.getAttribute('data-interaction-state')).toBe('inactive');
});
});
});

View file

@ -26,20 +26,47 @@ 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 maxPoints = series.reduce((max, current) => Math.max(max, current.data?.length ?? 0), 0);
const triggerHover = () => {
const seriesId = series[0]?.id;
if (!props.onHoverSyncChange || !seriesId || !props.hoverSourceKey) {
return;
}
props.onHoverSyncChange({
sourceKey: props.hoverSourceKey,
seriesId,
timestamp: Date.now(),
});
};
return (
<div
data-testid="sparkline"
data-series-count={series.length}
data-series-ids={series.map((current) => current.id || '').join('|')}
data-series-names={series.map((current) => current.name || '').join('|')}
data-max-points={maxPoints}
data-highlight-series-id={props.highlightSeriesId || ''}
data-interaction-state={props.interactionState || 'default'}
data-active-series-display={props.activeSeriesDisplay || ''}
/>
<>
<button
type="button"
data-testid={`chart-hover-${props.hoverSourceKey || 'unknown'}`}
onClick={triggerHover}
/>
<div
data-testid="sparkline"
data-series-count={series.length}
data-series-ids={series.map((current) => current.id || '').join('|')}
data-series-names={series.map((current) => current.name || '').join('|')}
data-max-points={maxPoints}
data-highlight-series-id={props.highlightSeriesId || ''}
data-hover-source-key={props.hoverSourceKey || ''}
data-hover-sync-series-id={props.hoverSync?.seriesId || ''}
data-interaction-state={props.interactionState || 'default'}
data-active-series-display={props.activeSeriesDisplay || ''}
/>
</>
);
},
}));
@ -49,19 +76,46 @@ vi.mock('@/components/shared/DensityMap', () => ({
series?: Array<{ id?: string; name?: string; data?: Array<unknown> }>;
highlightSeriesId?: string | null;
interactionState?: string;
hoverSourceKey?: string;
hoverSync?: { seriesId: string } | null;
onHoverSyncChange?: (value: {
sourceKey: string;
seriesId: string;
timestamp: number;
} | null) => void;
}) => {
const series = props.series ?? [];
const maxPoints = series.reduce((max, current) => Math.max(max, current.data?.length ?? 0), 0);
const triggerHover = () => {
const seriesId = series[0]?.id;
if (!props.onHoverSyncChange || !seriesId || !props.hoverSourceKey) {
return;
}
props.onHoverSyncChange({
sourceKey: props.hoverSourceKey,
seriesId,
timestamp: Date.now(),
});
};
return (
<div
data-testid="sparkline"
data-series-count={series.length}
data-series-ids={series.map((current) => current.id || '').join('|')}
data-series-names={series.map((current) => current.name || '').join('|')}
data-max-points={maxPoints}
data-highlight-series-id={props.highlightSeriesId || ''}
data-interaction-state={props.interactionState || 'default'}
/>
<>
<button
type="button"
data-testid={`chart-hover-${props.hoverSourceKey || 'unknown'}`}
onClick={triggerHover}
/>
<div
data-testid="sparkline"
data-series-count={series.length}
data-series-ids={series.map((current) => current.id || '').join('|')}
data-series-names={series.map((current) => current.name || '').join('|')}
data-max-points={maxPoints}
data-highlight-series-id={props.highlightSeriesId || ''}
data-hover-source-key={props.hoverSourceKey || ''}
data-hover-sync-series-id={props.hoverSync?.seriesId || ''}
data-interaction-state={props.interactionState || 'default'}
/>
</>
);
},
}));
@ -314,6 +368,35 @@ describe('WorkloadsSummary performance behavior', () => {
});
});
it('promotes chart hover into shared summary focus across all workload cards', async () => {
const workloadIds = ['cluster-a:pve1:101', 'cluster-a:pve1:102'];
mockGetWorkloadCharts.mockResolvedValueOnce(makeChartsResponse(workloadIds));
render(() => (
<WorkloadsSummary
timeRange="1h"
fallbackGuestCounts={{ total: workloadIds.length, running: workloadIds.length, stopped: 0 }}
fallbackSnapshots={makeSnapshots(workloadIds)}
/>
));
await waitFor(() => {
expect(screen.getAllByTestId('sparkline')).toHaveLength(4);
});
screen.getByTestId('chart-hover-cpu').click();
await waitFor(() => {
const sparklines = screen.getAllByTestId('sparkline');
expect(sparklines).toHaveLength(4);
for (const sparkline of sparklines) {
expect(sparkline.getAttribute('data-highlight-series-id')).toBe(workloadIds[0]);
expect(sparkline.getAttribute('data-hover-sync-series-id')).toBe(workloadIds[0]);
expect(sparkline.getAttribute('data-interaction-state')).toBe('active');
}
});
});
it('ignores focused workload ids that do not have interactive history', async () => {
const interactiveWorkloadId = 'cluster-a:pve1:101';
const nonInteractiveWorkloadId = 'cluster-a:pve1:102';

View file

@ -3,7 +3,10 @@ import {
InteractiveSparkline,
type InteractiveSparklineSeries,
} from '@/components/shared/InteractiveSparkline';
import { useSummaryContextualFocusState } from '@/components/shared/contextualFocus';
import {
useSummaryContextualFocusState,
type SummaryChartHoverSync,
} from '@/components/shared/contextualFocus';
import { DensityMap } from '@/components/shared/DensityMap';
import { SummaryPanel } from '@/components/shared/SummaryPanel';
import { SummaryMetricCard } from '@/components/shared/SummaryMetricCard';
@ -405,6 +408,7 @@ export const WorkloadsSummary: Component<WorkloadsSummaryProps> = (props) => {
const [loadedScopeKey, setLoadedScopeKey] = createSignal<string | null>(null);
const [fetchFailed, setFetchFailed] = createSignal(false);
const [orgScope, setOrgScope] = createSignal(normalizeOrgScope(getOrgID()));
const [chartHoverSync, setChartHoverSync] = createSignal<SummaryChartHoverSync | null>(null);
const selectedRange = createMemo<TimeRange>(() => props.timeRange || '1h');
const selectedNodeScope = createMemo(() => props.selectedNodeId?.trim() || '');
const activeScopeKey = createMemo(
@ -648,6 +652,7 @@ export const WorkloadsSummary: Component<WorkloadsSummaryProps> = (props) => {
const displaySeries = createMemo(() => workloadSeries());
const summaryFocus = useSummaryContextualFocusState({
chartHoveredSeriesId: () => chartHoverSync()?.seriesId ?? null,
interactiveSeries: workloadSeries,
hoveredSeriesId: () => props.hoveredWorkloadId,
focusedSeriesId: () => props.focusedWorkloadId,
@ -658,7 +663,15 @@ export const WorkloadsSummary: Component<WorkloadsSummaryProps> = (props) => {
series.network.length >= 2,
});
const focusedWorkloadName = createMemo(() => summaryFocus.getFocusedSeriesName(workloadSeries()));
createEffect(() => {
const hovered = chartHoverSync();
if (!hovered) return;
if (!summaryFocus.hasInteractiveSeriesId(hovered.seriesId)) {
setChartHoverSync(null);
}
});
const focusedWorkloadName = createMemo(() => summaryFocus.getActiveSeriesName(workloadSeries()));
const cpuSeries = createMemo<InteractiveSparklineSeries[]>(() =>
displaySeries().map((series) => ({
@ -752,8 +765,11 @@ export const WorkloadsSummary: Component<WorkloadsSummaryProps> = (props) => {
sortTooltipByValue
maxTooltipRows={8}
highlightNearestSeriesOnHover
hoverSourceKey="cpu"
hoverSync={chartHoverSync()}
highlightSeriesId={summaryFocus.activeSeriesId()}
interactionState={summaryFocus.interactionStateFor(cpuSeries())}
onHoverSyncChange={setChartHoverSync}
/>
</SummaryMetricCard>
@ -774,8 +790,11 @@ export const WorkloadsSummary: Component<WorkloadsSummaryProps> = (props) => {
sortTooltipByValue
maxTooltipRows={8}
highlightNearestSeriesOnHover
hoverSourceKey="memory"
hoverSync={chartHoverSync()}
highlightSeriesId={summaryFocus.activeSeriesId()}
interactionState={summaryFocus.interactionStateFor(memorySeries())}
onHoverSyncChange={setChartHoverSync}
/>
</SummaryMetricCard>
@ -792,8 +811,11 @@ export const WorkloadsSummary: Component<WorkloadsSummaryProps> = (props) => {
rangeLabel={selectedRange()}
timeRange={selectedRange()}
formatValue={formatThroughputRate}
hoverSourceKey="diskio"
hoverSync={chartHoverSync()}
highlightSeriesId={summaryFocus.activeSeriesId()}
interactionState={summaryFocus.interactionStateFor(diskioSeries())}
onHoverSyncChange={setChartHoverSync}
/>
</SummaryMetricCard>
@ -810,8 +832,11 @@ export const WorkloadsSummary: Component<WorkloadsSummaryProps> = (props) => {
rangeLabel={selectedRange()}
timeRange={selectedRange()}
formatValue={formatThroughputRate}
hoverSourceKey="network"
hoverSync={chartHoverSync()}
highlightSeriesId={summaryFocus.activeSeriesId()}
interactionState={summaryFocus.interactionStateFor(networkSeries())}
onHoverSyncChange={setChartHoverSync}
/>
</SummaryMetricCard>
</SummaryPanel>

View file

@ -118,7 +118,7 @@ export const InteractiveSparkline: Component<InteractiveSparklineProps> = (props
)}
</For>
<Show when={sparkline.hoveredState()}>
<Show when={sparkline.activeHoverState()}>
{(hover) => (
<line
x1={hover().x}

View file

@ -56,6 +56,7 @@ import trialBannerModelSource from '@/components/shared/trialBannerModel.ts?raw'
import interactiveSparklineSource from '@/components/shared/InteractiveSparkline.tsx?raw';
import interactiveSparklineModelSource from '@/components/shared/interactiveSparklineModel.ts?raw';
import contextualFocusSource from '@/components/shared/contextualFocus.ts?raw';
import summaryCardInteractionSource from '@/components/shared/summaryCardInteraction.ts?raw';
import infrastructureSummaryTableSource from '@/components/shared/InfrastructureSummaryTable.tsx?raw';
import infrastructureSummaryTableRowSource from '@/components/shared/InfrastructureSummaryTableRow.tsx?raw';
import infrastructureSelectorModelSource from '@/components/shared/infrastructureSelectorModel.ts?raw';
@ -244,17 +245,24 @@ describe('shared primitive guardrails', () => {
it('keeps contextual row focus on one shared helper across summary consumers', () => {
expect(contextualFocusSource).toContain('export const preserveScrollableAncestorVerticalOffset');
expect(contextualFocusSource).toContain('export function useSummaryContextualFocusState');
expect(contextualFocusSource).toContain('chartHoveredSeriesId');
expect(summaryCardInteractionSource).toContain('chartHoveredSeriesId');
expect(interactiveSparklineModelSource).toContain('onHoverSyncChange');
expect(densityMapModelSource).toContain('onHoverSyncChange');
expect(dashboardSelectionStateSource).toContain('preserveScrollableAncestorVerticalOffset');
expect(dashboardSelectionStateSource).not.toContain('const scrollTop = scroller?.scrollTop');
expect(infrastructureSummaryStateSource).toContain('useSummaryContextualFocusState');
expect(infrastructureSummaryStateSource).toContain('chartHoverSync');
expect(infrastructureSummaryStateSource).not.toContain('const interactiveResourceIds = createMemo');
expect(storageSummarySource).toContain('useSummaryContextualFocusState');
expect(storageSummarySource).toContain('chartHoverSync');
expect(storageSummarySource).not.toContain('resolveSummaryActiveSeriesId');
expect(workloadsSummarySource).toContain('useSummaryContextualFocusState');
expect(workloadsSummarySource).toContain('chartHoverSync');
expect(workloadsSummarySource).not.toContain('const interactiveWorkloadIds = createMemo');
});

View file

@ -86,6 +86,60 @@ describe('DensityMap', () => {
expect(await screen.findByText('CPU')).toBeInTheDocument();
});
it('publishes synchronized hover identity and clears it on leave', () => {
const now = Date.now();
const onHoverSyncChange = vi.fn();
const { container } = render(() => (
<DensityMap
timeRange="1h"
hoverSourceKey="diskio"
onHoverSyncChange={onHoverSyncChange}
series={[
{
id: 'alpha',
name: 'Alpha',
color: '#10b981',
data: [
{ timestamp: now - 30_000, value: 25 },
{ timestamp: now - 10_000, value: 55 },
],
},
]}
/>
));
const canvas = container.querySelector('canvas');
expect(canvas).not.toBeNull();
if (!canvas) return;
(canvas as unknown as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () =>
({
left: 0,
top: 0,
width: 180,
height: 80,
right: 180,
bottom: 80,
x: 0,
y: 0,
toJSON: () => ({}),
}) as unknown as DOMRect;
fireEvent.mouseMove(canvas, { clientX: 160, clientY: 20 });
const publishedHover = onHoverSyncChange.mock.calls.at(-1)?.[0];
expect(publishedHover).toMatchObject({
sourceKey: 'diskio',
seriesId: 'alpha',
});
expect(typeof publishedHover?.timestamp).toBe('number');
expect(publishedHover?.timestamp).toBeGreaterThanOrEqual(now - 3_600_000);
expect(publishedHover?.timestamp).toBeLessThanOrEqual(now);
fireEvent.mouseLeave(canvas);
expect(onHoverSyncChange).toHaveBeenLastCalledWith(null);
});
it('keeps an externally highlighted series visible when it falls outside the default density top set', () => {
const now = Date.now();
const series = Array.from({ length: 24 }, (_, index) => ({

View file

@ -73,6 +73,62 @@ describe('InteractiveSparkline hover behavior', () => {
expect(screen.queryByText('CPU')).toBeNull();
});
it('publishes synchronized hover identity and clears it on leave', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
const now = Date.now();
const onHoverSyncChange = vi.fn();
const { container } = render(() => (
<InteractiveSparkline
timeRange="1h"
hoverSourceKey="cpu"
onHoverSyncChange={onHoverSyncChange}
highlightNearestSeriesOnHover
series={[
{
id: 'alpha',
name: 'Alpha',
color: '#ff0000',
data: [
{ timestamp: now - 30_000, value: 40 },
{ timestamp: now - 10_000, value: 50 },
],
},
]}
/>
));
const svg = container.querySelector('svg');
expect(svg).not.toBeNull();
if (!svg) return;
(svg as unknown as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () =>
({
left: 0,
top: 100,
width: 200,
height: 50,
right: 200,
bottom: 150,
x: 0,
y: 100,
toJSON: () => ({}),
}) as unknown as DOMRect;
fireEvent.mouseMove(svg, { clientX: 199, clientY: 125 });
const publishedHover = onHoverSyncChange.mock.calls.at(-1)?.[0];
expect(publishedHover).toMatchObject({
sourceKey: 'cpu',
seriesId: 'alpha',
});
expect(publishedHover?.timestamp).toBe(now - 10_000);
fireEvent.mouseLeave(svg);
expect(onHoverSyncChange).toHaveBeenLastCalledWith(null);
});
it('limits tooltip rows and shows the "+N more series" affordance', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));

View file

@ -25,6 +25,14 @@ describe('summaryCardInteraction', () => {
});
it('prefers hovered series ids and falls back to focused ids', () => {
expect(
resolveSummaryActiveSeriesId({
chartHoveredSeriesId: 'gamma',
hoveredSeriesId: 'beta',
focusedSeriesId: 'alpha',
}),
).toBe('gamma');
expect(
resolveSummaryActiveSeriesId({
hoveredSeriesId: 'beta',
@ -52,6 +60,7 @@ describe('summaryCardInteraction', () => {
expect(
resolveSummaryCardInteractionState({
series: [{ id: 'alpha' }, { id: 'beta' }],
chartHoveredSeriesId: 'beta',
hoveredSeriesId: 'beta',
focusedSeriesId: 'alpha',
}),
@ -71,9 +80,11 @@ describe('summaryCardInteraction', () => {
it('filters contextual focus down to interactive series ids through one shared hook', () => {
const [hoveredSeriesId] = createSignal<string | null>('gamma');
const [focusedSeriesId] = createSignal<string | null>('alpha');
const [chartHoveredSeriesId, setChartHoveredSeriesId] = createSignal<string | null>(null);
const { result } = renderHook(() =>
useSummaryContextualFocusState({
chartHoveredSeriesId,
interactiveSeries: () => [
{ id: 'alpha', name: 'Alpha', interactive: true },
{ id: 'beta', name: 'Beta', interactive: false },
@ -97,6 +108,13 @@ describe('summaryCardInteraction', () => {
).toBe('Alpha');
expect(result.interactionStateFor([{ id: 'alpha' }])).toBe('active');
expect(result.interactionStateFor([{ id: 'beta' }])).toBe('inactive');
setChartHoveredSeriesId('alpha');
expect(result.effectiveChartHoveredSeriesId()).toBe('alpha');
expect(result.activeSeriesId()).toBe('alpha');
expect(result.getActiveSeriesName([{ id: 'alpha', name: 'Alpha', interactive: true }])).toBe(
'Alpha',
);
});
it('preserves the nearest scrollable ancestor when contextual focus changes locally', () => {

View file

@ -10,7 +10,14 @@ type ContextualFocusSeries = {
name?: string | null;
};
export interface SummaryChartHoverSync {
sourceKey: string;
seriesId: string;
timestamp: number;
}
export interface UseSummaryContextualFocusStateOptions<T extends ContextualFocusSeries> {
chartHoveredSeriesId?: Accessor<string | null | undefined>;
focusedSeriesId?: Accessor<string | null | undefined>;
getSeriesName?: (series: T) => string | null | undefined;
hoveredSeriesId?: Accessor<string | null | undefined>;
@ -62,6 +69,7 @@ export const preserveScrollableAncestorVerticalOffset = (
export function useSummaryContextualFocusState<T extends ContextualFocusSeries>(
options: UseSummaryContextualFocusStateOptions<T>,
) {
const chartHoveredSeriesId = options.chartHoveredSeriesId ?? (() => null);
const hoveredSeriesId = options.hoveredSeriesId ?? (() => null);
const focusedSeriesId = options.focusedSeriesId ?? (() => null);
const getSeriesName =
@ -93,16 +101,23 @@ export function useSummaryContextualFocusState<T extends ContextualFocusSeries>(
return hasInteractiveSeriesId(hoveredSeriesId()) ? normalizeSeriesId(hoveredSeriesId()) : null;
});
const effectiveChartHoveredSeriesId = createMemo<string | null>(() => {
return hasInteractiveSeriesId(chartHoveredSeriesId())
? normalizeSeriesId(chartHoveredSeriesId())
: null;
});
const effectiveFocusedSeriesId = createMemo<string | null>(() => {
return hasInteractiveSeriesId(focusedSeriesId()) ? normalizeSeriesId(focusedSeriesId()) : null;
});
const activeSeriesId = createMemo<string | null>(() =>
resolveSummaryActiveSeriesId({
const activeSeriesId = createMemo<string | null>(() => {
return resolveSummaryActiveSeriesId({
chartHoveredSeriesId: effectiveChartHoveredSeriesId(),
hoveredSeriesId: effectiveHoveredSeriesId(),
focusedSeriesId: effectiveFocusedSeriesId(),
}),
);
});
});
const getFocusedSeriesName = (series: readonly T[]): string | null => {
const focusedId = effectiveFocusedSeriesId();
@ -113,19 +128,31 @@ export function useSummaryContextualFocusState<T extends ContextualFocusSeries>(
return match ? getSeriesName(match) || null : null;
};
const getActiveSeriesName = (series: readonly T[]): string | null => {
const currentActiveId = activeSeriesId();
if (!currentActiveId) {
return null;
}
const match = series.find((entry) => normalizeSeriesId(entry.id) === currentActiveId);
return match ? getSeriesName(match) || null : null;
};
const interactionStateFor = (
series: ReadonlyArray<{ id?: string | null }>,
): SummaryCardInteractionState =>
resolveSummaryCardInteractionState({
series,
chartHoveredSeriesId: effectiveChartHoveredSeriesId(),
hoveredSeriesId: effectiveHoveredSeriesId(),
focusedSeriesId: effectiveFocusedSeriesId(),
});
return {
activeSeriesId,
effectiveChartHoveredSeriesId,
effectiveFocusedSeriesId,
effectiveHoveredSeriesId,
getActiveSeriesName,
getFocusedSeriesName,
hasInteractiveSeriesId,
interactionStateFor,

View file

@ -1,6 +1,7 @@
import type { TimeRange } from '@/api/charts';
import { timeRangeToMs } from '@/utils/timeRange';
import type { InteractiveSparklineSeries } from './InteractiveSparkline';
import type { SummaryChartHoverSync } from './contextualFocus';
import type { SummaryCardInteractionState } from './summaryCardInteraction';
export interface DensityMapProps {
@ -8,11 +9,15 @@ export interface DensityMapProps {
rangeLabel?: string;
timeRange?: TimeRange;
formatValue?: (value: number) => string;
hoverSourceKey?: string;
hoverSync?: SummaryChartHoverSync | null;
onHoverSyncChange?: (value: SummaryChartHoverSync | null) => void;
highlightSeriesId?: string | null;
interactionState?: SummaryCardInteractionState;
}
export interface DensityMapHoveredState {
columnIndex: number;
tooltipX: number;
tooltipY: number;
timestamp: number;
@ -172,6 +177,7 @@ export function buildDensityMapHoveredState(options: {
const cellHeight = rect.height / data.series.length;
return {
columnIndex: column,
tooltipX: rect.left + column * cellWidth + cellWidth / 2,
tooltipY: rect.top + row * cellHeight,
timestamp: data.windowStart + column * data.bucketDuration,
@ -181,3 +187,40 @@ export function buildDensityMapHoveredState(options: {
seriesIndex: row,
};
}
export function buildDensityMapSynchronizedHoveredState(options: {
data: DensityMapChartData;
hoverSync?: SummaryChartHoverSync | null;
}): DensityMapHoveredState | null {
const { data, hoverSync } = options;
if (!hoverSync || data.series.length === 0 || data.rangeMs <= 0) {
return null;
}
const seriesIndex = data.series.findIndex((series) => series.id === hoverSync.seriesId);
if (seriesIndex < 0) {
return null;
}
const clampedTimestamp = clampDensityMapValue(
hoverSync.timestamp,
data.windowStart,
data.windowStart + data.rangeMs,
);
const column = clampDensityMapValue(
Math.floor(((clampedTimestamp - data.windowStart) / data.rangeMs) * DENSITY_MAP_COLUMNS),
0,
DENSITY_MAP_COLUMNS - 1,
);
return {
columnIndex: column,
tooltipX: 0,
tooltipY: 0,
timestamp: clampedTimestamp,
value: data.grid[seriesIndex][column],
seriesName: data.series[seriesIndex].name || 'Unknown',
seriesColor: data.series[seriesIndex].color,
seriesIndex,
};
}

View file

@ -1,6 +1,7 @@
import type { MetricPoint, TimeRange } from '@/api/charts';
import { downsampleLTTB, calculateOptimalPoints } from '@/utils/downsample';
import { timeRangeToMs } from '@/utils/timeRange';
import type { SummaryChartHoverSync } from './contextualFocus';
import type { SummaryCardInteractionState } from './summaryCardInteraction';
export interface InteractiveSparklineSeries {
@ -25,6 +26,9 @@ export interface InteractiveSparklineProps {
maxTooltipRows?: number;
highlightNearestSeriesOnHover?: boolean;
highlightSeriesId?: string | null;
hoverSourceKey?: string;
hoverSync?: SummaryChartHoverSync | null;
onHoverSyncChange?: (value: SummaryChartHoverSync | null) => void;
interactionState?: SummaryCardInteractionState;
}
@ -452,6 +456,64 @@ export const getInteractiveSparklineActiveEmphasisSeriesIndex = ({
return externalSeriesIndex;
};
export const buildInteractiveSparklineSynchronizedHoverState = ({
chartData,
hoverSync,
vbW,
vbH,
yMode,
}: {
chartData: InteractiveSparklineChartData;
hoverSync?: SummaryChartHoverSync | null;
vbW: number;
vbH: number;
yMode: 'percent' | 'auto';
}): InteractiveSparklineHoverState | null => {
if (!hoverSync || chartData.validSeries.length === 0 || chartData.rangeMs <= 0) {
return null;
}
const seriesIndex = chartData.validSeries.findIndex((series) => series.id === hoverSync.seriesId);
if (seriesIndex < 0) {
return null;
}
const clampedTimestamp = clampInteractiveSparklineValue(
hoverSync.timestamp,
chartData.windowStart,
chartData.windowStart + chartData.rangeMs,
);
const series = chartData.validSeries[seriesIndex];
const nearest = findNearestMetricPoint(series.hoverData, clampedTimestamp);
if (!nearest) {
return null;
}
const valueToChartY = createInteractiveSparklineValueToY(yMode, chartData.scaleMax, vbH);
const pointY = valueToChartY(nearest.point.value);
const chartX = ((clampedTimestamp - chartData.windowStart) / chartData.rangeMs) * vbW;
return {
x: chartX,
tooltipX: 0,
tooltipY: 0,
timestamp: clampedTimestamp,
totalValues: 1,
minY: pointY,
nearestSeriesIndex: seriesIndex,
highlightedSeriesIndex: seriesIndex,
focusedTooltip: true,
values: [
{
name: series.name || 'Series',
color: series.color,
value: nearest.point.value,
seriesIndex,
},
],
};
};
export const computeInteractiveSparklineHoverState = ({
chartData,
chartRect,

View file

@ -7,9 +7,15 @@ type SummarySeriesIdentity = {
const normalizeSeriesId = (value: string | null | undefined): string => value?.trim() || '';
export function resolveSummaryActiveSeriesId(options: {
chartHoveredSeriesId?: string | null;
hoveredSeriesId?: string | null;
focusedSeriesId?: string | null;
}): string | null {
const chartHoveredSeriesId = normalizeSeriesId(options.chartHoveredSeriesId);
if (chartHoveredSeriesId) {
return chartHoveredSeriesId;
}
const hoveredSeriesId = normalizeSeriesId(options.hoveredSeriesId);
if (hoveredSeriesId) {
return hoveredSeriesId;
@ -21,10 +27,12 @@ export function resolveSummaryActiveSeriesId(options: {
export function resolveSummaryCardInteractionState(options: {
series: readonly SummarySeriesIdentity[];
chartHoveredSeriesId?: string | null;
hoveredSeriesId?: string | null;
focusedSeriesId?: string | null;
}): SummaryCardInteractionState {
const activeSeriesId = resolveSummaryActiveSeriesId({
chartHoveredSeriesId: options.chartHoveredSeriesId,
hoveredSeriesId: options.hoveredSeriesId,
focusedSeriesId: options.focusedSeriesId,
});

View file

@ -3,6 +3,7 @@ import {
buildDensityMapChartData,
getDensityMapExternalSeriesIndex,
buildDensityMapHoveredState,
buildDensityMapSynchronizedHoveredState,
formatDensityMapValue,
getDensityMapCellOpacity,
DENSITY_MAP_COLUMNS,
@ -26,6 +27,22 @@ export function useDensityMapState(props: DensityMapProps) {
const externalSeriesIndex = createMemo(() =>
getDensityMapExternalSeriesIndex(chartData(), props.highlightSeriesId),
);
const synchronizedHoveredState = createMemo(() => {
const hoverSync = props.hoverSync;
if (!hoverSync) {
return null;
}
if (props.hoverSourceKey && hoverSync.sourceKey === props.hoverSourceKey) {
return null;
}
return buildDensityMapSynchronizedHoveredState({
data: chartData(),
hoverSync,
});
});
const activeHoveredState = createMemo<DensityMapHoveredState | null>(() => {
return hoveredState() ?? synchronizedHoveredState();
});
const formatValue = (value: number) => formatDensityMapValue(value, props.formatValue);
@ -55,7 +72,8 @@ export function useDensityMapState(props: DensityMapProps) {
const cellWidth = width / DENSITY_MAP_COLUMNS;
const cellHeight = height / rows;
const interactionOpacity = props.interactionState === 'inactive' ? 0.35 : 1;
const activeSeriesIndex = hoveredState()?.seriesIndex ?? externalSeriesIndex();
const hover = activeHoveredState();
const activeSeriesIndex = hover?.seriesIndex ?? externalSeriesIndex();
const activeSeries =
activeSeriesIndex !== null && activeSeriesIndex >= 0 && activeSeriesIndex < data.series.length
? data.series[activeSeriesIndex]
@ -109,6 +127,12 @@ export function useDensityMapState(props: DensityMapProps) {
if (activeSeries !== null && activeSeriesIndex !== null) {
const highlightY = activeSeriesIndex * cellHeight;
context.save();
if (hover) {
const highlightX = hover.columnIndex * cellWidth;
context.globalAlpha = 0.12 * interactionOpacity;
context.fillStyle = activeSeries.color;
context.fillRect(highlightX, 0, Math.max(cellWidth, 1), height);
}
context.globalAlpha = 0.12 * interactionOpacity;
context.fillStyle = activeSeries.color;
context.fillRect(0, highlightY, width, cellHeight);
@ -136,18 +160,33 @@ export function useDensityMapState(props: DensityMapProps) {
const canvas = canvasRef();
if (!canvas) return;
setHoveredState(
buildDensityMapHoveredState({
clientX: event.clientX,
clientY: event.clientY,
rect: canvas.getBoundingClientRect(),
data: chartData(),
}),
);
const computed = buildDensityMapHoveredState({
clientX: event.clientX,
clientY: event.clientY,
rect: canvas.getBoundingClientRect(),
data: chartData(),
});
setHoveredState(computed);
if (!props.onHoverSyncChange || !props.hoverSourceKey || !computed) {
props.onHoverSyncChange?.(null);
return;
}
const hoveredSeries = chartData().series[computed.seriesIndex];
const hoveredSeriesId = hoveredSeries?.id?.trim() || '';
if (!hoveredSeriesId) {
props.onHoverSyncChange(null);
return;
}
props.onHoverSyncChange({
sourceKey: props.hoverSourceKey,
seriesId: hoveredSeriesId,
timestamp: computed.timestamp,
});
};
const handleMouseLeave = () => {
setHoveredState(null);
props.onHoverSyncChange?.(null);
};
createEffect(() => {
@ -159,6 +198,7 @@ export function useDensityMapState(props: DensityMapProps) {
});
return {
activeHoveredState,
chartData,
externalSeriesIndex,
formatValue,

View file

@ -5,6 +5,7 @@ import {
buildInteractiveSparklineChartData,
buildInteractiveSparklineGridLineX,
buildInteractiveSparklineGridLineY,
buildInteractiveSparklineSynchronizedHoverState,
buildInteractiveSparklineTopLabel,
buildInteractiveSparklineXAxisTicks,
clampInteractiveSparklineValue,
@ -80,6 +81,29 @@ export function useInteractiveSparklineState(
tooltipEstimatedWidth,
});
setHoveredState(computed);
if (!props.onHoverSyncChange || !props.hoverSourceKey) {
return;
}
if (!computed) {
props.onHoverSyncChange(null);
return;
}
const highlightedIndex = computed?.highlightedSeriesIndex;
if (highlightedIndex === null || highlightedIndex === undefined) {
props.onHoverSyncChange(null);
return;
}
const highlightedSeries = chartData().validSeries[highlightedIndex];
const highlightedSeriesId = highlightedSeries?.id?.trim() || '';
if (!highlightedSeriesId) {
props.onHoverSyncChange(null);
return;
}
props.onHoverSyncChange({
sourceKey: props.hoverSourceKey,
seriesId: highlightedSeriesId,
timestamp: computed.timestamp,
});
};
const flushHoverState = () => {
@ -108,6 +132,7 @@ export function useInteractiveSparklineState(
hoverRafId = null;
}
setHoveredState(null);
props.onHoverSyncChange?.(null);
};
const handleClick = () => {
@ -154,11 +179,30 @@ export function useInteractiveSparklineState(
const externalSeriesIndex = createMemo(() =>
getInteractiveSparklineExternalSeriesIndex(chartData(), props.highlightSeriesId),
);
const synchronizedHoverState = createMemo(() => {
const hoverSync = props.hoverSync;
if (!hoverSync) {
return null;
}
if (props.hoverSourceKey && hoverSync.sourceKey === props.hoverSourceKey) {
return null;
}
return buildInteractiveSparklineSynchronizedHoverState({
chartData: chartData(),
hoverSync,
vbW,
vbH,
yMode: yMode(),
});
});
const activeHoverState = createMemo<InteractiveSparklineHoverState | null>(() => {
return hoveredState() ?? synchronizedHoverState();
});
const activeEmphasisSeriesIndex = createMemo(() =>
getInteractiveSparklineActiveEmphasisSeriesIndex({
highlightNearestSeriesOnHover: props.highlightNearestSeriesOnHover === true,
lockedSeriesIndex: lockedSeriesIndex(),
hoveredState: hoveredState(),
hoveredState: activeHoverState(),
externalSeriesIndex: externalSeriesIndex(),
}),
);
@ -341,7 +385,7 @@ export function useInteractiveSparklineState(
ctx.restore();
}
const hover = hoveredState();
const hover = activeHoverState();
if (!hover) return;
ctx.save();
const x = (hover.x / vbW) * width;
@ -378,7 +422,7 @@ export function useInteractiveSparklineState(
void chartData();
void activeEmphasisSeriesIndex();
void hoveredState();
void activeHoverState();
queueCanvasDraw();
});
@ -404,6 +448,7 @@ export function useInteractiveSparklineState(
return {
activeEmphasisSeriesIndex,
activeHoverState,
activeSeriesDisplay,
axisTicks,
chartData,

View file

@ -77,6 +77,106 @@ async function expectSummaryHighlightCount(
.toBe(expectedCount);
}
async function readSummaryHighlightCount(
summary: import("@playwright/test").Locator,
resourceId: string,
): Promise<number> {
return summary
.locator("[data-highlight-series-id]")
.evaluateAll(
(nodes, expectedId) =>
nodes.filter(
(node) =>
node.getAttribute("data-highlight-series-id") === expectedId &&
node.getAttribute("data-highlight-series-active") === "true",
).length,
resourceId,
);
}
async function hoverRowUntilSummaryHighlights(
page: import("@playwright/test").Page,
rows: import("@playwright/test").Locator,
summary: import("@playwright/test").Locator,
fallbackAttribute: string,
expectedCount: number,
): Promise<{ index: number; resourceId: string }> {
const rowCount = await rows.count();
for (let index = 0; index < rowCount; index += 1) {
const row = rows.nth(index);
if (!(await row.isVisible())) {
continue;
}
const resourceId = await readSummarySeriesId(row, fallbackAttribute);
if (!resourceId) {
continue;
}
await row.hover();
try {
await expect
.poll(() => readSummaryHighlightCount(summary, resourceId), {
timeout: 1_500,
})
.toBe(expectedCount);
return { index, resourceId };
} catch {
await page.mouse.move(1, 1);
}
}
throw new Error("Unable to find a row that maps to summary highlight state");
}
async function hoverSummaryChartUntilActiveId(
page: import("@playwright/test").Page,
summary: import("@playwright/test").Locator,
chartRoot: import("@playwright/test").Locator,
): Promise<string> {
const surface = chartRoot.locator("svg, canvas").first();
await expect(surface).toBeVisible();
const box = await surface.boundingBox();
expect(box).not.toBeNull();
if (!box) {
throw new Error("Chart surface has no bounding box");
}
const probePoints = [
{ x: 0.88, y: 0.25 },
{ x: 0.88, y: 0.5 },
{ x: 0.72, y: 0.25 },
{ x: 0.72, y: 0.5 },
{ x: 0.56, y: 0.25 },
{ x: 0.56, y: 0.5 },
];
for (const point of probePoints) {
await page.mouse.move(
box.x + box.width * point.x,
box.y + box.height * point.y,
);
const activeIds = await summary
.locator("[data-highlight-series-id]")
.evaluateAll((nodes) =>
Array.from(
new Set(
nodes
.filter(
(node) =>
node.getAttribute("data-highlight-series-active") === "true",
)
.map((node) => node.getAttribute("data-highlight-series-id") || "")
.filter(Boolean),
),
),
);
if (activeIds.length === 1) {
return activeIds[0];
}
}
throw new Error("Unable to activate a summary chart hover state");
}
async function expectActiveIsolatedLineCards(
summary: import("@playwright/test").Locator,
expectedCount: number,
@ -152,19 +252,23 @@ test.describe.serial("Summary hover selection", () => {
await page.goto("/infrastructure", { waitUntil: "domcontentloaded" });
const infrastructureSummary = page.getByTestId("infrastructure-summary");
await expect(infrastructureSummary).toBeVisible();
const infrastructureRow = page
.locator('[data-testid="infrastructure-page"] tr[data-row-id]')
.first();
await expect(infrastructureRow).toBeVisible();
const infrastructureRowId =
(await infrastructureRow.getAttribute("data-row-id")) ?? "";
expect(infrastructureRowId).not.toBe("");
await infrastructureRow.hover();
await expectSummaryHighlightCount(
const infrastructureRows = page.locator(
'[data-testid="infrastructure-page"] tr[data-row-id]',
);
const firstInfrastructureRow = infrastructureRows.first();
await expect(firstInfrastructureRow).toBeVisible();
const {
index: infrastructureRowIndex,
resourceId: infrastructureRowId,
} = await hoverRowUntilSummaryHighlights(
page,
infrastructureRows,
infrastructureSummary,
infrastructureRowId,
"data-row-id",
4,
);
const infrastructureRow = infrastructureRows.nth(infrastructureRowIndex);
expect(infrastructureRowId).not.toBe("");
await expectActiveIsolatedLineCards(infrastructureSummary, 2);
await infrastructureRow.click();
await infrastructureSummary.hover();
@ -178,13 +282,17 @@ test.describe.serial("Summary hover selection", () => {
await page.goto("/workloads", { waitUntil: "domcontentloaded" });
const workloadsSummary = page.getByTestId("workloads-summary");
await expect(workloadsSummary).toBeVisible();
const workloadRow = page.locator("tr[data-guest-id]").first();
await expect(workloadRow).toBeVisible();
const workloadRowId =
(await workloadRow.getAttribute("data-guest-id")) ?? "";
const workloadRows = page.locator("tr[data-guest-id]");
const firstWorkloadRow = workloadRows.first();
await expect(firstWorkloadRow).toBeVisible();
const { resourceId: workloadRowId } = await hoverRowUntilSummaryHighlights(
page,
workloadRows,
workloadsSummary,
"data-guest-id",
4,
);
expect(workloadRowId).not.toBe("");
await workloadRow.hover();
await expectSummaryHighlightCount(workloadsSummary, workloadRowId, 4);
await expectActiveIsolatedLineCards(workloadsSummary, 2);
await page.goto("/storage", { waitUntil: "domcontentloaded" });
@ -198,27 +306,31 @@ test.describe.serial("Summary hover selection", () => {
"false",
);
const storagePoolRow = page.locator("tr[data-row-id]").first();
await expect(storagePoolRow).toBeVisible();
const storagePoolRowId = await readSummarySeriesId(
storagePoolRow,
const storagePoolRows = page.locator("tr[data-row-id]");
const firstStoragePoolRow = storagePoolRows.first();
await expect(firstStoragePoolRow).toBeVisible();
const { resourceId: storagePoolRowId } = await hoverRowUntilSummaryHighlights(
page,
storagePoolRows,
storageSummary,
"data-row-id",
3,
);
expect(storagePoolRowId).not.toBe("");
await storagePoolRow.hover();
await expectSummaryHighlightCount(storageSummary, storagePoolRowId, 3);
await expectActiveIsolatedLineCards(storageSummary, 3);
await page.getByRole("tab", { name: "Physical Disks" }).click();
const storageDiskRow = page.locator("tr[data-row-id]").first();
await expect(storageDiskRow).toBeVisible();
const storageDiskRowId = await readSummarySeriesId(
storageDiskRow,
const storageDiskRows = page.locator("tr[data-row-id]");
const firstStorageDiskRow = storageDiskRows.first();
await expect(firstStorageDiskRow).toBeVisible();
const { resourceId: storageDiskRowId } = await hoverRowUntilSummaryHighlights(
page,
storageDiskRows,
storageSummary,
"data-row-id",
1,
);
expect(storageDiskRowId).not.toBe("");
await storageDiskRow.hover();
await expectSummaryHighlightCount(storageSummary, storageDiskRowId, 1);
await expectActiveIsolatedLineCards(storageSummary, 1);
await scrollPrimaryViewportToBottom(page);
@ -227,4 +339,55 @@ test.describe.serial("Summary hover selection", () => {
expect(stickyBox).not.toBeNull();
expect(stickyBox?.y ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual(24);
});
test("synchronizes chart hover across summary cards on infrastructure, workloads, and storage", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("mobile-"),
"Desktop runtime proof",
);
await ensureMockModeEnabled(page);
await page.goto("/infrastructure", { waitUntil: "domcontentloaded" });
const infrastructureSummary = page.getByTestId("infrastructure-summary");
await expect(infrastructureSummary).toBeVisible();
const infrastructureChartId = await hoverSummaryChartUntilActiveId(
page,
infrastructureSummary,
infrastructureSummary.locator('[data-active-series-display="isolate"]').first(),
);
expect(infrastructureChartId).not.toBe("");
await expectSummaryHighlightCount(
infrastructureSummary,
infrastructureChartId,
4,
);
await expectActiveIsolatedLineCards(infrastructureSummary, 2);
await page.goto("/workloads", { waitUntil: "domcontentloaded" });
const workloadsSummary = page.getByTestId("workloads-summary");
await expect(workloadsSummary).toBeVisible();
const workloadChartId = await hoverSummaryChartUntilActiveId(
page,
workloadsSummary,
workloadsSummary.locator('[data-active-series-display="isolate"]').first(),
);
expect(workloadChartId).not.toBe("");
await expectSummaryHighlightCount(workloadsSummary, workloadChartId, 4);
await expectActiveIsolatedLineCards(workloadsSummary, 2);
await page.goto("/storage", { waitUntil: "domcontentloaded" });
const storageSummary = page.getByTestId("storage-summary");
await expect(storageSummary).toBeVisible();
const storageChartId = await hoverSummaryChartUntilActiveId(
page,
storageSummary,
storageSummary.locator('[data-active-series-display="isolate"]').first(),
);
expect(storageChartId).not.toBe("");
await expectSummaryHighlightCount(storageSummary, storageChartId, 3);
await expectActiveIsolatedLineCards(storageSummary, 3);
});
});