fix(frontend): integrate density-map focus detail

This commit is contained in:
rcourtman 2026-04-01 14:02:23 +01:00
parent 8a44d27675
commit 1339e17eaa
8 changed files with 558 additions and 218 deletions

View file

@ -44,90 +44,90 @@ work extends shared components instead of creating new local variants.
22. `frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx`
23. `frontend-modern/src/components/Settings/OperationsPanel.tsx`
24. `frontend-modern/src/utils/diagnosticsPresentation.ts`
24. `frontend-modern/src/utils/discoveryPresentation.ts`
24. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`
25. `frontend-modern/src/components/Settings/NetworkSettingsPanel.tsx`
26. `frontend-modern/src/components/Settings/RecoverySettingsPanel.tsx`
27. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx`
28. `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx`
29. `frontend-modern/src/components/Settings/settingsHeaderMeta.ts`
30. `frontend-modern/src/components/Settings/selfHostedBillingPresentation.ts`
30. `frontend-modern/src/components/Settings/SSOProvidersPanel.tsx`
31. `frontend-modern/src/components/Settings/useAISettingsState.ts`
32. `frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts`
33. `frontend-modern/src/components/Settings/useSettingsShellState.ts`
34. `frontend-modern/src/components/Settings/useSSOProvidersState.ts`
35. `frontend-modern/src/components/Settings/ssoProvidersModel.ts`
36. `frontend-modern/src/utils/ssoProviderPresentation.ts`
37. `frontend-modern/src/utils/systemSettingsPresentation.ts`
38. `frontend-modern/src/utils/aiSettingsPresentation.ts`
39. `frontend-modern/src/utils/settingsShellPresentation.ts`
40. `frontend-modern/src/utils/textPresentation.ts`
37. `frontend-modern/src/components/Settings/UpdateInstallGuide.tsx`
38. `frontend-modern/src/components/Settings/updatesSettingsModel.ts`
39. `frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx`
40. `frontend-modern/src/components/Settings/ReportingPanel.tsx`
41. `frontend-modern/src/components/Settings/reportingPanelModel.ts`
42. `frontend-modern/src/components/Settings/reportingInventoryExportModel.ts`
43. `frontend-modern/src/components/Settings/useReportingPanelState.ts`
44. `frontend-modern/src/utils/reportingPresentation.ts`
45. `frontend-modern/src/utils/updatesPresentation.ts`
44. `frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts`
45. `tests/integration/tests/15-settings-shell-consistency.spec.ts`
46. `frontend-modern/src/components/shared/PageControls.guardrails.test.ts`
47. `frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts`
48. `frontend-modern/src/features/`
49. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx`
50. `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`
50. `frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx`
51. `frontend-modern/src/components/SetupWizard/steps/WelcomeStep.tsx`
52. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx`
53. `frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx`
54. `frontend-modern/src/components/SetupWizard/__tests__/WelcomeStep.test.tsx`
55. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx`
56. `frontend-modern/src/components/Settings/SystemLogsPanel.tsx`
57. `frontend-modern/src/components/Settings/useSystemLogsPanelState.ts`
58. `frontend-modern/src/utils/systemLogsPresentation.ts`
59. `frontend-modern/src/components/Settings/__tests__/SystemLogsPanel.test.tsx`
60. `frontend-modern/src/features/operations/OperationsPageSurface.tsx`
61. `frontend-modern/src/features/operations/operationsPageModel.ts`
62. `frontend-modern/src/pages/Operations.tsx`
63. `frontend-modern/src/components/Settings/ResourcePicker.tsx`
64. `frontend-modern/src/components/Settings/reportingResourceTypes.ts`
65. `frontend-modern/src/utils/reportableResourceTypes.ts`
66. `frontend-modern/src/utils/reportingResourceTypes.ts`
67. `frontend-modern/src/utils/problemResourcePresentation.ts`
68. `frontend-modern/src/utils/dashboardEmptyStatePresentation.ts`
69. `frontend-modern/src/utils/dashboardGuestPresentation.ts`
70. `frontend-modern/src/utils/dashboardKpiPresentation.ts`
71. `frontend-modern/src/utils/dashboardTrendPresentation.ts`
72. `frontend-modern/src/components/Toast/Toast.tsx`
73. `frontend-modern/src/utils/toast.ts`
74. `frontend-modern/src/utils/semanticTonePresentation.ts`
75. `frontend-modern/src/utils/emptyStatePresentation.ts`
76. `frontend-modern/src/utils/typeColumnPresentation.ts`
77. `frontend-modern/src/pages/__tests__/Operations.helpers.test.ts`
78. `frontend-modern/src/components/Settings/NetworkDiscoverySection.tsx`
79. `frontend-modern/src/components/Settings/NetworkBoundarySettingsSection.tsx`
80. `frontend-modern/src/components/Settings/networkSettingsModel.ts`
81. `frontend-modern/src/components/Settings/useDiscoverySettingsState.ts`
82. `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`
83. `frontend-modern/src/components/Settings/settingsPanelRegistryContext.tsx`
84. `frontend-modern/src/components/Settings/settingsPanelRegistryLoaders.ts`
85. `frontend-modern/src/components/Settings/settingsNavigationModel.ts`
86. `frontend-modern/src/components/Settings/settingsNavCatalog.ts`
87. `frontend-modern/src/components/Settings/settingsNavVisibility.ts`
88. `frontend-modern/src/components/Settings/settingsRouting.ts`
89. `frontend-modern/src/components/Settings/settingsTabSaveBehavior.ts`
90. `frontend-modern/src/components/Settings/settingsTypes.ts`
91. `frontend-modern/src/components/Settings/useSettingsNavigation.ts`
92. `frontend-modern/src/components/Settings/useSettingsPanelRegistry.tsx`
93. `frontend-modern/src/components/Settings/useSettingsSystemPanels.tsx`
94. `frontend-modern/src/components/Settings/DockerRuntimeSettingsCard.tsx`
95. `frontend-modern/src/components/shared/EnvironmentLockBadge.tsx`
96. `frontend-modern/src/utils/environmentLockPresentation.ts`
97. `frontend-modern/src/utils/docsLinks.ts`
98. `tests/integration/tests/20-local-doc-links.spec.ts`
25. `frontend-modern/src/utils/discoveryPresentation.ts`
26. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`
27. `frontend-modern/src/components/Settings/NetworkSettingsPanel.tsx`
28. `frontend-modern/src/components/Settings/RecoverySettingsPanel.tsx`
29. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx`
30. `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx`
31. `frontend-modern/src/components/Settings/settingsHeaderMeta.ts`
32. `frontend-modern/src/components/Settings/selfHostedBillingPresentation.ts`
33. `frontend-modern/src/components/Settings/SSOProvidersPanel.tsx`
34. `frontend-modern/src/components/Settings/useAISettingsState.ts`
35. `frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts`
36. `frontend-modern/src/components/Settings/useSettingsShellState.ts`
37. `frontend-modern/src/components/Settings/useSSOProvidersState.ts`
38. `frontend-modern/src/components/Settings/ssoProvidersModel.ts`
39. `frontend-modern/src/utils/ssoProviderPresentation.ts`
40. `frontend-modern/src/utils/systemSettingsPresentation.ts`
41. `frontend-modern/src/utils/aiSettingsPresentation.ts`
42. `frontend-modern/src/utils/settingsShellPresentation.ts`
43. `frontend-modern/src/utils/textPresentation.ts`
44. `frontend-modern/src/components/Settings/UpdateInstallGuide.tsx`
45. `frontend-modern/src/components/Settings/updatesSettingsModel.ts`
46. `frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx`
47. `frontend-modern/src/components/Settings/ReportingPanel.tsx`
48. `frontend-modern/src/components/Settings/reportingPanelModel.ts`
49. `frontend-modern/src/components/Settings/reportingInventoryExportModel.ts`
50. `frontend-modern/src/components/Settings/useReportingPanelState.ts`
51. `frontend-modern/src/utils/reportingPresentation.ts`
52. `frontend-modern/src/utils/updatesPresentation.ts`
53. `frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts`
54. `tests/integration/tests/15-settings-shell-consistency.spec.ts`
55. `frontend-modern/src/components/shared/PageControls.guardrails.test.ts`
56. `frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts`
57. `frontend-modern/src/features/`
58. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx`
59. `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`
60. `frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx`
61. `frontend-modern/src/components/SetupWizard/steps/WelcomeStep.tsx`
62. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx`
63. `frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx`
64. `frontend-modern/src/components/SetupWizard/__tests__/WelcomeStep.test.tsx`
65. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx`
66. `frontend-modern/src/components/Settings/SystemLogsPanel.tsx`
67. `frontend-modern/src/components/Settings/useSystemLogsPanelState.ts`
68. `frontend-modern/src/utils/systemLogsPresentation.ts`
69. `frontend-modern/src/components/Settings/__tests__/SystemLogsPanel.test.tsx`
70. `frontend-modern/src/features/operations/OperationsPageSurface.tsx`
71. `frontend-modern/src/features/operations/operationsPageModel.ts`
72. `frontend-modern/src/pages/Operations.tsx`
73. `frontend-modern/src/components/Settings/ResourcePicker.tsx`
74. `frontend-modern/src/components/Settings/reportingResourceTypes.ts`
75. `frontend-modern/src/utils/reportableResourceTypes.ts`
76. `frontend-modern/src/utils/reportingResourceTypes.ts`
77. `frontend-modern/src/utils/problemResourcePresentation.ts`
78. `frontend-modern/src/utils/dashboardEmptyStatePresentation.ts`
79. `frontend-modern/src/utils/dashboardGuestPresentation.ts`
80. `frontend-modern/src/utils/dashboardKpiPresentation.ts`
81. `frontend-modern/src/utils/dashboardTrendPresentation.ts`
82. `frontend-modern/src/components/Toast/Toast.tsx`
83. `frontend-modern/src/utils/toast.ts`
84. `frontend-modern/src/utils/semanticTonePresentation.ts`
85. `frontend-modern/src/utils/emptyStatePresentation.ts`
86. `frontend-modern/src/utils/typeColumnPresentation.ts`
87. `frontend-modern/src/pages/__tests__/Operations.helpers.test.ts`
88. `frontend-modern/src/components/Settings/NetworkDiscoverySection.tsx`
89. `frontend-modern/src/components/Settings/NetworkBoundarySettingsSection.tsx`
90. `frontend-modern/src/components/Settings/networkSettingsModel.ts`
91. `frontend-modern/src/components/Settings/useDiscoverySettingsState.ts`
92. `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`
93. `frontend-modern/src/components/Settings/settingsPanelRegistryContext.tsx`
94. `frontend-modern/src/components/Settings/settingsPanelRegistryLoaders.ts`
95. `frontend-modern/src/components/Settings/settingsNavigationModel.ts`
96. `frontend-modern/src/components/Settings/settingsNavCatalog.ts`
97. `frontend-modern/src/components/Settings/settingsNavVisibility.ts`
98. `frontend-modern/src/components/Settings/settingsRouting.ts`
99. `frontend-modern/src/components/Settings/settingsTabSaveBehavior.ts`
100. `frontend-modern/src/components/Settings/settingsTypes.ts`
101. `frontend-modern/src/components/Settings/useSettingsNavigation.ts`
102. `frontend-modern/src/components/Settings/useSettingsPanelRegistry.tsx`
103. `frontend-modern/src/components/Settings/useSettingsSystemPanels.tsx`
104. `frontend-modern/src/components/Settings/DockerRuntimeSettingsCard.tsx`
105. `frontend-modern/src/components/shared/EnvironmentLockBadge.tsx`
106. `frontend-modern/src/utils/environmentLockPresentation.ts`
107. `frontend-modern/src/utils/docsLinks.ts`
108. `tests/integration/tests/20-local-doc-links.spec.ts`
## Shared Boundaries
@ -200,86 +200,87 @@ work extends shared components instead of creating new local variants.
must keep a route-owned canonical option visible in shared selects like
`LabeledFilterSelect` even when current results do not contain that
option, so provider-scoped handoffs do not flash back to `All`.
10. Keep the first welcome screen in
`frontend-modern/src/components/SetupWizard/steps/WelcomeStep.tsx`
explicit about operator context. The shell must explain that the bootstrap
token only unlocks first-run setup, state where the command should run, and
adapt command/help text to detected Docker or containerized deployments
instead of assuming the operator already knows which host or container owns
the Pulse install.
11. Keep the settings-shell infrastructure landing path aligned with that same
first-session story. `frontend-modern/src/components/Settings/settingsNavigationModel.ts`
must treat `/settings` and the infrastructure settings tab as the canonical
path to `/settings/infrastructure/install`, not to reporting/control, so
the shell does not send first-time operators to the wrong infrastructure
subview by default.
12. Keep dashboard onboarding copy on the shared presentation owner in
`frontend-modern/src/utils/dashboardEmptyStatePresentation.ts`. Both the
infrastructure empty state and the dashboard route's no-resources state
must name the canonical install workspace explicitly, keep `Platform
connections` visible as the API-backed alternative for Proxmox and
TrueNAS, and expose the same first-host next step instead of falling back
to passive “nothing here yet” wording.
13. Keep cross-surface investigation handoffs on shared route ownership.
14. Keep shared summary-card emphasis coherent. When shared summary primitives enter an `inactive` state, `SummaryMetricCard`, `InteractiveSparkline`, and `DensityMap` must all demote background context together so storage, infrastructure, and workloads read as one interaction model instead of mixing page-local opacity, sticky-shell, or highlight rules.
11. Keep the first welcome screen in
`frontend-modern/src/components/SetupWizard/steps/WelcomeStep.tsx`
explicit about operator context. The shell must explain that the bootstrap
token only unlocks first-run setup, state where the command should run, and
adapt command/help text to detected Docker or containerized deployments
instead of assuming the operator already knows which host or container owns
the Pulse install.
12. Keep the settings-shell infrastructure landing path aligned with that same
first-session story. `frontend-modern/src/components/Settings/settingsNavigationModel.ts`
must treat `/settings` and the infrastructure settings tab as the canonical
path to `/settings/infrastructure/install`, not to reporting/control, so
the shell does not send first-time operators to the wrong infrastructure
subview by default.
13. Keep dashboard onboarding copy on the shared presentation owner in
`frontend-modern/src/utils/dashboardEmptyStatePresentation.ts`. Both the
infrastructure empty state and the dashboard route's no-resources state
must name the canonical install workspace explicitly, keep `Platform
connections` visible as the API-backed alternative for Proxmox and
TrueNAS, and expose the same first-host next step instead of falling back
to passive “nothing here yet” wording.
14. Keep cross-surface investigation handoffs on shared route ownership.
Feature shells such as Alerts and Patrol may decide which governed
destination chips to render, but canonical href, label, dedupe, and
infrastructure-fallback truth must stay in
`frontend-modern/src/routing/resourceLinks.ts` instead of freezing raw
route strings or provider-local link builders inside feature panels.
15. Keep shared contextual focus canonical after adoption. Once a summary or table surface enters route-backed contextual focus, future additions must extend `frontend-modern/src/components/shared/contextualFocus.ts` and its guardrail tests rather than forking another helper for workload IDs, resource IDs, or scroll-preserving same-route selection.
14. Keep shared infrastructure/resource selectors on the canonical agent-facet
15. Keep shared summary-card emphasis coherent. When shared summary primitives enter an `inactive` state, `SummaryMetricCard`, `InteractiveSparkline`, and `DensityMap` must all demote background context together so storage, infrastructure, and workloads read as one interaction model instead of mixing page-local opacity, sticky-shell, or highlight rules.
16. Keep density-map summaries overview-first. When a shared summary density map receives row focus or chart-hover emphasis, `frontend-modern/src/components/shared/DensityMap.tsx`, `frontend-modern/src/components/shared/useDensityMapState.ts`, and `frontend-modern/src/components/shared/densityMapModel.ts` must preserve the multi-entity overview rows and layer focused-entity detail inside the same card instead of swapping the card into a single-series chart or dimming the rest of the map into unusable background noise.
17. Keep shared contextual focus canonical after adoption. Once a summary or table surface enters route-backed contextual focus, future additions must extend `frontend-modern/src/components/shared/contextualFocus.ts` and its guardrail tests rather than forking another helper for workload IDs, resource IDs, or scroll-preserving same-route selection.
18. Keep shared infrastructure/resource selectors on the canonical agent-facet
truth. Shared primitives and settings-facing selector helpers must treat
top-level TrueNAS appliances as agent-facet infrastructure via shared
helper ownership instead of reviving a direct `resource.type === 'truenas'`
branch inside page shells, selectors, or reporting-resource type helpers.
15. Keep shared feature-shell Patrol run fixtures on the canonical run-record
19. Keep shared feature-shell Patrol run fixtures on the canonical run-record
contract. When `frontend-modern/src/features/patrol/` consumes Patrol run
history, the shared normalized record must preserve provider-backed counts
such as `truenas_checked` instead of letting feature-local fixtures or
fallback objects collapse API-backed TrueNAS systems back into generic
agent-host presentation.
16. Keep the authenticated app root aligned with that same first-session path.
That same shared-primitive ownership now includes contextual row focus.
`frontend-modern/src/components/shared/contextualFocus.ts` is the canonical
owner for interactive-series filtering, focused-label lookup, active-series
resolution, and nearest-scrollable-ancestor preservation across page-scoped
summary surfaces. Dashboard row focus, infrastructure summary emphasis,
storage summary emphasis, and workloads summary emphasis must all route through
that helper instead of maintaining page-local copies of the same hover/focus
rules.
20. Keep the authenticated app root aligned with that same first-session path.
That same shared-primitive ownership now includes contextual row focus.
`frontend-modern/src/components/shared/contextualFocus.ts` is the canonical
owner for interactive-series filtering, focused-label lookup, active-series
resolution, and nearest-scrollable-ancestor preservation across page-scoped
summary surfaces. Dashboard row focus, infrastructure summary emphasis,
storage summary emphasis, and workloads summary emphasis must all route through
that helper instead of maintaining page-local copies of the same hover/focus
rules.
`frontend-modern/src/App.tsx` must land `/` on the dashboard shell and let
the governed dashboard empty state route first-time operators into
Infrastructure Install, instead of preserving a separate root-only jump to
`/infrastructure` that drifts from the rest of the onboarding contract.
17. Keep relay settings shell copy on the shared presentation owner in
21. Keep relay settings shell copy on the shared presentation owner in
`frontend-modern/src/utils/relayPresentation.ts`. The route metadata in
`settingsHeaderMeta.ts` and the leading `SettingsPanel` in
`RelaySettingsPanel.tsx` must reuse the same description and availability
copy instead of drifting into separate rollout or pairing wording.
15. Keep shared settings-shell legal and docs referrals on
22. Keep shared settings-shell legal and docs referrals on
`frontend-modern/src/utils/docsLinks.ts`. Shared settings surfaces such as
`AIRuntimeControlsSection.tsx` must not hardcode GitHub `main` doc URLs for
privacy, security, proxy-auth, scope-reference, or Terms-of-Service links.
16. Keep shared settings-shell telemetry transparency controls on the governed
23. Keep shared settings-shell telemetry transparency controls on the governed
general settings panel. Preview/reset affordances for anonymous telemetry
must stay rendered inside
`frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`
instead of drifting into route-local modals, hidden dev tools, or shell
chrome that operators would not naturally inspect.
15. Keep the short telemetry/privacy summary copy on that same shared surface
24. Keep the short telemetry/privacy summary copy on that same shared surface
accurate to the governed privacy doc. If the trust boundary depends on a
specific retention window or on “IP addresses are not stored” rather than
“IPs are never seen,” the summary copy in
`GeneralSettingsPanel.tsx` must state those facts plainly instead of
reverting to a stronger but inaccurate shorthand.
16. Keep shared storage-route feature presentation on neutral capability truth.
25. Keep shared storage-route feature presentation on neutral capability truth.
Reusable mappers and presenters in `frontend-modern/src/features/storageBackups/`
must distinguish inventory datastores from backup repositories so VMware
rows on the shared storage route stay canonical to the admitted phase-1 floor instead of
reviving backup-target, protected-target, or recovery-local semantics on a
shared page.
16. Keep infrastructure settings-shell API alternatives on the shared shell
26. Keep infrastructure settings-shell API alternatives on the shared shell
contract. `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`,
`frontend-modern/src/components/Settings/settingsHeaderMeta.ts`,
`frontend-modern/src/components/Settings/settingsNavigationModel.ts`, and
@ -287,7 +288,7 @@ rules.
present `Platform connections` as the canonical API-backed alternative for
Proxmox, TrueNAS, and future provider integrations instead of reviving
top-level `Direct Proxmox` wording or shell-local provider routes.
17. Keep the infrastructure settings platform-connections summary and provider
27. Keep the infrastructure settings platform-connections summary and provider
workspaces on one shared state source. `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`,
`frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`,
`frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`,
@ -295,14 +296,14 @@ rules.
derive TrueNAS connection counts and availability from the shared
infrastructure settings state instead of letting the reporting summary and
the provider-specific panel issue separate connection fetches.
18. Keep alert-history feature composition on the current owned state contract.
28. Keep alert-history feature composition on the current owned state contract.
`frontend-modern/src/features/alerts/tabs/HistoryTab.tsx` must react to the
shared `alertData()` history state instead of reviving deleted aliases, and
it must pass unified-resource resolution through to
`frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx` so
the panel can render shared route chips without creating another page-local
resource lookup or provider-specific handoff layer.
19. Keep the alert-thresholds containers surface on the canonical shared owner.
29. Keep the alert-thresholds containers surface on the canonical shared owner.
`alertOverridesModel.ts`, `useAlertOverridesState.ts`, and
`useAlertsConfigurationState.ts` must surface API-backed `app-container`
parents such as TrueNAS as first-class `Container Runtimes`, while

