diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 6761a4abf..39b37fae1 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 23e0488fb..c0901bae3 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -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` diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 0a49b5df0..d7cb76134 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 `` +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 ``-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 diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 079e548ff..ad64a28f6 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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" ] } diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index fff0c3118..226c76f38 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -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(() => { + const primaryTabsSource = createMemo(() => { 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 consumer recreates all nav + // buttons each tick — dropping taps that land mid-rebuild. + const primaryTabs = createStableTabList(primaryTabsSource, primaryNavTabEquals); + + const utilityTabsSource = createMemo(() => { 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 () => { diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index e2c62a048..29c8917fe 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -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 + // 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(() =>'); 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(() =>'); + expect(appLayoutSource).toContain('const primaryTabsSource = createMemo(() =>'); expect(appLayoutSource).toContain("id: 'proxmox',"); expect(appLayoutSource).toContain("icon: getPlatformIcon('proxmox'),"); expect(appLayoutSource).toContain('const Icon = tab.icon;'); diff --git a/frontend-modern/src/components/shared/__tests__/MobileNavBar.test.tsx b/frontend-modern/src/components/shared/__tests__/MobileNavBar.test.tsx index 17f11177d..e34057e20 100644 --- a/frontend-modern/src/components/shared/__tests__/MobileNavBar.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/MobileNavBar.test.tsx @@ -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(() => ( + null} + primaryTabs={() => [makePrimary('proxmox', 'Proxmox')]} + utilityTabs={() => [...utilityTabs()]} + onPrimaryClick={() => {}} + onUtilityClick={() => {}} + /> + )); + + const buttonsBefore = [ + ...container.querySelectorAll('[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('[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', () => { diff --git a/frontend-modern/src/components/shared/__tests__/stableNavTabs.test.ts b/frontend-modern/src/components/shared/__tests__/stableNavTabs.test.ts new file mode 100644 index 000000000..49fc8c70f --- /dev/null +++ b/frontend-modern/src/components/shared/__tests__/stableNavTabs.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/frontend-modern/src/components/shared/stableNavTabs.ts b/frontend-modern/src/components/shared/stableNavTabs.ts new file mode 100644 index 000000000..eb0fa46a9 --- /dev/null +++ b/frontend-modern/src/components/shared/stableNavTabs.ts @@ -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 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( + source: Accessor, + equals: (previous: T, next: T) => boolean, +): Accessor { + 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)) + ); +}