From 67eb19633b5e50a8b733985d6d88cf476a0adc4d Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 2 Apr 2026 23:04:49 +0100 Subject: [PATCH] fix(frontend): emphasize scoped group members in tables --- .../subsystems/frontend-primitives.md | 2 +- .../subsystems/performance-and-scalability.md | 6 +- .../internal/subsystems/storage-recovery.md | 5 +- .../internal/subsystems/unified-resources.md | 4 ++ .../src/components/Dashboard/Dashboard.tsx | 2 + .../Dashboard/DashboardWorkloadTable.tsx | 4 ++ .../src/components/Dashboard/GuestRow.tsx | 5 ++ .../components/Dashboard/WorkloadPanel.tsx | 12 +++- .../Dashboard.performance.contract.test.tsx | 3 + .../components/Dashboard/guestRowModel.tsx | 2 + .../UnifiedResourceHostTableCard.tsx | 13 +++++ .../useUnifiedResourceTableState.ts | 2 + .../src/components/Storage/Storage.tsx | 2 + .../components/Storage/StorageContentCard.tsx | 4 ++ .../src/components/Storage/StoragePoolRow.tsx | 7 +++ .../components/Storage/StoragePoolsTable.tsx | 8 +++ .../Storage/__tests__/Storage.test.tsx | 55 +++++++++++++++++++ .../SharedPrimitives.guardrails.test.ts | 8 +++ .../__tests__/summaryCardInteraction.test.ts | 38 +++++++++++++ .../shared/summaryCardInteraction.ts | 24 ++++++++ .../InfrastructurePageSurface.tsx | 2 + ...frastructurePageSurface.guardrails.test.ts | 2 + frontend-modern/src/index.css | 35 ++++++++++++ .../tests/48-summary-hover-selection.spec.ts | 35 ++++++++++++ 24 files changed, 276 insertions(+), 4 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 86ff33d7a..9231e6865 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -169,7 +169,7 @@ work extends shared components instead of creating new local variants. the shared `SummaryScopeBar.tsx` reset path. Workloads, infrastructure, and storage must not rebuild row-as-button keyboard handling or trailing one-off expand columns once the shared action primitive exists. -12. Keep summary-linked table row emphasis on the shared primitive contract. Workloads, infrastructure, and storage rows that mirror the active summary entity must expose that state through `data-summary-row-active` and let the shared presentation in `frontend-modern/src/index.css` render the row emphasis, rather than carrying page-local sky or blue fill classes inside each row renderer. +12. Keep summary-linked table row emphasis on the shared primitive contract. Workloads, infrastructure, and storage rows that mirror the active summary entity must expose that state through `data-summary-row-active` and let the shared presentation in `frontend-modern/src/index.css` render the row emphasis, rather than carrying page-local sky or blue fill classes inside each row renderer. Group-scoped preview and pin must use that same shared presentation boundary: child rows that belong to a hovered or pinned summary group should expose `data-summary-group-member-active="preview|pinned"` so the block-level emphasis stays subtle, consistent, and reversible instead of each table inventing its own outline, badge, or full-strength fill treatment. 13. Keep retained-value data loading honest at the ownership boundary. Helpers that prevent a feature surface from falling through the app-level Suspense boundary during in-flight refresh should stay feature-local until multiple 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 60989de14..25e1f5645 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -177,7 +177,11 @@ regression protection. scope/pinned pill buttons that compete with the summary shell. Workloads, infrastructure, and storage do not rebuild mouse-only hover branches, focusable-row toggles, or touch-hostile synthetic hover behavior inside - individual row renderers. + individual row renderers. The same hot path must also carry block-level + group feedback through one shared row-state contract: when a summary group + is previewed or pinned, member rows should take a restrained shared + preview/pinned wash via `data-summary-group-member-active` rather than + per-surface outlines, secondary buttons, or full-strength row fills. 28. Keep summary-card hover emphasis on one bounded rendering budget: when a summary row is active, shared sparkline and density-map primitives must promote the selected series and demote background series through the same active-series ID rather than layering a second page-local highlight pass, so zoom-range and hover scrubbing stay visually coherent without reintroducing multi-series overdraw on the hot summary cards. Density maps on that hot path must stay overview-first under focus: preserve the multi-entity heatmap rows, layer focused-entity detail inside the card, and avoid swapping transient hover into a separate single-series chart path. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 40cfa94b1..ff9f7ea0f 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -199,7 +199,10 @@ querying, and the operator-facing storage health presentation layer. `frontend-modern/src/components/shared/summaryScopePresentation.ts` contract so touch users and mouse users see the same explicit, reversible scope state instead of relying on hidden route state or storage-local - badges. + badges. When that scope is a storage pool group, member pool rows should + expose shared `data-summary-group-member-active="preview|pinned"` state so + the grouped block reads as one scoped set without adding storage-local + outlines, pill buttons, or heavy full-row fills. 14. Keep storage summary remount caches versioned with the chart contract. `frontend-modern/src/components/Storage/StorageSummary.tsx` may keep a bounded in-memory cache for same-tab remounts, but its cache key must carry diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 562d305c8..174c9559d 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -228,6 +228,10 @@ assembly branch. `data-summary-row-active` and rely on the shared row presentation owned by `frontend-modern/src/index.css` instead of provider-specific background classes that drift across resource tables or hide inline metric bars. + Cluster-member rows must also expose shared preview-versus-pinned group + emphasis through `data-summary-group-member-active`, so the whole cluster + block reads as the active scope without inventing a second infrastructure- + local outline or banner treatment. Summary-linked infrastructure rows and cluster headers must also route pointer preview and focus preview through `frontend-modern/src/components/shared/summaryInteractionA11y.ts`, while diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index b2ee8d9ef..932c6d23c 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -133,7 +133,9 @@ export function Dashboard(props: DashboardProps) { handleTagClick={state.handleTagClick} activeSummaryWorkloadGroupScope={state.activeSummaryWorkloadGroupScope} activeSummaryWorkloadId={state.activeSummaryWorkloadId} + focusedSummaryWorkloadGroupScope={state.focusedSummaryWorkloadGroupScope} focusedSummaryWorkloadGroupId={state.focusedSummaryWorkloadGroupId} + hoveredSummaryWorkloadGroupScope={state.hoveredSummaryWorkloadGroupScope} isMobile={state.isMobile} mobileVisibleColumnIds={state.mobileVisibleColumnIds} mobileVisibleColumns={state.mobileVisibleColumns} diff --git a/frontend-modern/src/components/Dashboard/DashboardWorkloadTable.tsx b/frontend-modern/src/components/Dashboard/DashboardWorkloadTable.tsx index 6b8a339dd..3deed1430 100644 --- a/frontend-modern/src/components/Dashboard/DashboardWorkloadTable.tsx +++ b/frontend-modern/src/components/Dashboard/DashboardWorkloadTable.tsx @@ -22,7 +22,9 @@ type DashboardWorkloadTableProps = Pick< | 'handleTagClick' | 'activeSummaryWorkloadGroupScope' | 'activeSummaryWorkloadId' + | 'focusedSummaryWorkloadGroupScope' | 'focusedSummaryWorkloadGroupId' + | 'hoveredSummaryWorkloadGroupScope' | 'isMobile' | 'mobileVisibleColumnIds' | 'mobileVisibleColumns' @@ -83,7 +85,9 @@ export function DashboardWorkloadTable(props: DashboardWorkloadTableProps) { handleTagClick={props.handleTagClick} activeSummaryWorkloadGroupScope={props.activeSummaryWorkloadGroupScope} activeSummaryWorkloadId={props.activeSummaryWorkloadId} + focusedSummaryWorkloadGroupScope={props.focusedSummaryWorkloadGroupScope} focusedSummaryWorkloadGroupId={props.focusedSummaryWorkloadGroupId} + hoveredSummaryWorkloadGroupScope={props.hoveredSummaryWorkloadGroupScope} mobileVisibleColumnIds={props.mobileVisibleColumnIds} nodeByInstance={props.nodeByInstance} search={props.search} diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index 086447348..aa5cd15ce 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -110,6 +110,11 @@ export function GuestRow(props: GuestRowProps) { style={rowStyle()} data-guest-id={guestId()} data-summary-series-id={guestId()} + data-summary-group-member-active={ + props.summaryGroupMemberState && props.summaryGroupMemberState !== 'default' + ? props.summaryGroupMemberState + : undefined + } data-summary-row-active={props.isSummaryHighlighted && !props.isExpanded ? 'true' : 'false'} onClick={props.onClick} {...interactiveRowHandlers} diff --git a/frontend-modern/src/components/Dashboard/WorkloadPanel.tsx b/frontend-modern/src/components/Dashboard/WorkloadPanel.tsx index 5e3ac9970..383eb5a21 100644 --- a/frontend-modern/src/components/Dashboard/WorkloadPanel.tsx +++ b/frontend-modern/src/components/Dashboard/WorkloadPanel.tsx @@ -10,7 +10,10 @@ import { TableBody, TableCell, TableRow } from '@/components/shared/Table'; import { getAlertStyles } from '@/utils/alerts'; import { isNodeOnline } from '@/utils/status'; import { getCanonicalWorkloadId } from '@/utils/workloads'; -import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction'; +import { + resolveSummaryGroupMemberInteractionState, + type SummarySeriesGroupScope, +} from '@/components/shared/summaryCardInteraction'; import { GuestDrawer } from './GuestDrawer'; import { GuestRow } from './GuestRow'; @@ -32,7 +35,9 @@ type WorkloadPanelProps = Pick< | 'handleTagClick' | 'activeSummaryWorkloadGroupScope' | 'activeSummaryWorkloadId' + | 'focusedSummaryWorkloadGroupScope' | 'focusedSummaryWorkloadGroupId' + | 'hoveredSummaryWorkloadGroupScope' | 'mobileVisibleColumnIds' | 'nodeByInstance' | 'search' @@ -182,6 +187,11 @@ export function WorkloadPanel(props: WorkloadPanelProps) { } isExpanded={props.selectedGuestId() === guestId()} isSummaryHighlighted={props.activeSummaryWorkloadId() === guestId()} + summaryGroupMemberState={resolveSummaryGroupMemberInteractionState({ + seriesId: guestId(), + hoveredGroupScope: props.hoveredSummaryWorkloadGroupScope(), + focusedGroupScope: props.focusedSummaryWorkloadGroupScope(), + })} ioEmphasis={props.workloadIOEmphasis()} onHoverChange={props.setHoveredWorkloadId} /> diff --git a/frontend-modern/src/components/Dashboard/__tests__/Dashboard.performance.contract.test.tsx b/frontend-modern/src/components/Dashboard/__tests__/Dashboard.performance.contract.test.tsx index e38bc9091..7b4800899 100644 --- a/frontend-modern/src/components/Dashboard/__tests__/Dashboard.performance.contract.test.tsx +++ b/frontend-modern/src/components/Dashboard/__tests__/Dashboard.performance.contract.test.tsx @@ -873,11 +873,14 @@ describe('Dashboard performance contract', () => { expect(workloadPanelSource).toContain('GuestDrawer'); expect(workloadPanelSource).toContain('createMemo(() => getCanonicalWorkloadId(guest()))'); expect(workloadPanelSource).toContain('createSummaryInteractiveRowPreviewHandlers'); + expect(workloadPanelSource).toContain('resolveSummaryGroupMemberInteractionState'); expect(workloadPanelSource).not.toContain('kind="scope"'); expect(workloadPanelSource).not.toContain('leadingAction={'); expect(dashboardSelectionStateSource).toContain('activeSummaryWorkloadGroupScope'); expect(dashboardSelectionStateSource).toContain('focusedSummaryWorkloadGroupScope'); expect(dashboardSelectionStateSource).toContain('setHoveredWorkloadGroupScope'); + expect(dashboardWorkloadTableSource).toContain('focusedSummaryWorkloadGroupScope'); + expect(dashboardWorkloadTableSource).toContain('hoveredSummaryWorkloadGroupScope'); expect(dashboardSource).toContain('hoveredGroupScope={state.hoveredSummaryWorkloadGroupScope()}'); expect(dashboardSource).toContain('focusedGroupScope={state.focusedSummaryWorkloadGroupScope()}'); expect(workloadPanelSource).not.toContain('TableHead'); diff --git a/frontend-modern/src/components/Dashboard/guestRowModel.tsx b/frontend-modern/src/components/Dashboard/guestRowModel.tsx index 0b93d29f2..762a831a3 100644 --- a/frontend-modern/src/components/Dashboard/guestRowModel.tsx +++ b/frontend-modern/src/components/Dashboard/guestRowModel.tsx @@ -1,4 +1,5 @@ import type { ColumnDef } from '@/hooks/useColumnVisibility'; +import type { SummaryGroupMemberInteractionState } from '@/components/shared/summaryCardInteraction'; import type { WorkloadGuest, ViewMode } from '@/types/workloads'; import { createVisibleCanonicalTypeColumn } from '@/utils/typeColumnDefinition'; @@ -42,6 +43,7 @@ export interface GuestRowProps { onClick?: () => void; isExpanded?: boolean; isSummaryHighlighted?: boolean; + summaryGroupMemberState?: SummaryGroupMemberInteractionState; ioEmphasis?: WorkloadIOEmphasis; onHoverChange?: (guestId: string | null) => void; } diff --git a/frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx b/frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx index 57ca12c25..c2b105110 100644 --- a/frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx +++ b/frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx @@ -33,6 +33,7 @@ import { buildWorkloadsHref } from './workloadsLink'; import { ClusterDeployBanner } from './ClusterDeployBanner'; import { ResourceFacetSummary } from './ResourceFacetSummary'; import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction'; +import { resolveSummaryGroupMemberInteractionState } from '@/components/shared/summaryCardInteraction'; import { buildSummaryDisclosureControlsId, createSummaryInteractiveRowPreviewHandlers, @@ -221,6 +222,13 @@ export const UnifiedResourceHostTableCard: Component getPreferredInfrastructureDisplayName(resource), ); + const summaryGroupMemberState = createMemo(() => + resolveSummaryGroupMemberInteractionState({ + seriesId: resource.id, + hoveredGroupScope: tableProps.hoveredSummaryGroupScope, + focusedGroupScope: tableProps.focusedSummaryGroupScope, + }), + ); const statusIndicator = createMemo(() => getAgentStatusIndicator({ status: resource.status }), ); @@ -306,6 +314,11 @@ export const UnifiedResourceHostTableCard: Component void; onHoverChange?: (id: string | null) => void; diff --git a/frontend-modern/src/components/Storage/Storage.tsx b/frontend-modern/src/components/Storage/Storage.tsx index 1e24ac89f..1f23eb479 100644 --- a/frontend-modern/src/components/Storage/Storage.tsx +++ b/frontend-modern/src/components/Storage/Storage.tsx @@ -148,6 +148,8 @@ const Storage: Component = () => { getRecordAlertState={getRecordAlertState} isLoadingPools={isLoadingPools} activeSummaryGroupScope={activeSummaryStorageGroupScope} + hoveredSummaryGroupScope={hoveredSummaryStorageGroupScope} + focusedSummaryGroupScope={focusedSummaryStorageGroupScope} focusedSummaryGroupId={focusedSummaryStorageGroupId} onGroupFocusChange={setFocusedStorageGroupScope} onGroupHoverChange={setHoveredStorageGroupScope} diff --git a/frontend-modern/src/components/Storage/StorageContentCard.tsx b/frontend-modern/src/components/Storage/StorageContentCard.tsx index 5133bd0ca..9cf08bf61 100644 --- a/frontend-modern/src/components/Storage/StorageContentCard.tsx +++ b/frontend-modern/src/components/Storage/StorageContentCard.tsx @@ -30,6 +30,8 @@ type StorageContentCardProps = { getRecordAlertState: (recordId: string) => StorageAlertRowState; isLoadingPools: () => boolean; activeSummaryGroupScope: () => SummarySeriesGroupScope | null; + hoveredSummaryGroupScope: () => SummarySeriesGroupScope | null; + focusedSummaryGroupScope: () => SummarySeriesGroupScope | null; focusedSummaryGroupId: () => string | null; onGroupFocusChange: (scope: SummarySeriesGroupScope | null) => void; onGroupHoverChange: (scope: SummarySeriesGroupScope | null) => void; @@ -78,6 +80,8 @@ export const StorageContentCard: Component = (props) => getRecordAlertState={props.getRecordAlertState} isLoading={props.isLoadingPools()} activeSummaryGroupScope={props.activeSummaryGroupScope()} + hoveredSummaryGroupScope={props.hoveredSummaryGroupScope()} + focusedSummaryGroupScope={props.focusedSummaryGroupScope()} focusedSummaryGroupId={props.focusedSummaryGroupId()} onGroupFocusChange={props.onGroupFocusChange} onGroupHoverChange={props.onGroupHoverChange} diff --git a/frontend-modern/src/components/Storage/StoragePoolRow.tsx b/frontend-modern/src/components/Storage/StoragePoolRow.tsx index 4a962e567..f4ae36661 100644 --- a/frontend-modern/src/components/Storage/StoragePoolRow.tsx +++ b/frontend-modern/src/components/Storage/StoragePoolRow.tsx @@ -29,6 +29,7 @@ import { STORAGE_POOL_ROW_USAGE_WRAP_CLASS, } from '@/features/storageBackups/storagePoolRowPresentation'; import type { Resource } from '@/types/resource'; +import type { SummaryGroupMemberInteractionState } from '@/components/shared/summaryCardInteraction'; import { EnhancedStorageBar } from './EnhancedStorageBar'; import { StoragePoolDetail } from './StoragePoolDetail'; import { @@ -42,6 +43,7 @@ interface StoragePoolRowProps { summarySeriesId: string; expanded: boolean; summaryHighlighted?: boolean; + summaryGroupMemberState?: SummaryGroupMemberInteractionState; onToggleExpand: () => void; onHoverChange?: (recordId: string | null) => void; rowClass: string; @@ -73,6 +75,11 @@ export const StoragePoolRow: Component = (props) => { onClick={props.onToggleExpand} {...interactiveRowHandlers} data-summary-series-id={props.summarySeriesId} + data-summary-group-member-active={ + props.summaryGroupMemberState && props.summaryGroupMemberState !== 'default' + ? props.summaryGroupMemberState + : undefined + } data-summary-row-active={props.summaryHighlighted && !props.expanded ? 'true' : 'false'} {...props.alertDataAttrs} > diff --git a/frontend-modern/src/components/Storage/StoragePoolsTable.tsx b/frontend-modern/src/components/Storage/StoragePoolsTable.tsx index cde0acd45..a93cf6ee8 100644 --- a/frontend-modern/src/components/Storage/StoragePoolsTable.tsx +++ b/frontend-modern/src/components/Storage/StoragePoolsTable.tsx @@ -19,6 +19,7 @@ import { StoragePoolRow } from './StoragePoolRow'; import type { StorageGroupedRecords, StorageGroupKey } from './useStorageModel'; import { useStoragePoolsTableModel } from './useStoragePoolsTableModel'; import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction'; +import { resolveSummaryGroupMemberInteractionState } from '@/components/shared/summaryCardInteraction'; import { buildStorageSummaryGroupScope } from './storageSummaryGroups'; type StoragePoolsTableProps = { @@ -34,6 +35,8 @@ type StoragePoolsTableProps = { getRecordAlertState: (recordId: string) => StorageAlertRowState; isLoading: boolean; activeSummaryGroupScope?: SummarySeriesGroupScope | null; + hoveredSummaryGroupScope?: SummarySeriesGroupScope | null; + focusedSummaryGroupScope?: SummarySeriesGroupScope | null; focusedSummaryGroupId?: string | null; onGroupFocusChange?: (scope: SummarySeriesGroupScope | null) => void; onGroupHoverChange?: (scope: SummarySeriesGroupScope | null) => void; @@ -108,6 +111,11 @@ export const StoragePoolsTable: Component = (props) => { props.highlightedSummarySeriesId === resolveStorageRecordMetricResourceId(record()) } + summaryGroupMemberState={resolveSummaryGroupMemberInteractionState({ + seriesId: resolveStorageRecordMetricResourceId(record()), + hoveredGroupScope: props.hoveredSummaryGroupScope, + focusedGroupScope: props.focusedSummaryGroupScope, + })} onToggleExpand={() => model.togglePool(record().id)} onHoverChange={props.onHoverChange} rowClass={rowModel().rowClass} diff --git a/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx b/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx index bd68c1264..7882c26d0 100644 --- a/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx @@ -782,6 +782,61 @@ describe('Storage', () => { ).not.toBeInTheDocument(); }); + it('uses shared preview and pinned group-member emphasis for storage pool rows', async () => { + hookResources = [ + buildStorageResource('storage-1', 'Node-Store', 'pve1'), + buildStorageResource('storage-2', 'Edge-Store', 'pve2'), + ]; + mockLocationSearch = '?group=node'; + navigateSpy.mockImplementation((nextPath: string) => { + mockLocationSearch = nextPath.includes('?') ? nextPath.slice(nextPath.indexOf('?')) : ''; + }); + + render(() => ); + + await waitFor(() => { + expect( + document.querySelector('tr[data-summary-group-id="storage:node:pve1"]'), + ).toBeTruthy(); + }); + + const groupRow = document.querySelector( + 'tr[data-summary-group-id="storage:node:pve1"]', + ) as HTMLTableRowElement | null; + expect(groupRow).not.toBeNull(); + if (!groupRow) { + return; + } + + fireEvent.pointerEnter(groupRow, { pointerType: 'mouse' }); + + await waitFor(() => { + expect( + document.querySelectorAll('tr[data-summary-group-member-active="preview"]'), + ).toHaveLength(1); + }); + + fireEvent.pointerLeave(groupRow, { pointerType: 'mouse' }); + + await waitFor(() => { + expect( + document.querySelectorAll('tr[data-summary-group-member-active="preview"]'), + ).toHaveLength(0); + }); + + fireEvent.click(groupRow); + + await waitFor(() => { + expect(navigateSpy).toHaveBeenCalledWith( + '/storage?group=node&summaryGroup=storage%3Anode%3Apve1', + ROUTE_STATE_REPLACE_OPTIONS, + ); + expect( + document.querySelectorAll('tr[data-summary-group-member-active="pinned"]'), + ).toHaveLength(1); + }); + }); + it('shows ceph summary card and pool expand chevron', async () => { hookResources = [ buildStorageResource('storage-ceph', 'Ceph-Pool-1', 'pve1', { diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index ec6a468f4..9eaf4bc4f 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -291,6 +291,7 @@ describe('shared primitive guardrails', () => { expect(summaryCardInteractionSource).toContain('chartHoveredSeriesId'); expect(summaryCardInteractionSource).toContain('SummarySeriesGroupScope'); expect(summaryCardInteractionSource).toContain('resolveSummaryGroupScope'); + expect(summaryCardInteractionSource).toContain('resolveSummaryGroupMemberInteractionState'); expect(summaryCardInteractionSource).toContain('resolveSummaryScopeState'); expect(interactiveSparklineModelSource).toContain('onHoverSyncChange'); expect(densityMapModelSource).toContain('onHoverSyncChange'); @@ -384,8 +385,12 @@ describe('shared primitive guardrails', () => { expect(frontendIndexCssSource).toContain("tr[data-summary-row-active='true'] > td"); expect(frontendIndexCssSource).toContain('--color-summary-row-bg'); expect(frontendIndexCssSource).toContain('--color-summary-row-accent'); + expect(frontendIndexCssSource).toContain("tr[data-summary-group-member-active='preview'] > td"); + expect(frontendIndexCssSource).toContain("tr[data-summary-group-member-active='pinned'] > td"); + expect(frontendIndexCssSource).toContain('--color-summary-group-member-pinned-accent'); expect(guestRowSource).toContain('data-summary-row-active'); + expect(guestRowSource).toContain('data-summary-group-member-active'); expect(guestRowStateSource).not.toContain('bg-sky-50/70'); expect(guestRowStateSource).not.toContain('ring-sky-400/25'); @@ -402,6 +407,9 @@ describe('shared primitive guardrails', () => { expect(source).not.toContain('bg-blue-100 dark:bg-blue-800'); expect(source).not.toContain('ring-blue-300 dark:ring-blue-600'); } + + expect(storagePoolRowSource).toContain('data-summary-group-member-active'); + expect(unifiedResourceHostTableCardSource).toContain('data-summary-group-member-active'); }); it('keeps summary-linked row input semantics on the shared interaction helper', () => { diff --git a/frontend-modern/src/components/shared/__tests__/summaryCardInteraction.test.ts b/frontend-modern/src/components/shared/__tests__/summaryCardInteraction.test.ts index ca6ac8856..046408adb 100644 --- a/frontend-modern/src/components/shared/__tests__/summaryCardInteraction.test.ts +++ b/frontend-modern/src/components/shared/__tests__/summaryCardInteraction.test.ts @@ -9,6 +9,7 @@ import { filterSummarySeriesByGroupScope, resolveSummaryActiveSeriesId, resolveSummaryCardInteractionState, + resolveSummaryGroupMemberInteractionState, resolveSummaryScopeState, type SummarySeriesGroupScope, } from '@/components/shared/summaryCardInteraction'; @@ -164,6 +165,43 @@ describe('summaryCardInteraction', () => { }); }); + it('resolves group-member emphasis from hovered and pinned group scope', () => { + const hoveredGroupScope: SummarySeriesGroupScope = { + id: 'cluster-a', + label: 'Cluster A (2 workloads)', + seriesIds: ['alpha', 'beta'], + }; + const focusedGroupScope: SummarySeriesGroupScope = { + id: 'cluster-b', + label: 'Cluster B (2 workloads)', + seriesIds: ['gamma', 'delta'], + }; + + expect( + resolveSummaryGroupMemberInteractionState({ + seriesId: 'alpha', + hoveredGroupScope, + focusedGroupScope, + }), + ).toBe('preview'); + + expect( + resolveSummaryGroupMemberInteractionState({ + seriesId: 'gamma', + hoveredGroupScope, + focusedGroupScope, + }), + ).toBe('pinned'); + + expect( + resolveSummaryGroupMemberInteractionState({ + seriesId: 'omega', + hoveredGroupScope, + focusedGroupScope, + }), + ).toBe('default'); + }); + it('builds consistent scope-bar presentation for page, group, and entity states', () => { const groupScope: SummarySeriesGroupScope = { id: 'cluster-a', diff --git a/frontend-modern/src/components/shared/summaryCardInteraction.ts b/frontend-modern/src/components/shared/summaryCardInteraction.ts index fd5063c5f..b7a6c4173 100644 --- a/frontend-modern/src/components/shared/summaryCardInteraction.ts +++ b/frontend-modern/src/components/shared/summaryCardInteraction.ts @@ -1,4 +1,5 @@ export type SummaryCardInteractionState = 'default' | 'active' | 'inactive'; +export type SummaryGroupMemberInteractionState = 'default' | 'preview' | 'pinned'; export type SummaryScopeKind = 'page' | 'group' | 'entity'; export type SummaryScopeSource = 'page' | 'preview' | 'pinned'; @@ -68,6 +69,29 @@ export const resolveSummaryGroupScope = (options: { return normalizeSummarySeriesGroupScope(options.focusedGroupScope); }; +export const resolveSummaryGroupMemberInteractionState = (options: { + seriesId?: string | null; + hoveredGroupScope?: SummarySeriesGroupScope | null; + focusedGroupScope?: SummarySeriesGroupScope | null; +}): SummaryGroupMemberInteractionState => { + const normalizedSeriesId = normalizeSeriesId(options.seriesId); + if (!normalizedSeriesId) { + return 'default'; + } + + const hoveredGroupScope = normalizeSummarySeriesGroupScope(options.hoveredGroupScope); + if (isSummarySeriesInGroupScope(hoveredGroupScope, normalizedSeriesId)) { + return 'preview'; + } + + const focusedGroupScope = normalizeSummarySeriesGroupScope(options.focusedGroupScope); + if (isSummarySeriesInGroupScope(focusedGroupScope, normalizedSeriesId)) { + return 'pinned'; + } + + return 'default'; +}; + export const resolveSummaryScopeState = (options: { chartHoveredSeriesId?: string | null; hoveredSeriesId?: string | null; diff --git a/frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx b/frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx index 0eff3ff7c..1da682312 100644 --- a/frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx +++ b/frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx @@ -338,6 +338,8 @@ export function InfrastructurePageSurface() { resources={filteredResources()} expandedResourceId={expandedResourceId()} activeSummaryGroupScope={activeSummaryResourceGroupScope()} + hoveredSummaryGroupScope={hoveredSummaryResourceGroupScope()} + focusedSummaryGroupScope={focusedSummaryResourceGroupScope()} focusedSummaryGroupId={focusedSummaryResourceGroupId()} hoveredResourceId={activeSummaryResourceId()} highlightedResourceId={highlightedResourceId()} diff --git a/frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts b/frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts index 1bdba87ea..20260c62d 100644 --- a/frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts +++ b/frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts @@ -46,6 +46,8 @@ describe('InfrastructurePageSurface guardrails', () => { expect(infrastructurePageSurfaceSource).toContain('hoveredGroupScope={hoveredSummaryResourceGroupScope()}'); expect(infrastructurePageSurfaceSource).toContain('focusedGroupScope={focusedSummaryResourceGroupScope()}'); expect(infrastructurePageSurfaceSource).toContain('activeSummaryGroupScope={activeSummaryResourceGroupScope()}'); + expect(infrastructurePageSurfaceSource).toContain('hoveredSummaryGroupScope={hoveredSummaryResourceGroupScope()}'); + expect(infrastructurePageSurfaceSource).toContain('focusedSummaryGroupScope={focusedSummaryResourceGroupScope()}'); expect(infrastructurePageSurfaceSource).toContain('onGroupHoverChange={setHoveredResourceGroupScope}'); expect(infrastructurePageSurfaceSource).toContain('setTableRootRef={setSummaryTableRootRef}'); expect(infrastructurePageSurfaceSource).toContain('SummaryScopeBar'); diff --git a/frontend-modern/src/index.css b/frontend-modern/src/index.css index b72aa5396..8f5a78cfe 100644 --- a/frontend-modern/src/index.css +++ b/frontend-modern/src/index.css @@ -62,6 +62,11 @@ --color-summary-row-bg-hover: rgba(14, 165, 233, 0.08); --color-summary-row-border: rgba(14, 165, 233, 0.16); --color-summary-row-accent: rgba(14, 165, 233, 0.42); + --color-summary-group-member-preview-bg: rgba(14, 165, 233, 0.025); + --color-summary-group-member-preview-bg-hover: rgba(14, 165, 233, 0.04); + --color-summary-group-member-pinned-bg: rgba(14, 165, 233, 0.04); + --color-summary-group-member-pinned-bg-hover: rgba(14, 165, 233, 0.055); + --color-summary-group-member-pinned-accent: rgba(14, 165, 233, 0.22); } .dark { @@ -79,6 +84,11 @@ --color-summary-row-bg-hover: rgba(56, 189, 248, 0.12); --color-summary-row-border: rgba(56, 189, 248, 0.18); --color-summary-row-accent: rgba(56, 189, 248, 0.5); + --color-summary-group-member-preview-bg: rgba(56, 189, 248, 0.045); + --color-summary-group-member-preview-bg-hover: rgba(56, 189, 248, 0.065); + --color-summary-group-member-pinned-bg: rgba(56, 189, 248, 0.07); + --color-summary-group-member-pinned-bg-hover: rgba(56, 189, 248, 0.09); + --color-summary-group-member-pinned-accent: rgba(56, 189, 248, 0.28); } body { @@ -163,6 +173,31 @@ @apply border-b border-border hover:bg-surface-hover; } + tr[data-summary-group-member-active='preview'] > td, + tr[data-summary-group-member-active='preview'] > th { + background-color: var(--color-summary-group-member-preview-bg); + } + + tr[data-summary-group-member-active='preview']:hover > td, + tr[data-summary-group-member-active='preview']:hover > th { + background-color: var(--color-summary-group-member-preview-bg-hover); + } + + tr[data-summary-group-member-active='pinned'] > td, + tr[data-summary-group-member-active='pinned'] > th { + background-color: var(--color-summary-group-member-pinned-bg); + } + + tr[data-summary-group-member-active='pinned']:hover > td, + tr[data-summary-group-member-active='pinned']:hover > th { + background-color: var(--color-summary-group-member-pinned-bg-hover); + } + + tr[data-summary-group-member-active='pinned'] > td:first-child, + tr[data-summary-group-member-active='pinned'] > th:first-child { + box-shadow: inset 1px 0 0 var(--color-summary-group-member-pinned-accent); + } + tr[data-summary-row-active='true'] > td, tr[data-summary-row-active='true'] > th { background-color: var(--color-summary-row-bg); diff --git a/tests/integration/tests/48-summary-hover-selection.spec.ts b/tests/integration/tests/48-summary-hover-selection.spec.ts index 4ea557435..6abab2d93 100644 --- a/tests/integration/tests/48-summary-hover-selection.spec.ts +++ b/tests/integration/tests/48-summary-hover-selection.spec.ts @@ -894,6 +894,9 @@ test.describe.serial("Summary hover selection", () => { await matchedGroupRow.hover(); await expect(matchedGroupRow).toHaveAttribute("data-summary-row-active", "true"); + await expect(page.locator('tr[data-summary-group-member-active="preview"]')).toHaveCount( + matchedGroupSeriesCount, + ); await expect .poll(() => readRenderedSeriesCounts(workloadsSummary)) .toEqual(new Array(4).fill(matchedGroupSeriesCount)); @@ -901,6 +904,7 @@ test.describe.serial("Summary hover selection", () => { await page.mouse.move(1, 1); await expect(matchedGroupRow).toHaveAttribute("data-summary-row-active", "false"); + await expect(page.locator('tr[data-summary-group-member-active="preview"]')).toHaveCount(0); await expect .poll(() => readRenderedSeriesCounts(workloadsSummary)) .toEqual(resolvedBaselineCounts); @@ -964,6 +968,9 @@ test.describe.serial("Summary hover selection", () => { await matchedGroupRow.hover(); await expect(matchedGroupRow).toHaveAttribute("data-summary-row-active", "true"); + await expect(page.locator('tr[data-summary-group-member-active="preview"]')).toHaveCount( + matchedGroupSeriesCount, + ); await expect .poll(() => readRenderedSeriesCounts(infrastructureSummary)) .toEqual(new Array(4).fill(matchedGroupSeriesCount)); @@ -973,6 +980,7 @@ test.describe.serial("Summary hover selection", () => { await page.mouse.move(1, 1); await expect(matchedGroupRow).toHaveAttribute("data-summary-row-active", "false"); + await expect(page.locator('tr[data-summary-group-member-active="preview"]')).toHaveCount(0); await expect .poll(() => readRenderedSeriesCounts(infrastructureSummary)) .toEqual(resolvedBaselineCounts); @@ -1053,14 +1061,21 @@ test.describe.serial("Summary hover selection", () => { await matchedGroupRow.hover(); await expect(scopeBar).toContainText("Preview"); + await expect(page.locator('tr[data-summary-group-member-active="preview"]')).toHaveCount( + matchedGroupSeriesCount, + ); await page.mouse.move(1, 1); await expect(scopeBar).toContainText("All"); + await expect(page.locator('tr[data-summary-group-member-active="preview"]')).toHaveCount(0); await matchedGroupRow.click(); await expect .poll(() => new URL(page.url()).searchParams.get("summaryGroup")) .toBe(matchedGroupId); await expect(scopeBar).toContainText("Pinned"); + await expect(page.locator('tr[data-summary-group-member-active="preview"]')).toHaveCount( + matchedGroupSeriesCount, + ); await expect( scopeBar.getByRole("button", { name: "Reset pinned scope" }), ).toBeVisible(); @@ -1072,6 +1087,10 @@ test.describe.serial("Summary hover selection", () => { ); await page.mouse.move(1, 1); + await expect(page.locator('tr[data-summary-group-member-active="preview"]')).toHaveCount(0); + await expect(page.locator('tr[data-summary-group-member-active="pinned"]')).toHaveCount( + matchedGroupSeriesCount, + ); await expect .poll(() => readRenderedSeriesCounts(summary)) .toEqual(new Array(4).fill(matchedGroupSeriesCount)); @@ -1081,6 +1100,7 @@ test.describe.serial("Summary hover selection", () => { .poll(() => new URL(page.url()).searchParams.get("summaryGroup")) .toBeNull(); await expect(scopeBar).toContainText("All"); + await expect(page.locator('tr[data-summary-group-member-active="pinned"]')).toHaveCount(0); await page.mouse.move(1, 1); await expect .poll(() => readRenderedSeriesCounts(summary)) @@ -1119,6 +1139,7 @@ test.describe.serial("Summary hover selection", () => { let matchedGroupRow: import("@playwright/test").Locator | null = null; let matchedGroupId = ""; + let matchedGroupSeriesCount = 0; for (let index = 0; index < groupRowCount; index += 1) { const row = groupRows.nth(index); if (!(await row.isVisible())) { @@ -1135,25 +1156,34 @@ test.describe.serial("Summary hover selection", () => { } matchedGroupRow = row; matchedGroupId = groupId; + matchedGroupSeriesCount = seriesCount; break; } expect(matchedGroupRow).not.toBeNull(); expect(matchedGroupId).not.toBe(""); + expect(matchedGroupSeriesCount).toBeGreaterThan(0); if (!matchedGroupRow) { return; } await matchedGroupRow.hover(); await expect(storageScopeBar).toContainText("Preview"); + await expect(page.locator('tr[data-summary-group-member-active="preview"]')).toHaveCount( + matchedGroupSeriesCount, + ); await page.mouse.move(1, 1); await expect(storageScopeBar).toContainText("All"); + await expect(page.locator('tr[data-summary-group-member-active="preview"]')).toHaveCount(0); await matchedGroupRow.click(); await expect .poll(() => new URL(page.url()).searchParams.get("summaryGroup")) .toBe(matchedGroupId); await expect(storageScopeBar).toContainText("Pinned"); + await expect(page.locator('tr[data-summary-group-member-active="preview"]')).toHaveCount( + matchedGroupSeriesCount, + ); await expect(page.locator("tr[data-row-id]")).toHaveCount(baselineVisibleRows); await expect @@ -1161,11 +1191,16 @@ test.describe.serial("Summary hover selection", () => { .not.toEqual(baselineCounts); await page.mouse.move(1, 1); + await expect(page.locator('tr[data-summary-group-member-active="preview"]')).toHaveCount(0); + await expect(page.locator('tr[data-summary-group-member-active="pinned"]')).toHaveCount( + matchedGroupSeriesCount, + ); await expect(page.locator("tr[data-row-id]")).toHaveCount(baselineVisibleRows); await storageScopeBar .getByRole("button", { name: "Reset pinned scope" }) .click(); + await expect(page.locator('tr[data-summary-group-member-active="pinned"]')).toHaveCount(0); await expect .poll(() => new URL(page.url()).searchParams.get("summaryGroup")) .toBeNull();