View file

@ -90,35 +90,35 @@ regression protection.
68. `frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx`
69. `frontend-modern/src/components/Workloads/WorkloadsSummary.tsx`
70. `frontend-modern/src/utils/throughputPresentation.ts`
69. `frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx`
70. `frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx`
71. `frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts`
72. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts`
73. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts`
74. `frontend-modern/src/components/Dashboard/__tests__/Dashboard.performance.contract.test.tsx`
75. `frontend-modern/src/components/Dashboard/__tests__/DashboardFilter.test.tsx`
76. `frontend-modern/src/components/Dashboard/__tests__/useDashboardFilterState.test.ts`
77. `frontend-modern/src/components/Dashboard/__tests__/useDashboardSelectionState.test.ts`
78. `frontend-modern/src/components/Dashboard/MetricBar.test.tsx`
79. `frontend-modern/src/components/Dashboard/__tests__/useMetricBarState.test.tsx`
80. `frontend-modern/src/components/Dashboard/__tests__/EnhancedCPUBar.test.tsx`
81. `frontend-modern/src/components/Dashboard/__tests__/useEnhancedCPUBarState.test.tsx`
82. `frontend-modern/src/components/Dashboard/ThresholdSlider.test.tsx`
83. `frontend-modern/src/components/Dashboard/__tests__/useThresholdSliderState.test.ts`
84. `frontend-modern/src/components/Dashboard/__tests__/StackedDiskBar.test.tsx`
85. `frontend-modern/src/components/Dashboard/__tests__/useStackedDiskBarState.test.tsx`
86. `frontend-modern/src/components/Dashboard/StackedMemoryBar.test.tsx`
87. `frontend-modern/src/components/Dashboard/__tests__/useStackedMemoryBarState.test.tsx`
88. `frontend-modern/src/components/Dashboard/__tests__/DiskList.test.tsx`
89. `frontend-modern/src/components/Dashboard/__tests__/GuestRow.test.tsx`
90. `frontend-modern/src/components/Dashboard/GuestDrawer.test.tsx`
91. `frontend-modern/src/components/Dashboard/__tests__/useGroupedTableWindowing.test.ts`
92. `frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx`
93. `frontend-modern/src/components/Dashboard/useDashboardWorkloadViewportSync.ts`
94. `frontend-modern/src/components/Dashboard/__tests__/useDashboardWorkloadViewportSync.test.tsx`
95. `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx`
96. `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`
97. `frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts`
71. `frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx`
72. `frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx`
73. `frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts`
74. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts`
75. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts`
76. `frontend-modern/src/components/Dashboard/__tests__/Dashboard.performance.contract.test.tsx`
77. `frontend-modern/src/components/Dashboard/__tests__/DashboardFilter.test.tsx`
78. `frontend-modern/src/components/Dashboard/__tests__/useDashboardFilterState.test.ts`
79. `frontend-modern/src/components/Dashboard/__tests__/useDashboardSelectionState.test.ts`
80. `frontend-modern/src/components/Dashboard/MetricBar.test.tsx`
81. `frontend-modern/src/components/Dashboard/__tests__/useMetricBarState.test.tsx`
82. `frontend-modern/src/components/Dashboard/__tests__/EnhancedCPUBar.test.tsx`
83. `frontend-modern/src/components/Dashboard/__tests__/useEnhancedCPUBarState.test.tsx`
84. `frontend-modern/src/components/Dashboard/ThresholdSlider.test.tsx`
85. `frontend-modern/src/components/Dashboard/__tests__/useThresholdSliderState.test.ts`
86. `frontend-modern/src/components/Dashboard/__tests__/StackedDiskBar.test.tsx`
87. `frontend-modern/src/components/Dashboard/__tests__/useStackedDiskBarState.test.tsx`
88. `frontend-modern/src/components/Dashboard/StackedMemoryBar.test.tsx`
89. `frontend-modern/src/components/Dashboard/__tests__/useStackedMemoryBarState.test.tsx`
90. `frontend-modern/src/components/Dashboard/__tests__/DiskList.test.tsx`
91. `frontend-modern/src/components/Dashboard/__tests__/GuestRow.test.tsx`
92. `frontend-modern/src/components/Dashboard/GuestDrawer.test.tsx`
93. `frontend-modern/src/components/Dashboard/__tests__/useGroupedTableWindowing.test.ts`
94. `frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx`
95. `frontend-modern/src/components/Dashboard/useDashboardWorkloadViewportSync.ts`
96. `frontend-modern/src/components/Dashboard/__tests__/useDashboardWorkloadViewportSync.test.tsx`
97. `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx`
98. `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`
99. `frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts`
## Shared Boundaries
@ -167,7 +167,7 @@ regression protection.
25. Extend dashboard workload table shell ownership through `frontend-modern/src/components/Dashboard/WorkloadTableHeader.tsx` and `frontend-modern/src/components/Dashboard/WorkloadPanel.tsx` rather than rebuilding sortable header markup, grouped node rows, row expansion, or guest-drawer rendering inside `frontend-modern/src/components/Dashboard/DashboardWorkloadTable.tsx`
26. Keep long-range workload chart capping time-proportional across `frontend-modern/src/components/Workloads/WorkloadsSummary.tsx`, `frontend-modern/src/api/charts.ts`, and `internal/api/router.go`: when the workload hot path caps mixed-cadence history for top cards, it must bucket by time window rather than raw point index so 7-day and 30-day workload cards stay visually even without relaxing the protected payload budget.
27. Keep summary hover/focus and sticky-card behavior on shared hot paths: infrastructure, workloads, and storage summary shells must reuse one focused-series model plus `frontend-modern/src/components/shared/StickySummarySection.tsx` inside the app scroll shell instead of per-page scroll listeners or per-card hover derivations, so row scrubbing highlights all cards without multiplying render or scroll work.
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.
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
@ -214,8 +214,11 @@ same-path route-state scheduler so opening a focused workload preserves scroll
and does not look like a full page reload, and the governed infrastructure and
workloads summary surfaces must keep the summary page-scoped while that focus
reuses the shared highlight contract; density maps may retain page-level
context, but line-card isolation must still flow through the shared sparkline
runtime instead of a page-local focus overlay.
context, but they must now also surface focused-entity detail inside the same
card instead of dimming the rest of the map into unusable background noise or
swapping the hot path into a transient single-series chart. Line-card
isolation must still flow through the shared sparkline runtime instead of a
page-local focus overlay.
That same hot-path rule now has one shared runtime boundary. Interactive-series
filtering, active-series derivation, focused-label lookup, and local
scroll-preserving row focus must extend

