mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Stabilize nav tab identity across websocket ticks
The primaryTabs/utilityTabs memos in AppLayout rebuild their arrays from live store reads; activeAlerts is replaced wholesale whenever a state frame carries it, so every reference-keyed <For> consumer (desktop tab strip and the mobile bottom bar) tore down and recreated all nav buttons even when nothing visible changed. Probed on the 50-node mock estate: 3 of 4 mobile bottom-bar buttons and 4 of 10 desktop tabs were detached within 40s of websocket frames; taps landing mid-rebuild die silently. Reuse previous tab object references (and the previous array identity) when a rebuilt list is structurally unchanged, so downstream <For>s keep their DOM. With the fix the same 40s probe keeps every nav element connected on both form factors. MobileNavBar's proof suite now pins the DOM-identity behavior and App.architecture pins the stabilizer wiring; contract notes recorded in frontend-primitives, cloud-paid, and ai-runtime.
This commit is contained in:
@@ -3430,7 +3430,11 @@ query...`, and `Reading storage...` before streamed tool arguments are
|
||||
the shared AI-aware chrome by virtue of routing through `AppLayout`, and
|
||||
must not introduce a parallel chat surface, launcher button, or model
|
||||
picker on the platform page itself; cross-platform AI guidance stays
|
||||
routed through Assistant and Patrol.
|
||||
routed through Assistant and Patrol. The Patrol utility destination in
|
||||
`AppLayout`'s tab lists shares the nav identity-stability contract owned
|
||||
by `frontend-primitives` (`stableNavTabs.ts`): a websocket state frame
|
||||
with structurally unchanged tab content must not recreate the Patrol
|
||||
button DOM or its attention-count badge element.
|
||||
The primary-nav demotion of Infrastructure / Workloads / Storage /
|
||||
Recovery does not change Patrol or Assistant addressability: both
|
||||
surfaces remain reachable through the utility tab strip in
|
||||
|
||||
@@ -629,7 +629,12 @@ its independent operational-attention count. Action authority and result truth
|
||||
remain in the unified-resources and api-contracts projections. This
|
||||
information-architecture choice is independent of hosted entitlement, billing,
|
||||
and acquisition state. Navigation proof is owned by the App/AppLayout, routing,
|
||||
mobile navigation, and desktop Actions journey tests.
|
||||
mobile navigation, and desktop Actions journey tests. AppLayout's primary and
|
||||
utility tab arrays are identity-stabilized through the shared
|
||||
`frontend-modern/src/components/shared/stableNavTabs.ts` helper (see
|
||||
`frontend-primitives`): navigation destinations, including the Actions utility
|
||||
tab and its pending-approval badge, must not have their DOM recreated by a
|
||||
websocket state frame whose tab content is structurally unchanged.
|
||||
|
||||
1. Add or change limits through `pkg/licensing/`
|
||||
2. Add or change hosted entitlement issuance through `internal/cloudcp/entitlements/service.go`
|
||||
|
||||
@@ -66,6 +66,17 @@ four-row phone node preview before the guest list instead of visually moving
|
||||
the nodes after the guest list's full virtual scroll extent; revealing the full
|
||||
node estate remains an explicit table-preview action.
|
||||
|
||||
App-shell navigation tab lists rendered through reference-keyed `<For>`
|
||||
consumers keep stable item identity across websocket state frames. AppLayout
|
||||
derives its primary and utility tab arrays through the shared
|
||||
`frontend-modern/src/components/shared/stableNavTabs.ts` reuse helper, which
|
||||
returns previous tab object references (and the previous array identity) when a
|
||||
rebuilt list is structurally unchanged, so an alerts-bearing state frame with
|
||||
unchanged badge content cannot recreate nav button DOM and drop an in-flight
|
||||
tap. New nav or tab-strip consumers that rebuild their item arrays from live
|
||||
store reads must route through the same helper instead of `<For>`-ing over
|
||||
freshly constructed objects.
|
||||
|
||||
Estate-sized table and card rendering routes through the shared
|
||||
`PlatformWindowedRows`, `PlatformWindowedList`, and
|
||||
`usePlatformWindowedItems` primitives. They preserve the complete filtered and
|
||||
|
||||
@@ -1,189 +1,30 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "87ee3cd449e47b8c9f2214c64f46e0ee92bc1ed8",
|
||||
"verified_at": "2026-08-23T23:27:15Z",
|
||||
"base_sha": "814f7008833c19fce7d457c1818ebc9197b83c6e",
|
||||
"verified_at": "2026-08-24T07:44:48Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Alerts/AlertResourceTableDesktop.tsx",
|
||||
"frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx",
|
||||
"frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/useTableWindowing.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts",
|
||||
"frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx",
|
||||
"frontend-modern/src/components/Settings/ResourcePicker.tsx",
|
||||
"frontend-modern/src/components/Storage/DiskList.tsx",
|
||||
"frontend-modern/src/components/Storage/Storage.tsx",
|
||||
"frontend-modern/src/components/Storage/useStoragePageFilters.ts",
|
||||
"frontend-modern/src/components/Storage/useStoragePageModel.ts",
|
||||
"frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadRouteState.ts",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadsState.ts",
|
||||
"frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx",
|
||||
"frontend-modern/src/features/alerts/AlertHistoryTableSection.tsx",
|
||||
"frontend-modern/src/features/alerts/AlertOverviewActiveAlertsSection.tsx",
|
||||
"frontend-modern/src/features/docker/DockerAlertsTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerConfigsTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerContainersTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerHostsTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerImagesTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerNetworksTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerSecretsTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerServicesTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerStorageUsageTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerTasksTable.tsx",
|
||||
"frontend-modern/src/features/docker/DockerVolumesTable.tsx",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx",
|
||||
"frontend-modern/src/features/platformPage/PlatformWindowedList.tsx",
|
||||
"frontend-modern/src/features/platformPage/PlatformWindowedRows.tsx",
|
||||
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx",
|
||||
"frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx",
|
||||
"frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts",
|
||||
"frontend-modern/src/features/proxmox/proxmoxPageModel.ts",
|
||||
"frontend-modern/src/features/proxmox/useProxmoxBackupTableWindowing.ts",
|
||||
"frontend-modern/src/features/standalone/AgentsMachinesTable.tsx",
|
||||
"frontend-modern/src/features/standalone/AvailabilityChecksTable.tsx",
|
||||
"frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx",
|
||||
"frontend-modern/src/features/truenas/TrueNASAppsTable.tsx",
|
||||
"frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx",
|
||||
"frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx",
|
||||
"frontend-modern/src/features/truenas/TrueNASServicesTable.tsx",
|
||||
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx",
|
||||
"frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx",
|
||||
"frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx",
|
||||
"frontend-modern/src/features/vmware/VsphereActivityTable.tsx",
|
||||
"frontend-modern/src/features/vmware/VsphereAlertsTable.tsx",
|
||||
"frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx",
|
||||
"frontend-modern/src/features/vmware/VsphereHostsTable.tsx",
|
||||
"frontend-modern/src/features/vmware/VsphereNetworksTable.tsx",
|
||||
"frontend-modern/src/hooks/useUnifiedResources.ts",
|
||||
"frontend-modern/src/pages/Actions.tsx",
|
||||
"frontend-modern/src/stores/websocket-global.ts",
|
||||
"frontend-modern/src/stores/websocket.ts"
|
||||
"frontend-modern/src/AppLayout.tsx",
|
||||
"frontend-modern/src/components/shared/stableNavTabs.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Alerts/AlertResourceTableDesktop.tsx": "40114f8ba34db502cdb8d3c8f09ad2795a0b90b597aa460914837163afdde8a4",
|
||||
"frontend-modern/src/components/Alerts/AlertResourceTableMobile.tsx": "9a6ec90c00a42e382dd17e2354f8b2f1eded6bf9ebb7a26a1d2fe5ebef1c36d8",
|
||||
"frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx": "4bcec4a652819d14108b44f06be61ab89fc3236d9c675fc1e0e73b60e3277547",
|
||||
"frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx": "b418b76eda65c263fb412aba11927661eadad857f980c95621cb6bf2adf96aee",
|
||||
"frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx": "38060d74c672adca17da8568413bc0eaba21075931f3e9982f8345e1d9dc8056",
|
||||
"frontend-modern/src/components/Infrastructure/useTableWindowing.ts": "823b301ee9e689e4d64f43a2b9b884fb442e9c73c2a6b216311641b5a389b951",
|
||||
"frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts": "9cb8b31f22c197d21dba3ac2a7b8ef6ce9ed7a1bddbc11450df88071ab07fd16",
|
||||
"frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx": "38a6b14f30438a829af79af90e9770d812c0d60af3e6eebce5dd2da3c2b656cb",
|
||||
"frontend-modern/src/components/Settings/ResourcePicker.tsx": "33333088aa85b02ce4d4ee301a59228d9bd20703beaf651efb5621d8237fc87f",
|
||||
"frontend-modern/src/components/Storage/DiskList.tsx": "378fe6dca7f082a7192d4a3bed4e5ec8b13e458a64a798c7b5da8c6134fdccde",
|
||||
"frontend-modern/src/components/Storage/Storage.tsx": "49f95343a8327ec756fe9a0e17dfafacc4ca65146fc198befe7d028815f0b90f",
|
||||
"frontend-modern/src/components/Storage/useStoragePageFilters.ts": "454ce14da247b92ae27ea12a9ecbeaa9841d1ae582fafef79baa5ca4f65e8dca",
|
||||
"frontend-modern/src/components/Storage/useStoragePageModel.ts": "c60e1769f4d4811dd1682dfba7763d90777066f69034f1c5e44304bf767bcd9b",
|
||||
"frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts": "9412934e95eb2e1f273383aa69169b762ca2c14037ff586393d7d86374e5f6e4",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadRouteState.ts": "4a12717b73c8e836dd9fd5d049d772134bb53c19e3acc88b6bb88592291b975d",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts": "e441a35aeeae2c73845bb28bcfc1242dcfa49630d885b9506a0fb57bf1325653",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts": "1e3432b4df8635bf1234f049169c8249b7dc7afb5d91af5b47886d3ec25eee45",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts": "acad7b727086a684b5f92c7bb015752cbd31d0d88324fb396eec7179ea8b9e45",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadsState.ts": "bf360d72d68b02ac240ce4818d5d39d7308802405ff74592a99f9fe821c6d8ef",
|
||||
"frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx": "264c55150cb49e23d104783b01dc39e7f0563c061c1633faba143b431dd0d247",
|
||||
"frontend-modern/src/features/alerts/AlertHistoryTableSection.tsx": "8e875db1ff66291b4d688bd6e3a2fcba644bafca8e97a313560b151104f5ec4c",
|
||||
"frontend-modern/src/features/alerts/AlertOverviewActiveAlertsSection.tsx": "4de3198bb6d9a499cbdff530f117115db8e40c8bfa356bcbe9c2f94ab6fdd823",
|
||||
"frontend-modern/src/features/docker/DockerAlertsTable.tsx": "63aa2e31c66f3219facbc94ac7dd6439eabcb382189c13278da9d553b892764d",
|
||||
"frontend-modern/src/features/docker/DockerConfigsTable.tsx": "6a1827fc0a1811531991f6f97201c71b4b2085cca7f889e4e57840068176654d",
|
||||
"frontend-modern/src/features/docker/DockerContainersTable.tsx": "36bdf1cf9b6b2af627c7e521bb18e48a120af175af29f03edcf86dcf903e67b8",
|
||||
"frontend-modern/src/features/docker/DockerHostsTable.tsx": "c60d3f9287348b5cdaea75f0d8e257f60c212ec39439dc4c515f67dc9cf6c4fe",
|
||||
"frontend-modern/src/features/docker/DockerImagesTable.tsx": "ebb0d0808902f6c9d1dd8d0ad3d97742fc1ad1cedf4706e84dd24db4a9bef612",
|
||||
"frontend-modern/src/features/docker/DockerNetworksTable.tsx": "0770e6ccb02ef037f9ebe22aead174ff5a01c36613db3ac330e0f3aed04d82a3",
|
||||
"frontend-modern/src/features/docker/DockerSecretsTable.tsx": "5fab73a3182425e742f1b3a6c1e6068ee9d676aedf20b7ac05783b7e071d773d",
|
||||
"frontend-modern/src/features/docker/DockerServicesTable.tsx": "6b3c96bff27489495504fd67a7e07d127a1830326ebd5b4b16186c258aa46e1d",
|
||||
"frontend-modern/src/features/docker/DockerStorageUsageTable.tsx": "fce8b016818e3eafe55fe3f3571c9f8c0a88d10001e158dfbd5a787e6409cb10",
|
||||
"frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx": "1e434e500f9d87b0eea678531e5bfa4c8219974514a6bb4011301abbf77b0f04",
|
||||
"frontend-modern/src/features/docker/DockerTasksTable.tsx": "ececb440bc03ddc4428ebd9fd86a859115caaaf1b2d7c3483c252f7e5923ae67",
|
||||
"frontend-modern/src/features/docker/DockerVolumesTable.tsx": "437bcca202cdbc515a6be94826c2f052e2887a259b3e74b95314180668b97843",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx": "d6af4dc0cefd2a9bc60168cd1211d4e910c034084e19d8627f90a44527725090",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx": "72003df3088df2ea0b86119adf98340d4911a10ea06d2429b836ad10b4f9c1ab",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx": "5c35fdfbff731047380d5e80d266c40d1d9b9993e5df9e4807b802aa24dcf261",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx": "2a0f5f8c6d05465580393769c15ac8b5aab3502a1442b5fce6d9e269585ee8e8",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "0a76f4fe2a71a1f3e5f22e2f39dab09a69bd534dd88934b40cf5dc066271dced",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx": "464d5bc4e4465f306c2ab733fd5217ef27197ac831ff8e1d954bca1c9f033210",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx": "7e116f050ddf9d159808104d4e533fa640a72260a2bc46c405cfc771c0551346",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx": "6c3cb5fb8bc391b01677030df93f1fd0027f0fdd1ff080d5d23820a017843305",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx": "d3d5051b3706f4187b69e238c6151cdb63a676f774f980f1e0c2092ffba4212d",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx": "8dae4fe798edb6f7517ba5b96894686795bcb52a9187685a361f099ff11f5cb1",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx": "c721ea951e1725842a2e2ae02465ffebd1d8e41db089d7e596d271892d5249fa",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx": "c07de4d4c861eced421ec0b9b1aef666e30b8a4da193d1b77465c7b440c4033d",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx": "587a5f3f3ba69d1014b4160bb2a817284e2fb9208cd168571deb328f28277972",
|
||||
"frontend-modern/src/features/platformPage/PlatformWindowedList.tsx": "5865c9596ce43c8040f950fdd8944da117c8a60af304552563a30295cd75d836",
|
||||
"frontend-modern/src/features/platformPage/PlatformWindowedRows.tsx": "e9994d44b67683aafb0e2d5563b7c426967cb4812df959fb68a4cd3d71bec532",
|
||||
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "775abbe122738028deeed06087332fac15b6d24bcecd01490a6b05554db53da8",
|
||||
"frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts": "03f7e9e5a6ca8ced24ee3f01e84b447c16904c8953e4325e56e144f10fa9a78c",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "c450ec312fc1b66b3cd04d31ef4382b958bd39dc9d6c491fea4907f59b7901b3",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx": "d6f14908dd53e34022d67cee24c0afd586c425a334b910fc5a7b0542fce2ec72",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx": "734d4f845ca31a58a2751469288b90e1171477bf7811fe7e71e56994e9176838",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "50006f6baf423f85ee084cdfe57674e31a7fe383508f500f19d48c0edfc6fb65",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "ade892e9efb6917324b30d77462df02cd412bf872ab0a5ac011d2e8a7a1c94ae",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx": "ae7f2a35b0e393049212d4cdc1933a030f242f85292f840e14d457d499838e6b",
|
||||
"frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts": "9a74e09268cba15df0aa72af3fb45c6b3f66a59c02f36f5d56ed949458ec138d",
|
||||
"frontend-modern/src/features/proxmox/proxmoxPageModel.ts": "353aa4917e189d35a74a2619e841aef21403ed828426a76e12ba38c3db47df62",
|
||||
"frontend-modern/src/features/proxmox/useProxmoxBackupTableWindowing.ts": "ede175b264335ac7028a312e7cf5209e80a02398376d6e845dbb60809f153e7f",
|
||||
"frontend-modern/src/features/standalone/AgentsMachinesTable.tsx": "b923a5c901c980e9a0e4b15a27ed4e193bd437d7b564cd8dbba0309cf3c223a8",
|
||||
"frontend-modern/src/features/standalone/AvailabilityChecksTable.tsx": "c3e1be90961dc6c645ba35727e1cb9b3781e16ebce1ef1155e2a2739fc652135",
|
||||
"frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx": "4428e41210224f0c474e8a1f332bd94380bd98ab5c55317657ce613d2621082d",
|
||||
"frontend-modern/src/features/truenas/TrueNASAppsTable.tsx": "f5041219fa5042253833980ad45d1d4cca4362df68e1766df8319f101a9395b8",
|
||||
"frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx": "848490290134fe1d09d42b95af49b7fc7a4f876d783099f152340d98328681b6",
|
||||
"frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx": "8df07ae67c13130bc1b55e8efb5841cdf95f20adb47545aadc769d25a0be17eb",
|
||||
"frontend-modern/src/features/truenas/TrueNASServicesTable.tsx": "ab8cf7f11017e5acbf0d748e5f2fd134e01e0bc1949030d0df6b089f7fd519d7",
|
||||
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx": "57521f1d655d1b8d724cf2ff50e4d9af0414f2b60f1b95463e91fab30da621b9",
|
||||
"frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx": "91df11b1896c9de71f7d0b1f8c21bf00b108088fb908dc00809784b945688250",
|
||||
"frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx": "1f15e2ea7b223ad21470a745a859a1eca64e39d9b4ff5c85f457b29351f22bd3",
|
||||
"frontend-modern/src/features/vmware/VsphereActivityTable.tsx": "64fccea6ff56fc418916564887814fcfd61c3117ab517e164cc20aacf40440f0",
|
||||
"frontend-modern/src/features/vmware/VsphereAlertsTable.tsx": "8c1d5c367c54d1471110b1d0756aff97dae1f36592179fcf27607731f35998fb",
|
||||
"frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx": "acd0e88c8942dc4a526c20dcaa0a0931d8f01a1cd9ce1167216ccf1e9c710ca3",
|
||||
"frontend-modern/src/features/vmware/VsphereHostsTable.tsx": "063fc1821dbeafad2c2695f2595f157974bf65953a12ca5a75751a41197d5fa8",
|
||||
"frontend-modern/src/features/vmware/VsphereNetworksTable.tsx": "3a066da054edf2f6e5e0aefd21107c785b24962a17cc4238fabb0db9eeb7aa9e",
|
||||
"frontend-modern/src/hooks/useUnifiedResources.ts": "5e1435128cc11d56ce27927340e333796dfc0f4281edc8c127621ea0105de1fc",
|
||||
"frontend-modern/src/pages/Actions.tsx": "05ecf553ac62334b2323c980436f919c13bed96a988bc15bc63e8e1dc5a684de",
|
||||
"frontend-modern/src/stores/websocket-global.ts": "a6b8d424423e296dd7a5c560c1d012982cbf2377c1e420189ee4465563a4eb7e",
|
||||
"frontend-modern/src/stores/websocket.ts": "aec7a7a5ed0b05b315d2eb613e199b8d7a17aa7c2b6f4e810d5d6a05c4a76741"
|
||||
"frontend-modern/src/AppLayout.tsx": "60abf2ff6cbba15f57d1f0c8a5775b10fdfda96ff7827dc36953028571c5deeb",
|
||||
"frontend-modern/src/components/shared/stableNavTabs.ts": "41221184dc142e55a37a0bbc4f08edce2edfd067aa7f15e1a7f02d363f654018"
|
||||
},
|
||||
"routes": [
|
||||
"/login",
|
||||
"/proxmox/overview",
|
||||
"/proxmox/storage",
|
||||
"/proxmox/replication",
|
||||
"/proxmox/backups/date",
|
||||
"/proxmox/ceph",
|
||||
"/proxmox/mail",
|
||||
"/docker/overview",
|
||||
"/kubernetes/overview",
|
||||
"/truenas/overview",
|
||||
"/vmware/overview",
|
||||
"/standalone/machines",
|
||||
"/alerts/overview",
|
||||
"/patrol",
|
||||
"/actions"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
@@ -191,21 +32,13 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"The connected mock estate contained 50 Proxmox nodes, 929 guests, and 209 storage rows.",
|
||||
"At 1280x720 the Overview node and guest regions and the Storage table rendered populated rows immediately after settling, with platform tab content retained for fast return navigation.",
|
||||
"At 390x844 the Overview rendered the 50-node summary and 929-guest table with bounded mounted rows; Storage rendered 73 body rows including its virtual spacer instead of mounting all 209 records.",
|
||||
"A direct 6200px jump through the narrow Proxmox guest estate retained 23 visible table rows in every one of 12 consecutive samples, with zero blank frames and the jump-to-top control visible.",
|
||||
"Docker, Kubernetes, TrueNAS, vSphere, Machines, Alerts, and Actions all completed their narrow route render with their expected table or list content and no loading state left behind.",
|
||||
"A clean phone-sized performance profile measured settled Proxmox section transitions at 48-341ms and browser heap at roughly 225-239MB, down from multi-second transitions and roughly 386MB before the realtime reconciliation fix.",
|
||||
"Browser diagnostics contained no warnings or errors after the desktop, narrow, rapid-scroll, platform-route, and Proxmox section checks."
|
||||
"production build served via vite preview :4173 proxied to the live dev backend (mock estate: 50 nodes / 929 guests / 1508 resources, 46+ active alerts)",
|
||||
"desktop top nav (12 tabs) and mobile bottom bar (4 destinations) observed across 40s of websocket frames including activeAlerts-bearing rawData frames",
|
||||
"negative control on the pre-fix build: 3 of 4 mobile nav buttons and 4 of 10 desktop tabs were detached (recreated) within the same 40s window; with this change all tagged nav elements stayed connected"
|
||||
],
|
||||
"interactions": [
|
||||
"Loaded /proxmox/overview against the 50-node, 929-guest mock estate at desktop width and visually inspected the rendered Nodes and Guests regions.",
|
||||
"Clicked Storage, Backups, Replication, Ceph, Mail Gateway, and Overview, then visually inspected Overview and Storage at 1280x720.",
|
||||
"Applied a 390x844 viewport override, reloaded Overview, and confirmed nodes, guests, mobile navigation, and the fixed bottom navigation remained present.",
|
||||
"Jumped the active app scroll surface directly through 6200px of guest rows, sampled visible rows across 12 consecutive frames, and visually inspected the populated middle-of-estate viewport.",
|
||||
"Loaded narrow Docker, Kubernetes, TrueNAS, vSphere, Machines, Alerts, and Actions routes and confirmed each completed with expected estate content.",
|
||||
"Loaded narrow Proxmox Storage directly, waited for first hydration, and confirmed the bounded table was visible with populated rows.",
|
||||
"Read browser diagnostics, restored the temporary viewport override to 1280x720, and left the demo browser in its normal viewport."
|
||||
"login as admin (real input)",
|
||||
"Playwright CDP clicks on Proxmox sub-tabs (Storage, Backups, Mail, Overview) and mobile bottom-bar destinations (Alerts, Patrol, Actions) with navigation confirmed by URL change",
|
||||
"desktop utility tab click to /alerts after the 40s stability window; zero console errors on both form factors"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
type MobileNavBarPrimaryTab as PrimaryTab,
|
||||
type MobileNavBarUtilityTab as UtilityTab,
|
||||
} from '@/components/shared/MobileNavBar';
|
||||
import {
|
||||
createStableTabList,
|
||||
primaryNavTabEquals,
|
||||
utilityNavTabEquals,
|
||||
} from '@/components/shared/stableNavTabs';
|
||||
import {
|
||||
buildPrimaryPlatformNavigationVisibility,
|
||||
primaryPlatformNavigationIsVisible,
|
||||
@@ -487,7 +492,7 @@ export function AppLayout(props: AppLayoutProps) {
|
||||
// Platform/runtime nav is resource-admitted. A platform or runtime lens only
|
||||
// appears when the support manifest says the surface is supported and the
|
||||
// current resource snapshot proves that surface is actually present.
|
||||
const primaryTabs = createMemo<PrimaryTab[]>(() => {
|
||||
const primaryTabsSource = createMemo<PrimaryTab[]>(() => {
|
||||
const visible = platformNavigationVisibility();
|
||||
const isVisible = (id: PrimaryTab['id']) =>
|
||||
primaryPlatformNavigationIsVisible(visible, id as PrimaryPlatformNavId);
|
||||
@@ -563,7 +568,13 @@ export function AppLayout(props: AppLayoutProps) {
|
||||
return allPrimaryTabs.filter((tab) => tab.alwaysShow || tab.enabled);
|
||||
});
|
||||
|
||||
const utilityTabs = createMemo(() => {
|
||||
// Both tab memos rebuild their arrays from live store reads (activeAlerts is
|
||||
// replaced wholesale on every websocket state message), so without identity
|
||||
// stabilization every reference-keyed <For> consumer recreates all nav
|
||||
// buttons each tick — dropping taps that land mid-rebuild.
|
||||
const primaryTabs = createStableTabList(primaryTabsSource, primaryNavTabEquals);
|
||||
|
||||
const utilityTabsSource = createMemo<UtilityTab[]>(() => {
|
||||
const allAlerts = props.state().activeAlerts || [];
|
||||
const breakdown = allAlerts.reduce(
|
||||
(accumulator, alert: Alert) => {
|
||||
@@ -631,6 +642,8 @@ export function AppLayout(props: AppLayoutProps) {
|
||||
return tabs;
|
||||
});
|
||||
|
||||
const utilityTabs = createStableTabList(utilityTabsSource, utilityNavTabEquals);
|
||||
|
||||
const handlePrimaryClick = (tab: PrimaryTab) => {
|
||||
const targetRoute = resolvePrimaryNavigationRoute(tab, primaryRouteMemory);
|
||||
void (async () => {
|
||||
|
||||
@@ -361,6 +361,16 @@ describe('App architecture', () => {
|
||||
expect(appLayoutSource).not.toContain("id: 'recovery',");
|
||||
expect(appLayoutSource).not.toContain('aria-label="Workspaces"');
|
||||
expect(appLayoutSource).not.toContain('buildStorageRecoveryTabSpecs(');
|
||||
// Nav tab arrays are rebuilt from live store reads (activeAlerts is
|
||||
// replaced wholesale by state frames), so both tab lists must route
|
||||
// through the shared identity stabilizer or every reference-keyed <For>
|
||||
// consumer recreates all nav buttons and drops in-flight taps.
|
||||
expect(appLayoutSource).toContain(
|
||||
'const primaryTabs = createStableTabList(primaryTabsSource, primaryNavTabEquals);',
|
||||
);
|
||||
expect(appLayoutSource).toContain(
|
||||
'const utilityTabs = createStableTabList(utilityTabsSource, utilityNavTabEquals);',
|
||||
);
|
||||
expect(appSource).not.toContain('DashboardPage');
|
||||
expect(headerAuditSource).not.toContain("['src/pages/Dashboard.tsx', 'PageHeader']");
|
||||
expect(appSource).toContain("import RuntimeHomePage from '@/pages/RuntimeHome';");
|
||||
@@ -618,14 +628,14 @@ describe('App architecture', () => {
|
||||
expect(appLayoutSource).not.toContain("label: 'Needs Attention'");
|
||||
expect(appLayoutSource).not.toContain("route: '/operations',");
|
||||
expect(appLayoutSource).not.toContain('props.connected()');
|
||||
expect(appLayoutSource).toContain('const utilityTabs = createMemo(() =>');
|
||||
expect(appLayoutSource).toContain('const utilityTabsSource = createMemo<UtilityTab[]>(() =>');
|
||||
expect(appLayoutSource).toContain(
|
||||
'type MobileNavBarPrimaryTab as PrimaryTab,\n type MobileNavBarUtilityTab as UtilityTab,',
|
||||
);
|
||||
expect(appLayoutSource).toContain("const NAV_TAB_ICON_CLASS = 'w-4 h-4 shrink-0';");
|
||||
expect(appLayoutSource).toContain('function getDesktopUtilityTabAriaLabel(tab: UtilityTab)');
|
||||
expect(appLayoutSource).toContain('return `${count} ${tab.label}`;');
|
||||
expect(appLayoutSource).toContain('const primaryTabs = createMemo<PrimaryTab[]>(() =>');
|
||||
expect(appLayoutSource).toContain('const primaryTabsSource = createMemo<PrimaryTab[]>(() =>');
|
||||
expect(appLayoutSource).toContain("id: 'proxmox',");
|
||||
expect(appLayoutSource).toContain("icon: getPlatformIcon('proxmox'),");
|
||||
expect(appLayoutSource).toContain('const Icon = tab.icon;');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { type Component } from 'solid-js';
|
||||
import { createSignal, type Component } from 'solid-js';
|
||||
import { createStableTabList, utilityNavTabEquals } from '@/components/shared/stableNavTabs';
|
||||
import mobileNavBarSource from '@/components/shared/MobileNavBar.tsx?raw';
|
||||
import mobileNavBarModelSource from '@/components/shared/mobileNavBarModel.ts?raw';
|
||||
import mobileNavBarStateSource from '@/components/shared/useMobileNavBarState.ts?raw';
|
||||
@@ -290,6 +291,46 @@ describe('MobileNavBar', () => {
|
||||
expect(button).not.toHaveAttribute('aria-current');
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps destination button DOM identity when tab arrays are rebuilt with equal content', async () => {
|
||||
const buildTabs = () => [
|
||||
makeUtility('alerts', 'Alerts', { count: 3, breakdown: { warning: 2, critical: 1 } }),
|
||||
makeUtility('ai', 'Patrol'),
|
||||
makeUtility('actions', 'Actions'),
|
||||
];
|
||||
const [rawTabs, setRawTabs] = createSignal(buildTabs(), { equals: false });
|
||||
const utilityTabs = createStableTabList(rawTabs, utilityNavTabEquals);
|
||||
const { container } = render(() => (
|
||||
<MobileNavBar
|
||||
activeTab={() => null}
|
||||
primaryTabs={() => [makePrimary('proxmox', 'Proxmox')]}
|
||||
utilityTabs={() => [...utilityTabs()]}
|
||||
onPrimaryClick={() => {}}
|
||||
onUtilityClick={() => {}}
|
||||
/>
|
||||
));
|
||||
|
||||
const buttonsBefore = [
|
||||
...container.querySelectorAll<HTMLButtonElement>('[data-mobile-nav-destination]'),
|
||||
];
|
||||
expect(buttonsBefore.length).toBeGreaterThan(0);
|
||||
|
||||
// A state frame carrying an unchanged alerts array rebuilds the tab
|
||||
// objects; identical content must not recreate the rendered buttons.
|
||||
setRawTabs(buildTabs());
|
||||
await waitFor(() => {
|
||||
const buttonsAfter = [
|
||||
...container.querySelectorAll<HTMLButtonElement>('[data-mobile-nav-destination]'),
|
||||
];
|
||||
expect(buttonsAfter).toHaveLength(buttonsBefore.length);
|
||||
buttonsAfter.forEach((button, index) => {
|
||||
expect(button).toBe(buttonsBefore[index]);
|
||||
});
|
||||
buttonsBefore.forEach((button) => {
|
||||
expect(button.isConnected).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MobileNavBar clearance publishing', () => {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createRoot, createSignal } from 'solid-js';
|
||||
import { createStableTabList, primaryNavTabEquals, utilityNavTabEquals } from '../stableNavTabs';
|
||||
import type { MobileNavBarIcon, MobileNavBarUtilityTab } from '../mobileNavBarModel';
|
||||
|
||||
const Icon: MobileNavBarIcon = () => null;
|
||||
|
||||
const utilityTab = (overrides: Partial<MobileNavBarUtilityTab> = {}): MobileNavBarUtilityTab => ({
|
||||
id: 'alerts',
|
||||
label: 'Alerts',
|
||||
route: '/alerts',
|
||||
tooltip: 'Review active alerts',
|
||||
badge: null,
|
||||
count: 3,
|
||||
countLabel: undefined,
|
||||
breakdown: { warning: 2, critical: 1 },
|
||||
icon: Icon,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('createStableTabList', () => {
|
||||
it('returns the identical array when a rebuilt list is structurally equal', () => {
|
||||
createRoot((dispose) => {
|
||||
const [tabs, setTabs] = createSignal([utilityTab()], { equals: false });
|
||||
const stable = createStableTabList(tabs, utilityNavTabEquals);
|
||||
const first = stable();
|
||||
setTabs([utilityTab()]);
|
||||
expect(stable()).toBe(first);
|
||||
expect(stable()[0]).toBe(first[0]);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses unchanged item references when one item changes', () => {
|
||||
createRoot((dispose) => {
|
||||
const settingsTab = utilityTab({ id: 'settings', label: 'Settings', route: '/settings' });
|
||||
const [tabs, setTabs] = createSignal([utilityTab(), settingsTab], { equals: false });
|
||||
const stable = createStableTabList(tabs, utilityNavTabEquals);
|
||||
const first = stable();
|
||||
setTabs([
|
||||
utilityTab({ count: 4, breakdown: { warning: 3, critical: 1 } }),
|
||||
utilityTab({ id: 'settings', label: 'Settings', route: '/settings' }),
|
||||
]);
|
||||
const second = stable();
|
||||
expect(second).not.toBe(first);
|
||||
expect(second[0]).not.toBe(first[0]);
|
||||
expect(second[1]).toBe(first[1]);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it('detects length changes', () => {
|
||||
createRoot((dispose) => {
|
||||
const [tabs, setTabs] = createSignal([utilityTab()], { equals: false });
|
||||
const stable = createStableTabList(tabs, utilityNavTabEquals);
|
||||
const first = stable();
|
||||
setTabs([utilityTab(), utilityTab({ id: 'settings', route: '/settings' })]);
|
||||
const second = stable();
|
||||
expect(second).not.toBe(first);
|
||||
expect(second[0]).toBe(first[0]);
|
||||
expect(second).toHaveLength(2);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('utilityNavTabEquals', () => {
|
||||
it('compares breakdown by value', () => {
|
||||
expect(utilityNavTabEquals(utilityTab(), utilityTab())).toBe(true);
|
||||
expect(
|
||||
utilityNavTabEquals(utilityTab(), utilityTab({ breakdown: { warning: 9, critical: 1 } })),
|
||||
).toBe(false);
|
||||
expect(utilityNavTabEquals(utilityTab({ breakdown: undefined }), utilityTab())).toBe(false);
|
||||
expect(
|
||||
utilityNavTabEquals(
|
||||
utilityTab({ breakdown: undefined }),
|
||||
utilityTab({ breakdown: undefined }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('compares scalar fields', () => {
|
||||
expect(utilityNavTabEquals(utilityTab(), utilityTab({ count: undefined }))).toBe(false);
|
||||
expect(utilityNavTabEquals(utilityTab(), utilityTab({ badge: 'update' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('primaryNavTabEquals', () => {
|
||||
it('compares all scalar fields and the icon reference', () => {
|
||||
const base = {
|
||||
id: 'proxmox',
|
||||
label: 'Proxmox',
|
||||
route: '/proxmox',
|
||||
settingsRoute: '/settings/infrastructure',
|
||||
tooltip: 'Proxmox',
|
||||
enabled: true,
|
||||
live: true,
|
||||
icon: Icon,
|
||||
alwaysShow: false,
|
||||
badge: undefined,
|
||||
};
|
||||
expect(primaryNavTabEquals(base, { ...base })).toBe(true);
|
||||
expect(primaryNavTabEquals(base, { ...base, live: false })).toBe(false);
|
||||
const OtherIcon: MobileNavBarIcon = () => null;
|
||||
expect(primaryNavTabEquals(base, { ...base, icon: OtherIcon })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createMemo, type Accessor } from 'solid-js';
|
||||
import type { MobileNavBarPrimaryTab, MobileNavBarUtilityTab } from './mobileNavBarModel';
|
||||
|
||||
/**
|
||||
* Reuse previous item references (and the previous array itself) when a
|
||||
* rebuilt tab list is structurally unchanged. The nav tab memos rebuild
|
||||
* their arrays from live store reads on every websocket tick, and the
|
||||
* reference-keyed <For> consumers in AppLayout and MobileNavBar tear down
|
||||
* and recreate every button whenever item identity changes — which drops
|
||||
* taps that land mid-rebuild. Stabilizing identity here fixes every
|
||||
* consumer at once.
|
||||
*/
|
||||
export function createStableTabList<T>(
|
||||
source: Accessor<T[]>,
|
||||
equals: (previous: T, next: T) => boolean,
|
||||
): Accessor<T[]> {
|
||||
let previous: T[] = [];
|
||||
return createMemo(() => {
|
||||
const next = source();
|
||||
let changed = next.length !== previous.length;
|
||||
const merged = next.map((item, index) => {
|
||||
const previousItem = previous[index];
|
||||
if (previousItem !== undefined && equals(previousItem, item)) {
|
||||
return previousItem;
|
||||
}
|
||||
changed = true;
|
||||
return item;
|
||||
});
|
||||
if (!changed) return previous;
|
||||
previous = merged;
|
||||
return merged;
|
||||
});
|
||||
}
|
||||
|
||||
export function primaryNavTabEquals(a: MobileNavBarPrimaryTab, b: MobileNavBarPrimaryTab): boolean {
|
||||
return (
|
||||
a.id === b.id &&
|
||||
a.label === b.label &&
|
||||
a.route === b.route &&
|
||||
a.settingsRoute === b.settingsRoute &&
|
||||
a.tooltip === b.tooltip &&
|
||||
a.enabled === b.enabled &&
|
||||
a.live === b.live &&
|
||||
a.icon === b.icon &&
|
||||
a.alwaysShow === b.alwaysShow &&
|
||||
a.badge === b.badge
|
||||
);
|
||||
}
|
||||
|
||||
export function utilityNavTabEquals(a: MobileNavBarUtilityTab, b: MobileNavBarUtilityTab): boolean {
|
||||
return (
|
||||
a.id === b.id &&
|
||||
a.label === b.label &&
|
||||
a.route === b.route &&
|
||||
a.tooltip === b.tooltip &&
|
||||
a.badge === b.badge &&
|
||||
a.count === b.count &&
|
||||
a.countLabel === b.countLabel &&
|
||||
a.icon === b.icon &&
|
||||
(a.breakdown === b.breakdown ||
|
||||
(a.breakdown !== undefined &&
|
||||
b.breakdown !== undefined &&
|
||||
a.breakdown.warning === b.breakdown.warning &&
|
||||
a.breakdown.critical === b.breakdown.critical))
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user