View file

@ -8,16 +8,85 @@ export type { DensityMapProps } from './densityMapModel';
export const DensityMap: Component<DensityMapProps> = (props) => {
const densityMap = useDensityMapState(props);
const interactionState = () => props.interactionState ?? 'default';
const formatDetailValue = (value: number | null) =>
value === null ? 'No sample' : densityMap.formatValue(value);
return (
<div
class={`relative w-full h-full flex flex-col group transition-opacity duration-150 ease-out ${
interactionState() === 'inactive' ? 'opacity-35' : 'opacity-100'
}`.trim()}
data-summary-chart-kind="density-map"
data-highlight-series-id={props.highlightSeriesId ?? ''}
data-highlight-series-active={densityMap.externalSeriesIndex() !== null ? 'true' : 'false'}
data-rendered-series-count={densityMap.chartData().series.length}
data-summary-chart-state={interactionState()}
>
<div class="mx-1 mb-1 flex h-7 items-center overflow-hidden">
<Show
when={densityMap.focusDetail()}
fallback={
<div class="w-full truncate text-[9px] font-medium uppercase tracking-[0.12em] text-slate-500/80">
Top activity overview
</div>
}
>
{(detail) => (
<div
class="flex w-full min-w-0 items-center gap-2 text-[10px]"
data-density-map-focus-detail="true"
data-density-map-focus-series-id={detail().seriesId}
>
<div class="flex min-w-0 flex-1 items-center gap-1.5">
<span
class="h-2 w-2 shrink-0 rounded-full"
style={{ background: detail().seriesColor }}
/>
<span class="truncate font-semibold uppercase tracking-[0.1em] text-slate-600 dark:text-slate-300">
{detail().seriesName}
</span>
<Show when={detail().sparklinePath}>
{(path) => (
<svg
viewBox="0 0 64 22"
class="h-4 w-12 shrink-0 overflow-visible text-slate-400"
aria-hidden="true"
>
<path
d={path()}
fill="none"
stroke={detail().seriesColor}
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.75"
/>
</svg>
)}
</Show>
</div>
<div class="flex shrink-0 items-center gap-3 leading-none">
<div class="flex flex-col items-start">
<span class="text-[9px] uppercase tracking-wide text-slate-500 dark:text-slate-400">
Latest
</span>
<span class="mt-0.5 font-semibold text-slate-900 dark:text-slate-50">
{formatDetailValue(detail().currentValue)}
</span>
</div>
<div class="flex flex-col items-start">
<span class="text-[9px] uppercase tracking-wide text-slate-500 dark:text-slate-400">
Peak
</span>
<span class="mt-0.5 font-semibold text-slate-900 dark:text-slate-50">
{formatDetailValue(detail().peakValue)}
</span>
</div>
</div>
</div>
)}
</Show>
</div>
<div class="flex-1 relative min-h-0 bg-transparent flex">
{/* Y-axis: typically in a density map we might just show "Top Nodes" */}
<div class="absolute left-0 top-0 h-full w-full pointer-events-none flex flex-col justify-between py-1 opacity-0 group-hover:opacity-100 transition-opacity">

View file

@ -158,11 +158,13 @@ describe('shared primitive guardrails', () => {
expect(generalSettingsPanelSource).toContain('variant="prominent"');
expect(generalSettingsPanelSource).not.toContain("props.themePreference() === 'light'");
expect(generalSettingsPanelSource).not.toContain("temperatureStore.unit() === 'celsius'");
expect(generalSettingsPanelSource).not.toContain("props.pvePollingSelection() === option.value");
expect(generalSettingsPanelSource).not.toContain(
'props.pvePollingSelection() === option.value',
);
expect(reportingPanelSource.match(/<FilterButtonGroup/g) ?? []).toHaveLength(2);
expect(reportingPanelSource).toContain('variant="prominent"');
expect(reportingPanelSource).not.toContain('getReportingToggleButtonClass');
expect(reportingPanelSource).not.toContain("<For each={REPORTING_RANGE_OPTIONS}>");
expect(reportingPanelSource).not.toContain('<For each={REPORTING_RANGE_OPTIONS}>');
});
it('routes selectable settings cards through SelectionCardGroup', () => {
@ -217,9 +219,7 @@ describe('shared primitive guardrails', () => {
expect(activeUseTrialNudgeSource).not.toContain('localStorage');
expect(activeUseTrialNudgeSource).not.toContain('setInterval');
expect(activeUseTrialNudgeStateSource).toContain(
'export function useActiveUseTrialNudgeState',
);
expect(activeUseTrialNudgeStateSource).toContain('export function useActiveUseTrialNudgeState');
expect(activeUseTrialNudgeStateSource).toContain('createSignal');
expect(activeUseTrialNudgeStateSource).toContain('createMemo');
expect(activeUseTrialNudgeStateSource).toContain('window.localStorage');
@ -243,7 +243,9 @@ 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 const preserveScrollableAncestorVerticalOffset',
);
expect(contextualFocusSource).toContain('export function useSummaryContextualFocusState');
expect(contextualFocusSource).toContain('chartHoveredSeriesId');
expect(summaryCardInteractionSource).toContain('chartHoveredSeriesId');
@ -255,7 +257,9 @@ describe('shared primitive guardrails', () => {
expect(infrastructureSummaryStateSource).toContain('useSummaryContextualFocusState');
expect(infrastructureSummaryStateSource).toContain('chartHoverSync');
expect(infrastructureSummaryStateSource).not.toContain('const interactiveResourceIds = createMemo');
expect(infrastructureSummaryStateSource).not.toContain(
'const interactiveResourceIds = createMemo',
);
expect(storageSummarySource).toContain('useSummaryContextualFocusState');
expect(storageSummarySource).toContain('chartHoverSync');
@ -408,14 +412,12 @@ describe('shared primitive guardrails', () => {
'rounded-md border border-blue-200 bg-blue-50 px-4 py-3',
);
expect(reportingPanelSource).toContain('CalloutCard');
expect(reportingPanelSource).not.toContain(
'rounded-md border border-blue-200 bg-blue-50 p-6',
);
expect(reportingPanelSource).not.toContain('rounded-md border border-blue-200 bg-blue-50 p-6');
});
it('keeps shared fleet limit banner copy on the monitored-system commercial term', () => {
expect(monitoredSystemLimitWarningBannerModelSource).toContain(
"@/utils/monitoredSystemPresentation",
'@/utils/monitoredSystemPresentation',
);
expect(monitoredSystemLimitWarningBannerModelSource).toContain(
'formatMonitoredSystemLimitSummary',
@ -433,9 +435,7 @@ describe('shared primitive guardrails', () => {
expect(monitoredSystemLimitWarningBannerModelSource).not.toContain(
'do not count toward Unified Agents.',
);
expect(monitoredSystemLimitWarningBannerModelSource).not.toContain(
'Install v6 Unified Agents',
);
expect(monitoredSystemLimitWarningBannerModelSource).not.toContain('Install v6 Unified Agents');
});
it('keeps monitored system limit warning banner on shell, runtime, and model owners', () => {
@ -496,7 +496,9 @@ describe('shared primitive guardrails', () => {
expect(infrastructureSummaryTableStateSource).toContain('useWebSocket');
expect(infrastructureSummaryTableStateSource).toContain('useAlertsActivation');
expect(infrastructureSummaryTableStateSource).toContain('export function useInfrastructureSummaryTableState');
expect(infrastructureSummaryTableStateSource).toContain(
'export function useInfrastructureSummaryTableState',
);
expect(infrastructureSummaryTableStateSource).toContain('createSignal');
expect(infrastructureSummaryTableModelSource).toContain('getNormalizedIdentityLookupVariants');
@ -544,7 +546,9 @@ describe('shared primitive guardrails', () => {
expect(interactiveSparklineSource).not.toContain('scheduleSparkline');
expect(interactiveSparklineSource).not.toContain('downsampleLTTB');
expect(interactiveSparklineStateSource).toContain('export function useInteractiveSparklineState');
expect(interactiveSparklineStateSource).toContain(
'export function useInteractiveSparklineState',
);
expect(interactiveSparklineStateSource).toContain('activeSeriesDisplay');
expect(interactiveSparklineStateSource).toContain('shouldRenderSeries');
expect(interactiveSparklineStateSource).toContain('renderedSeriesCount');
@ -570,6 +574,7 @@ describe('shared primitive guardrails', () => {
expect(densityMapStateSource).toContain('window.addEventListener');
expect(densityMapModelSource).toContain('buildDensityMapChartData');
expect(densityMapModelSource).toContain('buildDensityMapFocusDetail');
expect(densityMapModelSource).toContain('buildDensityMapHoveredState');
expect(densityMapModelSource).toContain('formatDensityMapHoverTime');
expect(densityMapModelSource).toContain('getDensityMapCellOpacity');
@ -702,9 +707,7 @@ describe('shared primitive guardrails', () => {
);
expect(infrastructureDetailsDrawerSource).not.toContain('createSignal');
expect(infrastructureDetailsDrawerSource).not.toContain('getInfrastructureMetadataId');
expect(infrastructureDetailsDrawerSource).not.toContain(
'getInfrastructureDiscoveryHostname',
);
expect(infrastructureDetailsDrawerSource).not.toContain('getInfrastructureDiscoveryHostname');
expect(infrastructureDetailsDrawerStateSource).toContain(
'export function useInfrastructureDetailsDrawerState',
@ -721,9 +724,7 @@ describe('shared primitive guardrails', () => {
'resolveInfrastructureDetailsDrawerDiscoveryHostname',
);
expect(infrastructureDetailsDrawerModelSource).toContain('getInfrastructureMetadataId');
expect(infrastructureDetailsDrawerModelSource).toContain(
'getInfrastructureDiscoveryHostname',
);
expect(infrastructureDetailsDrawerModelSource).toContain('getInfrastructureDiscoveryHostname');
});
it('keeps mobile nav on shell, runtime, and model owners', () => {
@ -766,7 +767,9 @@ describe('shared primitive guardrails', () => {
it('keeps search field on shell, runtime, and model owners', () => {
expect(searchFieldSource).toContain('useSearchFieldState');
expect(searchFieldSource).not.toContain('let inputEl: HTMLInputElement');
expect(searchFieldSource).not.toContain("if (props.hasTrailingControls) return 'pr-14 sm:pr-20'");
expect(searchFieldSource).not.toContain(
"if (props.hasTrailingControls) return 'pr-14 sm:pr-20'",
);
expect(searchFieldSource).not.toContain("if (e.key === 'Escape'");
expect(searchFieldStateSource).toContain('export function useSearchFieldState');
@ -875,7 +878,7 @@ describe('shared primitive guardrails', () => {
expect(summaryMetricCardSource).toContain("density?: 'default' | 'compact'");
expect(summaryMetricCardSource).toContain("props.density === 'compact'");
expect(summaryMetricCardSource).toContain("!p-1.5 sm:!p-2");
expect(summaryMetricCardSource).toContain('!p-1.5 sm:!p-2');
expect(summaryMetricCardSource).not.toContain('Recovery Posture');
expect(summaryMetricCardSource).not.toContain('Freshness');
});

View file

@ -6,6 +6,7 @@ import densityMapStateSource from '@/components/shared/useDensityMapState.ts?raw
import { DensityMap } from '@/components/shared/DensityMap';
import {
buildDensityMapChartData,
buildDensityMapFocusDetail,
getDensityMapExternalSeriesIndex,
} from '@/components/shared/densityMapModel';
@ -15,6 +16,8 @@ HTMLCanvasElement.prototype.getContext = vi.fn(() => ({
scale: vi.fn(),
beginPath: vi.fn(),
roundRect: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
stroke: vi.fn(),
strokeRect: vi.fn(),
fill: vi.fn(),
@ -38,6 +41,7 @@ describe('DensityMap', () => {
expect(densityMapStateSource).toContain('export function useDensityMapState');
expect(densityMapModelSource).toContain('buildDensityMapChartData');
expect(densityMapModelSource).toContain('buildDensityMapFocusDetail');
expect(densityMapModelSource).toContain('buildDensityMapHoveredState');
expect(densityMapModelSource).toContain('getDensityMapExternalSeriesIndex');
expect(densityMapModelSource).toContain('getDensityMapCellOpacity');
@ -165,4 +169,88 @@ describe('DensityMap', () => {
expect(chartData.series.map((entry) => entry.id)).toContain('series-24');
expect(getDensityMapExternalSeriesIndex(chartData, 'series-24')).not.toBeNull();
});
it('builds focused detail from the active density-map series without replacing the overview model', () => {
const now = Date.now();
const chartData = buildDensityMapChartData({
now,
timeRange: '1h',
highlightSeriesId: 'alpha',
series: [
{
id: 'alpha',
name: 'Alpha',
color: '#10b981',
data: [
{ timestamp: now - 40_000, value: 12 },
{ timestamp: now - 10_000, value: 32 },
],
},
{
id: 'beta',
name: 'Beta',
color: '#3b82f6',
data: [
{ timestamp: now - 30_000, value: 18 },
{ timestamp: now - 5_000, value: 24 },
],
},
],
});
const detail = buildDensityMapFocusDetail({
data: chartData,
highlightSeriesId: 'alpha',
});
expect(chartData.series).toHaveLength(2);
expect(detail).toMatchObject({
seriesId: 'alpha',
seriesName: 'Alpha',
currentValue: 32,
peakValue: 32,
});
expect(detail?.sparklinePath).toContain('M');
});
it('renders focused detail while keeping the density-map overview series count intact', () => {
const now = Date.now();
const { container } = render(() => (
<DensityMap
timeRange="1h"
highlightSeriesId="alpha"
series={[
{
id: 'alpha',
name: 'Alpha',
color: '#10b981',
data: [
{ timestamp: now - 40_000, value: 12 },
{ timestamp: now - 10_000, value: 32 },
],
},
{
id: 'beta',
name: 'Beta',
color: '#3b82f6',
data: [
{ timestamp: now - 30_000, value: 18 },
{ timestamp: now - 5_000, value: 24 },
],
},
]}
/>
));
const root = container.firstElementChild;
expect(root?.getAttribute('data-summary-chart-kind')).toBe('density-map');
expect(root?.getAttribute('data-rendered-series-count')).toBe('2');
expect(screen.getByText('Alpha')).toBeInTheDocument();
expect(screen.getByText('Latest')).toBeInTheDocument();
expect(screen.getByText('Peak')).toBeInTheDocument();
expect(screen.getAllByText('32.0')).toHaveLength(2);
const overlay = container.querySelector('[data-density-map-focus-detail="true"]');
expect(overlay).not.toBeNull();
expect(overlay).toHaveAttribute('data-density-map-focus-series-id', 'alpha');
});
});

View file

@ -27,6 +27,18 @@ export interface DensityMapHoveredState {
seriesIndex: number;
}
export interface DensityMapFocusDetail {
currentValue: number | null;
currentTimestamp: number | null;
hoveredValue: number | null;
hoveredTimestamp: number | null;
peakValue: number | null;
seriesColor: string;
seriesId: string;
seriesName: string;
sparklinePath: string | null;
}
export interface DensityMapChartData {
series: InteractiveSparklineSeries[];
grid: number[][];
@ -224,3 +236,84 @@ export function buildDensityMapSynchronizedHoveredState(options: {
seriesIndex,
};
}
export function buildDensityMapFocusDetail(options: {
activeHoveredState?: DensityMapHoveredState | null;
data: DensityMapChartData;
highlightSeriesId?: string | null;
}): DensityMapFocusDetail | null {
const activeHoveredState = options.activeHoveredState ?? null;
const seriesIndex =
activeHoveredState?.seriesIndex ??
getDensityMapExternalSeriesIndex(options.data, options.highlightSeriesId);
if (seriesIndex === null || seriesIndex < 0 || seriesIndex >= options.data.series.length) {
return null;
}
const series = options.data.series[seriesIndex];
const seriesId = series.id?.trim() || '';
if (!seriesId) {
return null;
}
const windowEnd = options.data.windowStart + options.data.rangeMs;
const points = series.data.filter(
(point) => point.timestamp >= options.data.windowStart && point.timestamp <= windowEnd,
);
const currentPoint = points.length > 0 ? points[points.length - 1] : null;
let peakValue: number | null = null;
for (const point of points) {
peakValue = peakValue === null ? point.value : Math.max(peakValue, point.value);
}
const sparklinePath = buildDensityMapFocusSparklinePath({
points,
rangeMs: options.data.rangeMs,
windowStart: options.data.windowStart,
});
return {
currentValue: currentPoint?.value ?? null,
currentTimestamp: currentPoint?.timestamp ?? null,
hoveredValue: activeHoveredState?.value ?? null,
hoveredTimestamp: activeHoveredState?.timestamp ?? null,
peakValue,
seriesColor: series.color,
seriesId,
seriesName: series.name || 'Unknown',
sparklinePath,
};
}
const buildDensityMapFocusSparklinePath = (options: {
points: Array<{ timestamp: number; value: number }>;
rangeMs: number;
windowStart: number;
}): string | null => {
const { points, rangeMs, windowStart } = options;
if (points.length < 2 || rangeMs <= 0) {
return null;
}
const width = 64;
const height = 22;
let maxValue = 0;
for (const point of points) {
if (point.value > maxValue) {
maxValue = point.value;
}
}
if (maxValue <= 0) {
return null;
}
const commands: string[] = [];
for (let index = 0; index < points.length; index += 1) {
const point = points[index];
const x = clampDensityMapValue((point.timestamp - windowStart) / rangeMs, 0, 1) * width;
const y = height - clampDensityMapValue(point.value / maxValue, 0, 1) * height;
commands.push(`${index === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`);
}
return commands.join(' ');
};

View file

@ -1,6 +1,7 @@
import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
import {
buildDensityMapChartData,
buildDensityMapFocusDetail,
getDensityMapExternalSeriesIndex,
buildDensityMapHoveredState,
buildDensityMapSynchronizedHoveredState,
@ -43,6 +44,13 @@ export function useDensityMapState(props: DensityMapProps) {
const activeHoveredState = createMemo<DensityMapHoveredState | null>(() => {
return hoveredState() ?? synchronizedHoveredState();
});
const focusDetail = createMemo(() =>
buildDensityMapFocusDetail({
activeHoveredState: activeHoveredState(),
data: chartData(),
highlightSeriesId: props.highlightSeriesId,
}),
);
const formatValue = (value: number) => formatDensityMapValue(value, props.formatValue);
@ -78,17 +86,21 @@ export function useDensityMapState(props: DensityMapProps) {
activeSeriesIndex !== null && activeSeriesIndex >= 0 && activeSeriesIndex < data.series.length
? data.series[activeSeriesIndex]
: null;
const hasActiveSeries = activeSeriesIndex !== null;
for (let row = 0; row < rows; row += 1) {
const cellY = row * cellHeight;
const isDimmed = activeSeriesIndex !== null && row !== activeSeriesIndex;
const isActiveRow = activeSeriesIndex === row;
const rowOpacity = !hasActiveSeries ? 1 : isActiveRow ? 1 : 0.42;
for (let column = 0; column < DENSITY_MAP_COLUMNS; column += 1) {
const cellX = column * cellWidth;
const value = data.grid[row][column];
if (value <= 0) {
context.globalAlpha = (isDimmed ? 0.08 : 1) * interactionOpacity;
context.fillStyle = 'rgba(128, 128, 128, 0.05)';
context.globalAlpha = rowOpacity * interactionOpacity;
context.fillStyle = isActiveRow
? 'rgba(148, 163, 184, 0.08)'
: 'rgba(148, 163, 184, 0.04)';
context.fillRect(
cellX + DENSITY_MAP_PADDING_X / 2,
cellY + DENSITY_MAP_PADDING_Y / 2,
@ -99,9 +111,7 @@ export function useDensityMapState(props: DensityMapProps) {
}
context.globalAlpha =
getDensityMapCellOpacity(value, data.globalMax) *
(isDimmed ? 0.05 : 1) *
interactionOpacity;
getDensityMapCellOpacity(value, data.globalMax) * rowOpacity * interactionOpacity;
context.fillStyle = data.series[row].color;
if (context.roundRect) {
context.beginPath();
@ -141,10 +151,10 @@ export function useDensityMapState(props: DensityMapProps) {
context.lineWidth = 1.25;
if (context.roundRect) {
context.beginPath();
context.roundRect(0.5, highlightY+0.5, width-1, Math.max(cellHeight-1, 1), 4);
context.roundRect(0.5, highlightY + 0.5, width - 1, Math.max(cellHeight - 1, 1), 4);
context.stroke();
} else {
context.strokeRect(0.5, highlightY+0.5, width-1, Math.max(cellHeight-1, 1));
context.strokeRect(0.5, highlightY + 0.5, width - 1, Math.max(cellHeight - 1, 1));
}
context.restore();
}
@ -201,6 +211,7 @@ export function useDensityMapState(props: DensityMapProps) {
activeHoveredState,
chartData,
externalSeriesIndex,
focusDetail,
formatValue,
handleMouseLeave,
handleMouseMove,

View file

@ -172,7 +172,9 @@ async function hoverSummaryChartUntilActiveId(
(node) =>
node.getAttribute("data-highlight-series-active") === "true",
)
.map((node) => node.getAttribute("data-highlight-series-id") || "")
.map(
(node) => node.getAttribute("data-highlight-series-id") || "",
)
.filter(Boolean),
),
),
@ -205,6 +207,47 @@ async function expectActiveIsolatedLineCards(
.toBe(expectedCount);
}
async function expectActiveDensityMapsPreserveOverview(
summary: import("@playwright/test").Locator,
resourceId: string,
expectedCount: number,
): Promise<void> {
await expect
.poll(async () =>
summary
.locator('[data-summary-chart-kind="density-map"]')
.evaluateAll((nodes, expectedId) => {
const activeNodes = nodes.filter(
(node) =>
node.getAttribute("data-highlight-series-id") === expectedId &&
node.getAttribute("data-highlight-series-active") === "true",
);
return {
activeCount: activeNodes.length,
focusDetailCount: activeNodes.filter((node) => {
const detail = node.querySelector(
'[data-density-map-focus-detail="true"]',
);
return (
detail?.getAttribute("data-density-map-focus-series-id") ===
expectedId
);
}).length,
preservedOverview: activeNodes.every(
(node) =>
Number(node.getAttribute("data-rendered-series-count") || "0") >
1,
),
};
}, resourceId),
)
.toEqual({
activeCount: expectedCount,
focusDetailCount: expectedCount,
preservedOverview: true,
});
}
async function readSummarySeriesId(
row: import("@playwright/test").Locator,
fallbackAttribute: string,
@ -265,16 +308,14 @@ test.describe.serial("Summary hover selection", () => {
);
const firstInfrastructureRow = infrastructureRows.first();
await expect(firstInfrastructureRow).toBeVisible();
const {
index: infrastructureRowIndex,
resourceId: infrastructureRowId,
} = await hoverRowUntilSummaryHighlights(
page,
infrastructureRows,
infrastructureSummary,
"data-row-id",
4,
);
const { index: infrastructureRowIndex, resourceId: infrastructureRowId } =
await hoverRowUntilSummaryHighlights(
page,
infrastructureRows,
infrastructureSummary,
"data-row-id",
4,
);
const infrastructureRow = infrastructureRows.nth(infrastructureRowIndex);
expect(infrastructureRowId).not.toBe("");
await expectActiveIsolatedLineCards(infrastructureSummary, 2);
@ -286,6 +327,11 @@ test.describe.serial("Summary hover selection", () => {
4,
);
await expectActiveIsolatedLineCards(infrastructureSummary, 2);
await expectActiveDensityMapsPreserveOverview(
infrastructureSummary,
infrastructureRowId,
2,
);
await page.goto("/workloads", { waitUntil: "domcontentloaded" });
const workloadsSummary = page.getByTestId("workloads-summary");
@ -302,6 +348,11 @@ test.describe.serial("Summary hover selection", () => {
);
expect(workloadRowId).not.toBe("");
await expectActiveIsolatedLineCards(workloadsSummary, 2);
await expectActiveDensityMapsPreserveOverview(
workloadsSummary,
workloadRowId,
2,
);
const vmwareWorkloadRow = page
.locator('tr[data-guest-id^="vm-"]', { hasText: "warehouse-api-01" })
@ -315,6 +366,11 @@ test.describe.serial("Summary hover selection", () => {
await dispatchRowHover(vmwareWorkloadRow);
await expectSummaryHighlightCount(workloadsSummary, vmwareWorkloadId, 4);
await expectActiveIsolatedLineCards(workloadsSummary, 2);
await expectActiveDensityMapsPreserveOverview(
workloadsSummary,
vmwareWorkloadId,
2,
);
await page.goto("/storage", { waitUntil: "domcontentloaded" });
const storageSummary = page.getByTestId("storage-summary");
@ -330,13 +386,14 @@ test.describe.serial("Summary hover selection", () => {
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,
);
const { resourceId: storagePoolRowId } =
await hoverRowUntilSummaryHighlights(
page,
storagePoolRows,
storageSummary,
"data-row-id",
3,
);
expect(storagePoolRowId).not.toBe("");
await expectActiveIsolatedLineCards(storageSummary, 3);
@ -344,13 +401,14 @@ test.describe.serial("Summary hover selection", () => {
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,
);
const { resourceId: storageDiskRowId } =
await hoverRowUntilSummaryHighlights(
page,
storageDiskRows,
storageSummary,
"data-row-id",
1,
);
expect(storageDiskRowId).not.toBe("");
await expectActiveIsolatedLineCards(storageSummary, 1);
@ -377,7 +435,9 @@ test.describe.serial("Summary hover selection", () => {
const infrastructureChartId = await hoverSummaryChartUntilActiveId(
page,
infrastructureSummary,
infrastructureSummary.locator('[data-active-series-display="isolate"]').first(),
infrastructureSummary
.locator('[data-active-series-display="isolate"]')
.first(),
);
expect(infrastructureChartId).not.toBe("");
await expectSummaryHighlightCount(
@ -386,6 +446,11 @@ test.describe.serial("Summary hover selection", () => {
4,
);
await expectActiveIsolatedLineCards(infrastructureSummary, 2);
await expectActiveDensityMapsPreserveOverview(
infrastructureSummary,
infrastructureChartId,
2,
);
await page.goto("/workloads", { waitUntil: "domcontentloaded" });
const workloadsSummary = page.getByTestId("workloads-summary");
@ -393,11 +458,18 @@ test.describe.serial("Summary hover selection", () => {
const workloadChartId = await hoverSummaryChartUntilActiveId(
page,
workloadsSummary,
workloadsSummary.locator('[data-active-series-display="isolate"]').first(),
workloadsSummary
.locator('[data-active-series-display="isolate"]')
.first(),
);
expect(workloadChartId).not.toBe("");
await expectSummaryHighlightCount(workloadsSummary, workloadChartId, 4);
await expectActiveIsolatedLineCards(workloadsSummary, 2);
await expectActiveDensityMapsPreserveOverview(
workloadsSummary,
workloadChartId,
2,
);
await page.goto("/storage", { waitUntil: "domcontentloaded" });
const storageSummary = page.getByTestId("storage-summary");