From 5bbee0da3d428376312b621bec9ed534bec5391d Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 19 Aug 2026 16:27:30 +0100 Subject: [PATCH] feat: scale the Proxmox demo estate --- .github/workflows/update-demo-server.yml | 4 +- .../subsystems/deployment-installability.md | 19 +- .../subsystems/frontend-primitives.md | 6 +- .../v6/internal/subsystems/monitoring.md | 27 ++- .../subsystems/performance-and-scalability.md | 13 + .../v6/internal/subsystems/registry.json | 4 +- .../internal/subsystems/storage-recovery.md | 12 +- .../internal/subsystems/unified-resources.md | 4 + frontend-modern/browser-verification.json | 32 ++- .../features/proxmox/ProxmoxCoverageTable.tsx | 30 ++- .../proxmox/ProxmoxRecoverableTable.tsx | 78 ++++-- .../__tests__/ProxmoxCoverageTable.test.tsx | 42 ++++ .../ProxmoxRecoverableTable.test.tsx | 39 +++ .../proxmox/useProxmoxBackupTableWindowing.ts | 58 +++++ internal/mock/demo_scenarios.go | 223 ++++++++++++------ .../mock/demo_scenarios_benchmark_test.go | 52 ++++ internal/mock/demo_scenarios_test.go | 100 +++++++- internal/mock/generator.go | 189 +++++++++------ .../monitoring/monitor_metrics_slo_test.go | 7 +- .../installtests/build_release_assets_test.go | 3 + scripts/tests/test-toggle-mock.sh | 6 +- scripts/toggle-mock.sh | 4 +- .../tests/49-demo-scenario-curation.spec.ts | 86 ++++++- 23 files changed, 819 insertions(+), 219 deletions(-) create mode 100644 frontend-modern/src/features/proxmox/useProxmoxBackupTableWindowing.ts create mode 100644 internal/mock/demo_scenarios_benchmark_test.go diff --git a/.github/workflows/update-demo-server.yml b/.github/workflows/update-demo-server.yml index 583ecd5170..17a51126ac 100644 --- a/.github/workflows/update-demo-server.yml +++ b/.github/workflows/update-demo-server.yml @@ -616,8 +616,8 @@ jobs: set_env_value DEMO_MODE true set_env_value PULSE_MOCK_MODE true - set_env_value PULSE_MOCK_NODES 5 - set_env_value PULSE_MOCK_VMS_PER_NODE 6 + set_env_value PULSE_MOCK_NODES 32 + set_env_value PULSE_MOCK_VMS_PER_NODE 10 set_env_value PULSE_MOCK_LXCS_PER_NODE 8 set_env_value PULSE_MOCK_DOCKER_HOSTS 5 set_env_value PULSE_MOCK_DOCKER_CONTAINERS 14 diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index f0d00aaf41..b8f910684f 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -2227,17 +2227,20 @@ local demos. `scripts/toggle-mock.sh` must seed the same `PULSE_MOCK_*` defaults as `internal/mock.DefaultConfig`, so managed runtime toggles, local demo restarts, and CLI mock status all converge on one canonical dataset instead of drifting across shell helpers. -The canonical mock density today targets a mature small-to-mid homelab / -SMB environment so platform-first pages exercise real table density on -first boot. Both `mock_default_entries()` in `scripts/toggle-mock.sh` -and `internal/mock.DefaultConfig` carry the same baseline: 5 Proxmox -nodes with 6 VMs and 8 LXCs each, 5 Docker hosts with 14 containers +The canonical Proxmox mock density targets a large public-demo estate so +platform-first pages exercise multi-cluster grouping and the production +workload-windowing path on first boot. `mock_default_entries()` in +`scripts/toggle-mock.sh`, the public demo deployment environment in +`.github/workflows/update-demo-server.yml`, and `internal/mock.DefaultConfig` +carry the same baseline: 32 Proxmox nodes with 10 VMs and 8 LXCs each, arranged +as five six-node clusters plus two standalone nodes; 5 Docker hosts with 14 containers each, 4 standalone Pulse-managed hosts, and 3 Kubernetes clusters (production + staging + edge) with 5 nodes, 40 pods, and 14 -deployments each. Bumping either side requires bumping the other (and +deployments each. Bumping any owner requires bumping the others (and the matching `scripts/tests/test-toggle-mock.sh` fixtures) so toggle -CLIs, managed runtime restarts, and the in-binary default never drift -apart. +CLIs, managed runtime restarts, public demo convergence, and the in-binary +default never drift apart. Existing installations with explicit custom +`PULSE_MOCK_*` values retain those values during legacy-sidecar migration. Mock toggles are runtime transitions, not just environment-file edits. A successful `scripts/toggle-mock.sh on|off` run must leave the managed browser entrypoint serving the requested `/api/system/mock-mode` state through the diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index b8664ab7d6..8c21512733 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -1227,7 +1227,11 @@ not a replacement status card, CTA band, or page-local nested card. state, and data-window selection for their product surfaces; shared primitives and reusable presentation helpers may own viewport-safe chrome, focus treatment, pressed-state affordances, and accessible label builders - for repeated controls. Recovery timeline columns follow that split: + for repeated controls. Estate-sized Proxmox backup tables follow that split: + the storage/recovery feature owns its full-set-to-window projection and + spacer placement while reusing the canonical `useTableWindowing` math; it + must not invent a feature-local pagination or load-more button shell to avoid + mounting the complete result set. Recovery timeline columns follow that split: storage/recovery owns the range, selected day, chart/table transport windows, and bucket data, while the shared frontend boundary owns the reusable button focus/selected styling and ARIA wording so columns expose diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index f4f045e2ee..fb3dcc043a 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -1802,11 +1802,12 @@ enumerated backup artifact must never mint protected posture. The `internal/mock/demo_scenarios_test.go` evaluates those fixtures through the production posture engine. Mock fixture defaults in `internal/mock/generator.go` (the `DefaultConfig` -constant) are also part of that mock-runtime contract. They target a -mature small-to-mid homelab / SMB environment so platform-first pages -exercise table density, sorting, grouping, drawer behavior, and -responsive layout out of the box: 5 Proxmox cluster + standalone nodes -with 6 VMs and 8 LXCs each, 5 Docker/Podman hosts with 14 containers +constant) are also part of that mock-runtime contract. The Proxmox default is +an intentionally large public-demo estate so platform-first pages exercise +multi-cluster navigation, table density, sorting, grouping, drawer behavior, +responsive layout, and the production workload-windowing threshold out of the +box: five named six-node Proxmox clusters plus two standalone nodes, with 10 +VMs and 8 LXCs per node (576 guests total), 5 Docker/Podman hosts with 14 containers each, 4 standalone Pulse-managed hosts, and 3 Kubernetes clusters (Production EU + Staging EU + Development EU; a fourth Edge / k3s profile is curated in `demo_scenarios.go` and instantiates when @@ -1835,10 +1836,22 @@ nondeterministic; explicit curated outage scenarios remain the owner of cluster-wide unavailability. Bumps to those defaults must keep the curated demo scenario's per-node hostname seasoning in -`demo_scenarios.go` aligned (today: pve1..pve6 with regional labels, -shared-fabric storage names, and per-node fallback naming) so the +`demo_scenarios.go` aligned (today: pve1..pve30 distributed across Production +West, Production East, Core Services, Disaster Recovery, and Edge Sites, plus +two named standalone nodes, with regional labels, shared-fabric storage names, +and per-node fallback naming) so the broadcast and snapshot views render the same human-readable estate regardless of the configured fixture size. The +clustered storage graph must remain linear in node count: each node may report +the cluster's bounded primary and offsite PBS targets, while shared NFS, RBD, +and CephFS pools are emitted once per cluster rather than once per peer. Demo +backup history likewise keeps two representative recovery points per protected +guest so a large estate remains realistic without manufacturing an unbounded +payload. `TestDefaultDemoProxmoxEstateIsLargeMultiClusterAndBounded` guards the +estate shape and storage bound, while `demo_scenarios_benchmark_test.go` +records graph-build, sampler-update, and unified-snapshot costs at the default +density. +The monitor-broadcast equivalence test (`TestMonitorBuildBroadcastFrontendStateUsesCanonicalMockUnifiedResources`) compares broadcast count against the canonical snapshot count within a diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index d100747f0a..d5da6aecaa 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -1723,6 +1723,19 @@ which owns the Workloads table body measurement and the scroll/resize listener lifecycle. Future viewport sync changes must extend through that hook rather than rebuilding browser-event wiring or table-body geometry reads inside `frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts`. +The canonical public demo intentionally exercises this production hot path: +its default Proxmox estate contains 576 guests, crossing the 500-row windowing +threshold while retaining the hook's 140-row mounted-window budget. The demo +curation integration proof must assert both the 500-plus guest total and the +bounded mounted-row count. That same default estate produces more than 500 +backup coverage targets and recoverable artifacts; the coverage and By date +tables must route through `useProxmoxBackupTableWindowing.ts`, retain full-set +filter/sort/count semantics, and mount at most 140 row or day-header items +without pagination chrome. `internal/mock/demo_scenarios_benchmark_test.go` +records the corresponding graph-build, recurring sampler-update, and unified +resource projection costs, and the mock storage topology must stay linear in +node count so a realistic multi-cluster fixture cannot reintroduce quadratic +projection work. The workload guest-row path now follows the same pattern: the render shell stays in `frontend-modern/src/components/Workloads/GuestRow.tsx`, tooltip-backed cell presentation lives in `frontend-modern/src/components/Workloads/GuestRowCells.tsx`, diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 238cd930b1..87ef3cc22f 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -7085,6 +7085,7 @@ "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx", "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx", "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx", + "frontend-modern/src/features/proxmox/useProxmoxBackupTableWindowing.ts", "frontend-modern/src/hooks/useProtectionPostures.ts", "frontend-modern/src/hooks/useRecoveryPoints.ts", "frontend-modern/src/types/recovery.ts", @@ -7198,7 +7199,8 @@ "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx", "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx", "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx", - "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx" + "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx", + "frontend-modern/src/features/proxmox/useProxmoxBackupTableWindowing.ts" ], "allow_same_subsystem_tests": false, "test_prefixes": [], diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 701d34ee92..a5990e3d1f 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -148,6 +148,7 @@ command remains a bounded onboarding artifact with server-owned token quoting. 30. `frontend-modern/src/hooks/useProtectionPostures.ts` 31. `frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx` 32. `internal/mock/recovery_points.go` +33. `frontend-modern/src/features/proxmox/useProxmoxBackupTableWindowing.ts` ## Shared Boundaries @@ -466,7 +467,16 @@ artifact's source visible and must not flatten PBS verification/protection facts into PVE archive or snapshot semantics. Workload coverage posture must be derived from real recovery evidence and recent task outcomes, including explicit uncovered and failed-latest-task states, rather than from source-detail -row counts alone. PBS server/datastore rows may display backup counts, but the +row counts alone. Large-estate rendering must preserve that same full result +set without mounting every row. `useProxmoxBackupTableWindowing.ts` routes both +the coverage table and dated artifact feed through the shared table-window math +once the result set crosses 500 items, keeps at most 140 row/header items +mounted, and preserves continuous scroll height with table spacers. Filtering, +sorting, health counts, and day-group counts continue to operate over the +complete client-side result set; windowing must not become pagination, a partial +count, or a recovery-local `Next` / `Load more` control. Expanded coverage +evidence remains owned by the visible workload row. +PBS server/datastore rows may display backup counts, but the counts must come from the PBS backup API artifact identity, not from a datastore-capacity approximation. The table owns which PBS artifact count is meaningful for the backup-health row, while dense integer count presentation diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 5fe851b0af..1db98929ff 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -2361,6 +2361,10 @@ their forensic backup detail. That presentation state is not a fifth server posture and must not be counted as unknown provider evidence. Unified resources own row identity only; they do not derive backup freshness, provider completeness, verification, or protection state. +Backup table windowing may change which rows are mounted, but the visible slice +must retain the exact full-set row objects and canonical resource identities; +viewport movement, spacer rows, filtering, and sorting must never mint a +replacement identity from a row index or presentation label. `TrueNASProtectionTable` likewise consumes storage/recovery-owned event outcomes without turning its compact failed/warning attention count into a canonical workload `ProtectionPosture`; that count is event-history navigation, diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index b5fd5a7fe6..542716d94f 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,34 +1,44 @@ { "version": 1, - "base_sha": "2d801e20f32a4320bca2861294021669ea1dfdd3", - "verified_at": "2026-08-19T14:41:38Z", + "base_sha": "57e08985c37e33dc48e90345afe98c1d7a8f1a31", + "verified_at": "2026-08-19T15:24:06Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/proxmox/ProxmoxBackupsCoverageStrip.tsx" + "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx", + "frontend-modern/src/features/proxmox/useProxmoxBackupTableWindowing.ts" ], "content_sha256": { - "frontend-modern/src/features/proxmox/ProxmoxBackupsCoverageStrip.tsx": "ba2d275d1db2a7a9c21d02ab4dac5cd62712d2d641c0cda8c2cf7e7af3ca5483" + "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "82e2164c3190b49a6540531576b598862ce829b353236cc8df6946e4b3fd917d", + "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx": "ebf464760597dbb92c172e9b51284578f156ddcae574da87c097a9dd7b6381f3", + "frontend-modern/src/features/proxmox/useProxmoxBackupTableWindowing.ts": "6e9ba89417d2d664c3a40f0e29922414bbc103e738e9698ea584049c67790fd0" }, "routes": [ + "/proxmox/overview", "/proxmox/backups" ], "viewports": [ { "width": 1280, - "height": 800 + "height": 720 }, { - "width": 320, + "width": 390, "height": 844 } ], "states": [ - "Backup Health coverage strip with live target and restore-point summary, proportional status bar, and legend", - "At 320x844 the summary occupies a full-width row directly below the title and starts at the same 21px content inset without an empty auto-margin column", - "At 1280x800 the title and summary remain on one baseline with the summary aligned to the right edge" + "Large Proxmox overview showing 578 workloads across five named six-node clusters and two standalone nodes with no legacy mock aliases", + "Overview workload table capped at 140 mounted rows while preserving the complete fleet totals and exact filtered results", + "Backup Coverage showing 581 targets, 1874 restore points, 225 PBS snapshots, and at most 140 mounted rows", + "Backup By date view preserving day headings and restore artifacts within the same 140-row mounted window", + "Coverage and By date tables at desktop and 390px narrow widths with continuous table geometry and responsive columns" ], "interactions": [ - "Reloaded the Backups route after the source change at both verified viewport sizes", - "Inspected the rendered title and summary bounding boxes and confirmed narrow left alignment plus preserved desktop right alignment" + "Searched the overview for checkout-web-01 and confirmed the exact deep-estate workload appeared in approximately 176ms", + "Scrolled through the large overview and backup tables to verify continuous window advancement without pagination or load-more controls", + "Switched between Coverage and By date backup views and confirmed each mounted at most 140 rows", + "Searched backups for checkout-web-01 and confirmed the exact deep-estate result appeared in approximately 82ms", + "Reloaded and inspected overview, backup coverage, and backup date states at 1280x720 and 390x844" ] } diff --git a/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx index 4b4ed0b803..0135ffc8f5 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx @@ -36,6 +36,7 @@ import { isCoverageEvidenceColumnVisible, type CoverageColumnId, } from './proxmoxBackupsTablePresentation'; +import { useProxmoxBackupTableWindowing } from './useProxmoxBackupTableWindowing'; const coveragePostureVariant = ( posture: WorkloadCoverageRow['posture'], @@ -148,6 +149,7 @@ export function ProxmoxCoverageTable(props: { const pbsSource = getProxmoxBackupSourcePresentation('pbs'); const archiveSource = getProxmoxBackupSourcePresentation('archive'); const snapshotSource = getProxmoxBackupSourcePresentation('snapshot'); + const tableWindow = useProxmoxBackupTableWindowing(() => props.rows); return (
{ + observedWidth.setElement(element); + tableWindow.setRootRef(element); + }} data-proxmox-backups-table="coverage" data-proxmox-backups-layout={layoutMode()} + data-proxmox-backups-windowed={tableWindow.isWindowed()} > - + 0}> + + + {(row) => { const isExpanded = () => props.expandedKeys.has(row.key); const evidence = () => @@ -291,7 +306,7 @@ export function ProxmoxCoverageTable(props: { const detailRowId = () => `proxmox-coverage-evidence-${row.key}`; return ( <> - + @@ -656,6 +671,15 @@ export function ProxmoxCoverageTable(props: { ); }} + 0}> + + } /> diff --git a/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx index 981f6c3ac7..ee35976a77 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx @@ -33,6 +33,7 @@ import { isCompactBackupIdentityLayout, type RecoverableColumnId, } from './proxmoxBackupsTablePresentation'; +import { useProxmoxBackupTableWindowing } from './useProxmoxBackupTableWindowing'; // Flat recoverable-artifact table. Parent state owns filtering, sorting, and // date/source facets; optional day grouping is presentation only. @@ -43,6 +44,10 @@ interface DayGroup { items: RecoverableArtifact[]; } +type RecoverableTableItem = + | { kind: 'day'; key: string; label: string; count: number } + | { kind: 'artifact'; key: string; artifact: RecoverableArtifact }; + function groupByDay(artifacts: readonly RecoverableArtifact[]): DayGroup[] { const groups: DayGroup[] = []; let current: DayGroup | undefined; @@ -88,9 +93,28 @@ export function ProxmoxRecoverableTable(props: { const columnVisible = (column: RecoverableColumnId) => visibleColumns().some((candidate) => candidate.id === column); const showDayGroups = () => props.groupByDay && props.sortKey() === 'created'; + const tableItems = createMemo(() => { + if (!showDayGroups()) { + return props.artifacts.map((item) => ({ + kind: 'artifact', + key: item.id, + artifact: item, + })); + } + + return groupByDay(props.artifacts).flatMap((group) => [ + { kind: 'day' as const, key: group.key, label: group.label, count: group.items.length }, + ...group.items.map((item) => ({ + kind: 'artifact' as const, + key: item.id, + artifact: item, + })), + ]); + }); + const tableWindow = useProxmoxBackupTableWindowing(tableItems); const renderRow = (artifact: RecoverableArtifact): JSX.Element => ( - +
@@ -184,9 +208,13 @@ export function ProxmoxRecoverableTable(props: { } >
{ + observedWidth.setElement(element); + tableWindow.setRootRef(element); + }} data-proxmox-backups-table="recoverable" data-proxmox-backups-layout={layoutMode()} + data-proxmox-backups-windowed={tableWindow.isWindowed()} > - {(artifact) => renderRow(artifact)}} - > - - {(group) => ( - <> - + 0}> + + + + {(item) => ( + {/* Cell-level background is reliable across table layout engines. */} - {group.label}{' '} + {item.kind === 'day' ? item.label : ''}{' '} - {group.items.length} {group.items.length === 1 ? 'backup' : 'backups'} + {item.kind === 'day' ? item.count : 0}{' '} + {item.kind === 'day' && item.count === 1 ? 'backup' : 'backups'} - {(artifact) => renderRow(artifact)} - - )} - + } + > + {(artifact) => renderRow(artifact())} + + )} + + 0}> + } diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx index 3b383af48a..4c4fb5e0f9 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx @@ -171,4 +171,46 @@ describe('ProxmoxCoverageTable column visibility', () => { expect(document.body.textContent).toContain('History Complete'); expect(document.body.textContent).toContain('Access Sufficient'); }); + + it('windows large coverage result sets while keeping one continuous table', () => { + const rows = Array.from({ length: 600 }, (_, index) => ({ + ...row, + key: `w${index}`, + workload: { + ...row.workload, + key: `w${index}`, + vmid: String(100 + index), + label: `workload-${index}`, + name: `workload-${index}`, + }, + })) as WorkloadCoverageRow[]; + + render(() => ( + } + emptyTitle="" + emptyDescription="" + sortKey={(() => 'posture') as Accessor} + sortDirection={() => 'asc'} + onSort={() => {}} + expandedKeys={new Set()} + onToggleExpand={() => {}} + showPbsColumn={true} + showArchiveColumn={false} + showSnapshotColumn={false} + showTaskColumn={false} + layoutWidth={() => 1_200} + /> + )); + + expect(document.querySelector('[data-proxmox-backups-table="coverage"]')).toHaveAttribute( + 'data-proxmox-backups-windowed', + 'true', + ); + expect(document.querySelectorAll('[data-proxmox-backup-row="coverage"]')).toHaveLength(140); + expect(document.body.textContent).toContain('workload-0'); + expect(document.body.textContent).not.toContain('workload-599'); + }); }); diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx index 4d4c5528c9..9f85282006 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx @@ -81,4 +81,43 @@ describe('ProxmoxRecoverableTable responsive columns', () => { expect(document.body.textContent).toContain('main / pve1'); expect(document.body.textContent).toContain('5 PBS files'); }); + + it('windows large dated feeds without adding pagination controls', () => { + const artifacts = Array.from({ length: 600 }, (_, index) => ({ + ...artifact, + id: `pbs:web:${index}`, + nativeId: `vm/${index}/2026-08-04T08:00:00Z`, + workload: { + ...artifact.workload, + key: `vm:${index}`, + vmid: String(index), + label: `workload-${index}`, + name: `workload-${index}`, + }, + })); + + render(() => ( + } + emptyTitle="" + emptyDescription="" + sortKey={(() => 'created') as Accessor} + sortDirection={() => 'desc'} + onSort={() => {}} + sizeMaxBytes={artifact.size ?? 0} + groupByDay + layoutWidth={() => 1_200} + /> + )); + + expect(document.querySelector('[data-proxmox-backups-table="recoverable"]')).toHaveAttribute( + 'data-proxmox-backups-windowed', + 'true', + ); + expect(document.querySelectorAll('[data-proxmox-backup-row]')).toHaveLength(140); + expect(document.body.textContent).toContain('workload-0'); + expect(document.body.textContent).not.toContain('workload-599'); + }); }); diff --git a/frontend-modern/src/features/proxmox/useProxmoxBackupTableWindowing.ts b/frontend-modern/src/features/proxmox/useProxmoxBackupTableWindowing.ts new file mode 100644 index 0000000000..9cb76f6209 --- /dev/null +++ b/frontend-modern/src/features/proxmox/useProxmoxBackupTableWindowing.ts @@ -0,0 +1,58 @@ +import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from 'solid-js'; + +import { useTableWindowing } from '@/components/Infrastructure/useTableWindowing'; + +const BACKUP_TABLE_ESTIMATED_ROW_HEIGHT = 32; + +export function useProxmoxBackupTableWindowing(items: Accessor) { + const [rootRef, setRootRef] = createSignal(null); + const windowing = useTableWindowing({ totalCount: () => items().length }); + + const visibleItems = createMemo(() => { + if (!windowing.isWindowed()) return items(); + return items().slice(windowing.startIndex(), windowing.endIndex()); + }); + const topSpacerHeight = createMemo(() => + windowing.isWindowed() ? windowing.startIndex() * BACKUP_TABLE_ESTIMATED_ROW_HEIGHT : 0, + ); + const bottomSpacerHeight = createMemo(() => + windowing.isWindowed() + ? Math.max(0, items().length - windowing.endIndex()) * BACKUP_TABLE_ESTIMATED_ROW_HEIGHT + : 0, + ); + + const syncWindowToViewport = () => { + if (!windowing.isWindowed() || typeof window === 'undefined') return; + const root = rootRef(); + if (!root) return; + const rect = root.getBoundingClientRect(); + windowing.onScroll( + Math.max(0, -rect.top), + window.innerHeight, + BACKUP_TABLE_ESTIMATED_ROW_HEIGHT, + ); + }; + + createEffect(() => { + if (typeof window === 'undefined') return; + items().length; + if (!windowing.isWindowed() || !rootRef()) return; + + const handleViewportChange = () => syncWindowToViewport(); + handleViewportChange(); + window.addEventListener('scroll', handleViewportChange, { passive: true }); + window.addEventListener('resize', handleViewportChange); + onCleanup(() => { + window.removeEventListener('scroll', handleViewportChange); + window.removeEventListener('resize', handleViewportChange); + }); + }); + + return { + isWindowed: windowing.isWindowed, + visibleItems, + topSpacerHeight, + bottomSpacerHeight, + setRootRef, + }; +} diff --git a/internal/mock/demo_scenarios.go b/internal/mock/demo_scenarios.go index 67a2975777..a4543af875 100644 --- a/internal/mock/demo_scenarios.go +++ b/internal/mock/demo_scenarios.go @@ -41,6 +41,14 @@ type demoBackupGuest struct { DiskSize int64 } +type demoProxmoxClusterProfile struct { + SourceInstance string + Name string + NodeDisplayPrefix string + StoragePrefix string + SharedStorageName string +} + type demoDockerHostProfile struct { Hostname string DisplayName string @@ -274,7 +282,15 @@ var demoKubernetesPodProfiles = []demoKubernetesPodProfile{ {Name: "cron-nightly-backfill-28918234", Namespace: "services", NodeIndex: 2, OwnerKind: "Job", OwnerName: "cron-nightly-backfill", Phase: "Pending", Reason: "PodInitializing", Message: "Waiting for scheduled execution window", Container: "cron-nightly-backfill", Image: "ghcr.io/pulse-demo/cron-nightly-backfill:2026.04", ContainerOK: false}, } -const demoProxmoxClusterName = "Core Fabric" +var demoProxmoxClusterProfiles = []demoProxmoxClusterProfile{ + {SourceInstance: "mock-cluster-1", Name: "Production West", NodeDisplayPrefix: "West Production", StoragePrefix: "west", SharedStorageName: "shared-backup-fabric"}, + {SourceInstance: "mock-cluster-2", Name: "Production East", NodeDisplayPrefix: "East Production", StoragePrefix: "east", SharedStorageName: "east-shared-fabric"}, + {SourceInstance: "mock-cluster-3", Name: "Core Services", NodeDisplayPrefix: "Core Services", StoragePrefix: "core", SharedStorageName: "core-shared-fabric"}, + {SourceInstance: "mock-cluster-4", Name: "Disaster Recovery", NodeDisplayPrefix: "Disaster Recovery", StoragePrefix: "dr", SharedStorageName: "dr-shared-fabric"}, + {SourceInstance: "mock-cluster-5", Name: "Edge Sites", NodeDisplayPrefix: "Edge Sites", StoragePrefix: "edge", SharedStorageName: "edge-shared-fabric"}, +} + +const demoOfflineProxmoxNode = "pve22" func applyDemoScenarioGraph(graph *FixtureGraph, now time.Time) { if graph == nil { @@ -405,34 +421,29 @@ func applyDemoNodeScenario(state *models.StateSnapshot) { return } - nodeDisplayNames := []string{ - "West Production A", - "West Production B", - "West Production C", - "Disaster Recovery A", - "Disaster Recovery B", - } for i := range state.Nodes { - state.Nodes[i].Instance = scenarioClusterAlias(state.Nodes[i].Instance) - if state.Nodes[i].IsClusterMember || strings.TrimSpace(state.Nodes[i].ClusterName) != "" { - state.Nodes[i].ClusterName = scenarioClusterAlias(state.Nodes[i].ClusterName) + node := &state.Nodes[i] + sourceInstance := node.Instance + node.Instance = scenarioClusterAlias(node.Instance) + if node.IsClusterMember || strings.TrimSpace(node.ClusterName) != "" { + node.ClusterName = scenarioClusterAlias(node.ClusterName) } - if i < len(nodeDisplayNames) { - state.Nodes[i].DisplayName = nodeDisplayNames[i] + if displayName := demoProxmoxNodeDisplayName(*node, sourceInstance); displayName != "" { + node.DisplayName = displayName } - if state.Nodes[i].Name == "pve5" { - state.Nodes[i].Status = "offline" - state.Nodes[i].CPU = 0 - state.Nodes[i].Memory.Used = 0 - state.Nodes[i].Memory.Usage = 0 - state.Nodes[i].Memory.Free = state.Nodes[i].Memory.Total - state.Nodes[i].Memory.SwapUsed = 0 - state.Nodes[i].Disk.Used = 0 - state.Nodes[i].Disk.Free = state.Nodes[i].Disk.Total - state.Nodes[i].Disk.Usage = -1 - state.Nodes[i].Uptime = 0 - state.Nodes[i].LoadAverage = []float64{0.0, 0.0, 0.0} - state.Nodes[i].ConnectionHealth = "offline" + if isDemoOfflineProxmoxNode(node.Name) { + node.Status = "offline" + node.CPU = 0 + node.Memory.Used = 0 + node.Memory.Usage = 0 + node.Memory.Free = node.Memory.Total + node.Memory.SwapUsed = 0 + node.Disk.Used = 0 + node.Disk.Free = node.Disk.Total + node.Disk.Usage = -1 + node.Uptime = 0 + node.LoadAverage = []float64{0.0, 0.0, 0.0} + node.ConnectionHealth = "offline" } } @@ -472,7 +483,7 @@ func applyDemoWorkloadScenario(workloads []models.VM, profiles []demoWorkloadPro workloads[i].Instance = scenarioClusterAlias(workloads[i].Instance) workloads[i].Tags = mergeScenarioTags(workloads[i].Tags, profile.Tags) workloads[i].Status = normalizeWorkloadState(profile.ForceState, "running") - if workloads[i].Node == "pve5" { + if isDemoOfflineProxmoxNode(workloads[i].Node) { workloads[i].Status = "stopped" } if workloads[i].Status == "stopped" { @@ -512,7 +523,7 @@ func applyDemoContainerScenario(workloads []models.Container, profiles []demoWor workloads[i].Instance = scenarioClusterAlias(workloads[i].Instance) workloads[i].Tags = mergeScenarioTags(workloads[i].Tags, profile.Tags) workloads[i].Status = normalizeWorkloadState(profile.ForceState, "running") - if workloads[i].Node == "pve5" { + if isDemoOfflineProxmoxNode(workloads[i].Node) { workloads[i].Status = "stopped" } if workloads[i].Status == "stopped" { @@ -963,7 +974,7 @@ func applyDemoHostScenario(state *models.StateSnapshot, now time.Time) { host.DisplayName = humanizeHostDisplayName(host.Hostname) } hostname := strings.ToLower(strings.TrimSpace(host.Hostname)) - if hostname == "pve5" || hostname == "prod-euw1-k8s-03" { + if isDemoOfflineProxmoxNode(hostname) || hostname == "prod-euw1-k8s-03" { host.Status = "offline" host.CPUUsage = 0 host.Memory.Used = 0 @@ -1005,12 +1016,37 @@ func applyDemoStorageScenario(state *models.StateSnapshot, now time.Time) { return } + offlineNodes := make(map[string]struct{}) + for _, node := range state.Nodes { + if strings.EqualFold(strings.TrimSpace(node.Status), "offline") { + offlineNodes[strings.ToLower(strings.TrimSpace(node.Name))] = struct{}{} + } + } for i := range state.Storage { storage := &state.Storage[i] if name := strings.TrimSpace(storageScenarioAlias(*storage)); name != "" { storage.Name = name } storage.Instance = scenarioClusterAlias(storage.Instance) + if _, offline := offlineNodes[strings.ToLower(strings.TrimSpace(storage.Node))]; offline { + storage.Status = "offline" + storage.Enabled = false + storage.Active = false + storage.Used = 0 + storage.Free = storage.Total + storage.Usage = 0 + if !now.IsZero() { + storage.LastSeen = now.Add(-10 * time.Minute) + } + } + } + for i := range state.PhysicalDisks { + state.PhysicalDisks[i].Instance = scenarioClusterAlias(state.PhysicalDisks[i].Instance) + } + for i := range state.CephClusters { + cluster := &state.CephClusters[i] + cluster.Instance = scenarioClusterAlias(cluster.Instance) + cluster.Name = scenarioClusterAlias(cluster.Name) } for i := range state.PBSInstances { @@ -1240,6 +1276,7 @@ func applyDemoBackupScenario( for i := range state.ReplicationJobs { job := &state.ReplicationJobs[i] + job.Instance = scenarioClusterAlias(job.Instance) if profile, ok := vmProfiles[job.GuestID]; ok { job.GuestName = profile.Name } @@ -1350,7 +1387,10 @@ func demoProtectionArtifacts( if baseAge <= 0 { baseAge = 12 * time.Hour } - backupAges := []time.Duration{baseAge, baseAge + 24*time.Hour, baseAge + 4*24*time.Hour} + // Two evidence-backed recovery points per protected guest are enough to + // demonstrate recency and retention without multiplying the public demo's + // payload by a third historical copy across a 500+ workload estate. + backupAges := []time.Duration{baseAge, baseAge + 4*24*time.Hour} storageBackups := make([]models.StorageBackup, 0, len(backupAges)) for index, age := range backupAges { backupAt := now.Add(-age) @@ -1483,17 +1523,31 @@ func normalizeWorkloadState(preferred, fallback string) string { } func scenarioClusterAlias(name string) string { - switch strings.TrimSpace(strings.ToLower(name)) { - case "", "standalone": - return strings.TrimSpace(name) - case "mock-cluster": - return demoProxmoxClusterName - default: - return name + trimmed := strings.TrimSpace(name) + if trimmed == "" || strings.EqualFold(trimmed, "standalone") { + return trimmed } + if strings.EqualFold(trimmed, "mock-cluster") { + return demoProxmoxClusterProfiles[0].Name + } + if profile, ok := lookupDemoProxmoxClusterProfile(trimmed); ok { + return profile.Name + } + return name } func storageScenarioAlias(storage models.Storage) string { + normalizedName := strings.ToLower(strings.TrimSpace(storage.Name)) + if profile, ok := lookupDemoProxmoxClusterProfile(storage.Instance); ok { + switch normalizedName { + case "shared-storage": + return profile.SharedStorageName + case "pbs-primary": + return profile.StoragePrefix + "-backup-primary" + case "pbs-offsite": + return profile.StoragePrefix + "-backup-offsite" + } + } return scenarioStorageAliasForNode(storage.Name, storage.Node) } @@ -1504,51 +1558,70 @@ func scenarioStorageAliasForNode(name, node string) string { switch normalizedName { case "shared-storage": return "shared-backup-fabric" - case "pbs-pve1": - return "backup-vault-a" - case "pbs-pve2": - return "backup-vault-b" - case "pbs-pve3": - return "backup-vault-c" case "local": - switch normalizedNode { - case "pve1": - return "west-a-iso-library" - case "pve2": - return "west-b-iso-library" - case "pve3": - return "west-c-iso-library" - case "pve4": - return "dr-a-iso-library" - case "pve5": - return "dr-b-iso-library" - case "pve6": - return "dr-c-iso-library" - default: - return fmt.Sprintf("%s-iso-library", normalizedNode) - } + return fmt.Sprintf("%s-iso-library", demoProxmoxNodeStoragePrefix(normalizedNode)) case "local-zfs": - switch normalizedNode { - case "pve1": - return "west-a-service-pool" - case "pve2": - return "west-b-service-pool" - case "pve3": - return "west-c-service-pool" - case "pve4": - return "dr-a-service-pool" - case "pve5": - return "dr-b-service-pool" - case "pve6": - return "dr-c-service-pool" - default: - return fmt.Sprintf("%s-service-pool", normalizedNode) - } + return fmt.Sprintf("%s-service-pool", demoProxmoxNodeStoragePrefix(normalizedNode)) default: return trimmedName } } +func lookupDemoProxmoxClusterProfile(sourceInstance string) (demoProxmoxClusterProfile, bool) { + for _, profile := range demoProxmoxClusterProfiles { + if strings.EqualFold(strings.TrimSpace(sourceInstance), profile.SourceInstance) || + strings.EqualFold(strings.TrimSpace(sourceInstance), profile.Name) { + return profile, true + } + } + return demoProxmoxClusterProfile{}, false +} + +func demoProxmoxNodeOrdinal(nodeName string) (int, bool) { + normalized := strings.ToLower(strings.TrimSpace(nodeName)) + if !strings.HasPrefix(normalized, "pve") { + return 0, false + } + ordinal, err := strconv.Atoi(strings.TrimPrefix(normalized, "pve")) + return ordinal, err == nil && ordinal > 0 +} + +func demoProxmoxNodeDisplayName(node models.Node, sourceInstance string) string { + if profile, ok := lookupDemoProxmoxClusterProfile(sourceInstance); ok { + ordinal, valid := demoProxmoxNodeOrdinal(node.Name) + if !valid { + return profile.NodeDisplayPrefix + } + letter := rune('A' + (ordinal-1)%mockProxmoxClusterSize) + return fmt.Sprintf("%s %c", profile.NodeDisplayPrefix, letter) + } + + switch strings.ToLower(strings.TrimSpace(node.Name)) { + case "standalone1": + return "London Edge Standalone" + case "standalone2": + return "Automation Lab Standalone" + default: + return humanizeHostDisplayName(node.Name) + } +} + +func demoProxmoxNodeStoragePrefix(nodeName string) string { + if ordinal, ok := demoProxmoxNodeOrdinal(nodeName); ok { + clusterIndex := (ordinal - 1) / mockProxmoxClusterSize + if clusterIndex >= 0 && clusterIndex < len(demoProxmoxClusterProfiles) { + profile := demoProxmoxClusterProfiles[clusterIndex] + letter := rune('a' + (ordinal-1)%mockProxmoxClusterSize) + return fmt.Sprintf("%s-%c", profile.StoragePrefix, letter) + } + } + return strings.ReplaceAll(strings.ToLower(strings.TrimSpace(nodeName)), "_", "-") +} + +func isDemoOfflineProxmoxNode(nodeName string) bool { + return strings.EqualFold(strings.TrimSpace(nodeName), demoOfflineProxmoxNode) +} + func defaultDemoVMwareMetrics() *vmware.InventoryMetrics { return &vmware.InventoryMetrics{ CPUPercent: demoFloat64Ptr(17.4), diff --git a/internal/mock/demo_scenarios_benchmark_test.go b/internal/mock/demo_scenarios_benchmark_test.go new file mode 100644 index 0000000000..d11cca15cd --- /dev/null +++ b/internal/mock/demo_scenarios_benchmark_test.go @@ -0,0 +1,52 @@ +package mock + +import ( + "testing" + "time" +) + +func BenchmarkBuildDefaultDemoFixtureGraph(b *testing.B) { + cfg := DefaultConfig + cfg.RandomMetrics = false + now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + graph := buildFixtureGraph(cfg, now) + if len(graph.State.Nodes) != DefaultConfig.NodeCount { + b.Fatalf("node count = %d, want %d", len(graph.State.Nodes), DefaultConfig.NodeCount) + } + } +} + +func BenchmarkUpdateDefaultDemoFixtureGraph(b *testing.B) { + cfg := DefaultConfig + cfg.RandomMetrics = false + now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) + graph := buildFixtureGraph(cfg, now) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + graph.UpdateMetrics(cfg, now.Add(time.Duration(i+1)*cfg.UpdateInterval)) + } +} + +func BenchmarkDefaultDemoUnifiedResourceSnapshot(b *testing.B) { + cfg := DefaultConfig + cfg.RandomMetrics = false + now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) + graph := buildFixtureGraph(cfg, now) + resources, _ := graph.UnifiedResourceSnapshot() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + resources, _ = graph.UnifiedResourceSnapshot() + if len(resources) <= 500 { + b.Fatalf("resource count = %d, want a fleet-scale snapshot", len(resources)) + } + } + b.ReportMetric(float64(len(resources)), "resources") +} diff --git a/internal/mock/demo_scenarios_test.go b/internal/mock/demo_scenarios_test.go index 224f569236..383ef85f55 100644 --- a/internal/mock/demo_scenarios_test.go +++ b/internal/mock/demo_scenarios_test.go @@ -92,6 +92,71 @@ func TestBuildFixtureGraphAppliesCuratedDemoScenarioAcrossEstate(t *testing.T) { } } +func TestDefaultDemoProxmoxEstateIsLargeMultiClusterAndBounded(t *testing.T) { + cfg := DefaultConfig + cfg.RandomMetrics = false + + now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) + graph := buildFixtureGraph(cfg, now) + + if got, want := len(graph.State.Nodes), 32; got != want { + t.Fatalf("default demo node count = %d, want %d", got, want) + } + + clusterCounts := make(map[string]int) + standaloneCount := 0 + for _, node := range graph.State.Nodes { + if node.IsClusterMember { + clusterCounts[node.ClusterName]++ + continue + } + standaloneCount++ + } + wantClusters := []string{ + "Production West", + "Production East", + "Core Services", + "Disaster Recovery", + "Edge Sites", + } + for _, clusterName := range wantClusters { + if got := clusterCounts[clusterName]; got != mockProxmoxClusterSize { + t.Fatalf("cluster %q node count = %d, want %d", clusterName, got, mockProxmoxClusterSize) + } + } + if standaloneCount != 2 { + t.Fatalf("standalone Proxmox node count = %d, want 2", standaloneCount) + } + + guestCount := len(graph.State.VMs) + len(graph.State.Containers) + if guestCount <= 500 || guestCount > 700 { + t.Fatalf("default demo guest count = %d, want a windowed but bounded estate in (500, 700]", guestCount) + } + + // Local storage, two shared PBS definitions per clustered node, and a + // bounded cluster-wide NFS/Ceph set must stay linear in the node count. + if storageCount := len(graph.State.Storage); storageCount > len(graph.State.Nodes)*5 { + t.Fatalf("default demo storage count = %d, exceeds linear fleet budget %d", storageCount, len(graph.State.Nodes)*5) + } + + if !nodeDisplayNameExists(graph, "East Production A") || + !nodeDisplayNameExists(graph, "Edge Sites A") || + !nodeDisplayNameExists(graph, "London Edge Standalone") { + t.Fatal("expected multi-site and standalone Proxmox display identities") + } + if !demoProxmoxNodeIsOffline(graph, demoOfflineProxmoxNode) { + t.Fatalf("expected curated offline Proxmox node %q", demoOfflineProxmoxNode) + } + for _, storage := range graph.State.Storage { + if !strings.EqualFold(strings.TrimSpace(storage.Node), demoOfflineProxmoxNode) { + continue + } + if !strings.EqualFold(strings.TrimSpace(storage.Status), "offline") || storage.Active || storage.Enabled { + t.Fatalf("offline node storage retained live posture: %+v", storage) + } + } +} + func TestCuratedDemoProtectionStoriesProduceRepresentativePostures(t *testing.T) { cfg := DefaultConfig cfg.RandomMetrics = false @@ -479,7 +544,7 @@ func TestFixtureGraphUpdateMetricsRestoresStableDemoInfrastructurePosture(t *tes graph.UpdateMetrics(cfg, later) if !hostsMatchCuratedPosture(graph) { - t.Fatal("expected demo host agents to restore curated posture (pve5 + prod-euw1-k8s-03 offline) after metric refresh") + t.Fatalf("expected demo host agents to restore curated posture (%s + prod-euw1-k8s-03 offline) after metric refresh", demoOfflineProxmoxNode) } if !dockerHostsMatchCuratedPosture(graph) { t.Fatal("expected curated docker hosts to restore posture (one offline) after metric refresh") @@ -740,7 +805,7 @@ func pmgInstanceExists(graph FixtureGraph, want string) bool { func hostsMatchCuratedPosture(graph FixtureGraph) bool { for _, host := range graph.State.Hosts { hostname := strings.ToLower(strings.TrimSpace(host.Hostname)) - if hostname == "pve5" || hostname == "prod-euw1-k8s-03" { + if isDemoOfflineProxmoxNode(hostname) || hostname == "prod-euw1-k8s-03" { if host.Status != "offline" { return false } @@ -878,24 +943,47 @@ func connectionHealthMatchesCuratedDemoState(graph FixtureGraph) bool { func firstLegacyMockClusterLeak(graph FixtureGraph) string { for _, node := range graph.State.Nodes { - if strings.EqualFold(node.Instance, "mock-cluster") || strings.EqualFold(node.ClusterName, "mock-cluster") { + if isLegacyMockClusterName(node.Instance) || isLegacyMockClusterName(node.ClusterName) { return "node:" + firstNonEmptyTrimmed(node.DisplayName, node.Name, node.ID) } } for _, vm := range graph.State.VMs { - if strings.EqualFold(vm.Instance, "mock-cluster") { + if isLegacyMockClusterName(vm.Instance) { return "vm:" + firstNonEmptyTrimmed(vm.Name, vm.ID) } } for _, container := range graph.State.Containers { - if strings.EqualFold(container.Instance, "mock-cluster") { + if isLegacyMockClusterName(container.Instance) { return "container:" + firstNonEmptyTrimmed(container.Name, container.ID) } } for _, storage := range graph.State.Storage { - if strings.EqualFold(storage.Instance, "mock-cluster") { + if isLegacyMockClusterName(storage.Instance) { return "storage:" + firstNonEmptyTrimmed(storage.Name, storage.ID) } } + for _, disk := range graph.State.PhysicalDisks { + if isLegacyMockClusterName(disk.Instance) { + return "physical-disk:" + firstNonEmptyTrimmed(disk.Model, disk.ID) + } + } + for _, cluster := range graph.State.CephClusters { + if isLegacyMockClusterName(cluster.Instance) || isLegacyMockClusterName(cluster.Name) { + return "ceph:" + firstNonEmptyTrimmed(cluster.Name, cluster.ID) + } + } return "" } + +func isLegacyMockClusterName(value string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(value)), "mock-cluster") +} + +func demoProxmoxNodeIsOffline(graph FixtureGraph, nodeName string) bool { + for _, node := range graph.State.Nodes { + if strings.EqualFold(strings.TrimSpace(node.Name), strings.TrimSpace(nodeName)) { + return strings.EqualFold(strings.TrimSpace(node.Status), "offline") + } + } + return false +} diff --git a/internal/mock/generator.go b/internal/mock/generator.go index c1a717b98b..faaf9a3678 100644 --- a/internal/mock/generator.go +++ b/internal/mock/generator.go @@ -116,14 +116,14 @@ func mockRecoveryChance(meanRecovery time.Duration) float64 { return currentMockUpdateStepSeconds() / meanRecovery.Seconds() } -// Default fixture sizes target a mature small-to-mid homelab / SMB -// environment so platform pages exercise table density, sorting, -// grouping, drawer behavior, and responsive layout out of the box: -// - 5 Proxmox nodes (cluster + standalone) with 6 VMs and 8 LXCs each. -// NodeCount stays at 5 to keep the curated demo scenario in -// `internal/mock/demo_scenarios.go` (which seasons pve1..pve5 with -// regional labels and shared-fabric storage) the single source of -// truth for hostname presentation in mock mode. +// Default fixture sizes target a large, believable mixed estate so platform +// pages prove fleet-scale grouping, filtering, windowing, drawers, and +// responsive layout out of the box: +// - 32 Proxmox nodes across five six-node clusters plus two standalone +// systems, with enough VMs and LXCs to cross the workload table's bounded +// row-window threshold. The curated demo scenario in +// `internal/mock/demo_scenarios.go` owns the human-facing site, cluster, +// workload, and storage story layered over these graph-native fixtures. // - 5 Docker/Podman hosts with 14 containers each // - 4 standalone Pulse-managed hosts // - 3 Kubernetes clusters (production + staging + edge) with 5 nodes, @@ -131,8 +131,8 @@ func mockRecoveryChance(meanRecovery time.Duration) float64 { // overview, nodes, pods, and deployments tabs all exercise grouped // and flat layouts against a real multi-cluster footprint var DefaultConfig = MockConfig{ - NodeCount: 5, - VMsPerNode: 6, + NodeCount: 32, + VMsPerNode: 10, LXCsPerNode: 8, DockerHostCount: 5, DockerContainersPerHost: 14, @@ -901,16 +901,16 @@ func generateNodes(config MockConfig) []models.Node { // never controls an allocation size. The normalized count still controls how // many nodes are generated. nodes := make([]models.Node, 0, maxMockNodeCount) + clusterNodeCount := mockProxmoxClusteredNodeCount(config.NodeCount) - // First 5 nodes are part of the cluster - clusterNodeCount := 5 - if config.NodeCount < 5 { - clusterNodeCount = config.NodeCount - } - - // Generate clustered nodes + // Generate clustered nodes in realistic six-node failure domains. Keeping + // cluster identity on the generic graph means every downstream fixture + // (guests, storage, Ceph, recovery, and unified resources) inherits the same + // topology instead of relying on presentation-only demo rewrites. for i := 0; i < clusterNodeCount; i++ { nodeName := fmt.Sprintf("pve%d", i+1) + clusterOrdinal := i/mockProxmoxClusterSize + 1 + clusterName := fmt.Sprintf("mock-cluster-%d", clusterOrdinal) isHighLoad := false for _, n := range config.HighLoadNodes { if n == nodeName { @@ -920,10 +920,10 @@ func generateNodes(config MockConfig) []models.Node { } node := generateNode(nodeName, isHighLoad, config) - node.Instance = "mock-cluster" // Part of cluster + node.Instance = clusterName node.DisplayName = fmt.Sprintf("%s (%s)", node.Instance, nodeName) node.IsClusterMember = true - node.ClusterName = "mock-cluster" + node.ClusterName = clusterName // ID format matches real system: instance-nodename node.ID = fmt.Sprintf("%s-%s", node.Instance, nodeName) node.CPU = sampleNaturalMetric("node", node.ID, "cpu", 0.05, 0.85, 1.0, time.Now()) @@ -963,6 +963,33 @@ func generateNodes(config MockConfig) []models.Node { return nodes } +const ( + mockProxmoxClusterSize = 6 + maxMockProxmoxClusterCount = 5 +) + +// mockProxmoxClusteredNodeCount assigns nodes to complete or useful cluster +// groups while leaving a one- or two-node tail as standalone infrastructure. +// Tiny requested estates also stay standalone: calling one or two nodes a +// cluster makes the fixture less representative, not more. +func mockProxmoxClusteredNodeCount(nodeCount int) int { + if nodeCount < 3 { + return 0 + } + + maxClustered := mockProxmoxClusterSize * maxMockProxmoxClusterCount + clustered := nodeCount + if clustered > maxClustered { + clustered = maxClustered + } + + remainder := clustered % mockProxmoxClusterSize + if remainder > 0 && remainder < 3 { + clustered -= remainder + } + return clustered +} + func generateNode(name string, highLoad bool, config MockConfig) models.Node { baseLoad := 0.15 if highLoad { @@ -4752,54 +4779,73 @@ func generateStorage(nodes []models.Node) []models.Storage { // noise rather than a curated estate. } - // Add PBS storage for each node (simulating node-specific PBS namespaces) - // In real clusters, each node reports ALL PBS storage entries but with node-specific namespaces - // This matches real behavior where each node sees all PBS configurations - clusterNodes := []models.Node{} - for _, n := range nodes { - if n.Instance == "mock-cluster" { - clusterNodes = append(clusterNodes, n) + clusterNodesByInstance := make(map[string][]models.Node) + for _, node := range nodes { + if !node.IsClusterMember || strings.TrimSpace(node.Instance) == "" { + continue } + clusterNodesByInstance[node.Instance] = append(clusterNodesByInstance[node.Instance], node) } + clusterInstances := make([]string, 0, len(clusterNodesByInstance)) + for instance := range clusterNodesByInstance { + clusterInstances = append(clusterInstances, instance) + } + sort.Strings(clusterInstances) - // Each cluster node reports ALL PBS storage entries (one for each node) - for _, node := range clusterNodes { - // Each node sees ALL PBS storage configurations - for _, pbsTargetNode := range clusterNodes { - pbsTotal := int64(950 * 1024 * 1024 * 1024) // ~950GB matching real PBS - pbsID := fmt.Sprintf("%s-%s-pbs-%s", node.Instance, node.Name, pbsTargetNode.Name) - pbsUsage := SampleMetric("pbsDatastore", pbsID, "usage", now) - pbsUsed := int64(float64(pbsTotal) * (pbsUsage / 100.0)) - storage = append(storage, models.Storage{ - ID: pbsID, - Name: fmt.Sprintf("pbs-%s", pbsTargetNode.Name), - Node: node.Name, // The node that reports this storage - Instance: node.Instance, - Type: "pbs", - Status: "available", - Total: pbsTotal, - Used: pbsUsed, - Free: pbsTotal - pbsUsed, - Usage: float64(pbsUsed) / float64(pbsTotal) * 100, - Content: "backup", - Shared: true, // PBS storage is shared cluster-wide (all nodes can access it) - Enabled: true, - Active: true, - LastSeen: now, - }) + // Every node reports the cluster's two shared PBS storage definitions. This + // stays linear in fleet size: the former one-storage-per-target-node shape + // grew quadratically and made a large demo look busy for the wrong reason. + for _, clusterInstance := range clusterInstances { + clusterNodes := clusterNodesByInstance[clusterInstance] + sort.Slice(clusterNodes, func(i, j int) bool { return clusterNodes[i].Name < clusterNodes[j].Name }) + for _, node := range clusterNodes { + for storeIndex, storeName := range []string{"pbs-primary", "pbs-offsite"} { + pbsTotal := int64(48+storeIndex*16) * 1024 * 1024 * 1024 * 1024 + pbsID := fmt.Sprintf("%s-%s-%s", node.Instance, node.Name, storeName) + pbsUsage := SampleMetric("pbsDatastore", pbsID, "usage", now) + pbsUsed := int64(float64(pbsTotal) * (pbsUsage / 100.0)) + isOffline := node.Status != "online" || node.ConnectionHealth == "offline" || node.Uptime <= 0 + status := "available" + enabled := true + active := true + lastSeen := now + if isOffline { + status = "offline" + enabled = false + active = false + pbsUsed = 0 + pbsUsage = 0 + lastSeen = now.Add(-10 * time.Minute) + } + storage = append(storage, models.Storage{ + ID: pbsID, + Name: storeName, + Node: node.Name, + Instance: node.Instance, + Type: "pbs", + Status: status, + Total: pbsTotal, + Used: pbsUsed, + Free: pbsTotal - pbsUsed, + Usage: pbsUsage, + Content: "backup", + Shared: true, + Enabled: enabled, + Active: active, + LastSeen: lastSeen, + }) + } } - } - // Add a shared storage (NFS or CephFS) - if len(nodes) > 1 { - sharedTotal := int64(10 * 1024 * 1024 * 1024 * 1024) // 10TB - sharedUsage := SampleMetric("storage", "shared-storage", "usage", now) + sharedTotal := int64(20 * 1024 * 1024 * 1024 * 1024) + sharedID := fmt.Sprintf("%s-shared-storage", clusterInstance) + sharedUsage := SampleMetric("storage", sharedID, "usage", now) sharedUsed := int64(float64(sharedTotal) * (sharedUsage / 100.0)) storage = append(storage, models.Storage{ - ID: "shared-storage", + ID: sharedID, Name: "shared-storage", - Node: "shared", // Shared storage uses "shared" as node per production code - Instance: nodes[0].Instance, + Node: "shared", + Instance: clusterInstance, Type: "nfs", Status: "available", Total: sharedTotal, @@ -4812,25 +4858,15 @@ func generateStorage(nodes []models.Node) []models.Storage { Active: true, LastSeen: now, }) - } - // Cluster-wide Ceph storage (RBD + CephFS) so the Proxmox Ceph - // surface has real cluster topology to render. Without these, the - // canonical Ceph adapter never sees any cephfs/rbd/ceph-typed - // storage and skips cluster synthesis entirely. - clusterInstance := "" - for _, n := range nodes { - if n.Instance == "mock-cluster" { - clusterInstance = n.Instance - break - } - } - if clusterInstance != "" { + // Cluster-wide Ceph storage (RBD + CephFS) keeps every PVE failure + // domain visible through the canonical Ceph topology surface. cephRbdTotal := int64(48 * 1024 * 1024 * 1024 * 1024) // 48TB raw - cephRbdUsage := SampleMetric("storage", "ceph-rbd", "usage", now) + cephRbdID := fmt.Sprintf("%s-ceph-rbd", clusterInstance) + cephRbdUsage := SampleMetric("storage", cephRbdID, "usage", now) cephRbdUsed := int64(float64(cephRbdTotal) * (cephRbdUsage / 100.0)) storage = append(storage, models.Storage{ - ID: fmt.Sprintf("%s-ceph-rbd", clusterInstance), + ID: cephRbdID, Name: "ceph-rbd", Node: "shared", Instance: clusterInstance, @@ -4848,10 +4884,11 @@ func generateStorage(nodes []models.Node) []models.Storage { }) cephFsTotal := int64(24 * 1024 * 1024 * 1024 * 1024) // 24TB - cephFsUsage := SampleMetric("storage", "cephfs-data", "usage", now) + cephFsID := fmt.Sprintf("%s-cephfs", clusterInstance) + cephFsUsage := SampleMetric("storage", cephFsID, "usage", now) cephFsUsed := int64(float64(cephFsTotal) * (cephFsUsage / 100.0)) storage = append(storage, models.Storage{ - ID: fmt.Sprintf("%s-cephfs", clusterInstance), + ID: cephFsID, Name: "cephfs-data", Node: "shared", Instance: clusterInstance, diff --git a/internal/monitoring/monitor_metrics_slo_test.go b/internal/monitoring/monitor_metrics_slo_test.go index 8e6d016224..609c2c64d0 100644 --- a/internal/monitoring/monitor_metrics_slo_test.go +++ b/internal/monitoring/monitor_metrics_slo_test.go @@ -67,8 +67,11 @@ func TestStartMockMetricsSampler_PrewarmsDefaultWorkloadChartCaches(t *testing.T }) cfg := compactMockFixtureConfig() - cfg.VMsPerNode = 1 - cfg.LXCsPerNode = 1 + // The fixture intentionally varies per-node guest density. Keep the compact + // test floor above that variation so every workload family is guaranteed to + // exist regardless of whether the single node is clustered or standalone. + cfg.VMsPerNode = 4 + cfg.LXCsPerNode = 4 cfg.DockerHostCount = 1 cfg.DockerContainersPerHost = 1 cfg.K8sClusterCount = 1 diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index ffa25b4a03..c536a77654 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -1415,6 +1415,9 @@ func TestUpdateDemoWorkflowUsesGovernedNetworkPath(t *testing.T) { `resolve_config_dir`, `set_env_value DEMO_MODE true`, `set_env_value PULSE_MOCK_MODE true`, + `set_env_value PULSE_MOCK_NODES 32`, + `set_env_value PULSE_MOCK_VMS_PER_NODE 10`, + `set_env_value PULSE_MOCK_LXCS_PER_NODE 8`, `ensure_demo_fixture_entitlement`, `"demo_fixtures"`, `del(.integrity)`, diff --git a/scripts/tests/test-toggle-mock.sh b/scripts/tests/test-toggle-mock.sh index 5d283b824d..60f04196f0 100755 --- a/scripts/tests/test-toggle-mock.sh +++ b/scripts/tests/test-toggle-mock.sh @@ -210,8 +210,8 @@ test_ensure_mock_env_file_seeds_canonical_demo_defaults() { env_contents="${output}" assert_contains "ensure_mock_env_file seeds mock mode default" "${env_contents}" "PULSE_MOCK_MODE=false" - assert_contains "ensure_mock_env_file seeds canonical node count" "${env_contents}" "PULSE_MOCK_NODES=5" - assert_contains "ensure_mock_env_file seeds canonical vm count" "${env_contents}" "PULSE_MOCK_VMS_PER_NODE=6" + assert_contains "ensure_mock_env_file seeds canonical node count" "${env_contents}" "PULSE_MOCK_NODES=32" + assert_contains "ensure_mock_env_file seeds canonical vm count" "${env_contents}" "PULSE_MOCK_VMS_PER_NODE=10" assert_contains "ensure_mock_env_file seeds canonical lxc count" "${env_contents}" "PULSE_MOCK_LXCS_PER_NODE=8" assert_contains "ensure_mock_env_file seeds canonical docker host count" "${env_contents}" "PULSE_MOCK_DOCKER_HOSTS=5" assert_contains "ensure_mock_env_file seeds canonical docker container count" "${env_contents}" "PULSE_MOCK_DOCKER_CONTAINERS=14" @@ -254,7 +254,7 @@ EOF assert_contains "ensure_mock_env_file migrates legacy mock mode" "${env_contents}" "PULSE_MOCK_MODE=true" assert_contains "ensure_mock_env_file migrates legacy node count" "${env_contents}" "PULSE_MOCK_NODES=9" - assert_contains "ensure_mock_env_file still seeds missing defaults" "${env_contents}" "PULSE_MOCK_VMS_PER_NODE=6" + assert_contains "ensure_mock_env_file still seeds missing defaults" "${env_contents}" "PULSE_MOCK_VMS_PER_NODE=10" } test_set_mock_mode_does_not_recreate_legacy_sidecars() { diff --git a/scripts/toggle-mock.sh b/scripts/toggle-mock.sh index 0b09e36127..1e2f259274 100755 --- a/scripts/toggle-mock.sh +++ b/scripts/toggle-mock.sh @@ -231,8 +231,8 @@ load_env_file() { mock_default_entries() { cat <<'ENVEOF' PULSE_MOCK_MODE=false -PULSE_MOCK_NODES=5 -PULSE_MOCK_VMS_PER_NODE=6 +PULSE_MOCK_NODES=32 +PULSE_MOCK_VMS_PER_NODE=10 PULSE_MOCK_LXCS_PER_NODE=8 PULSE_MOCK_DOCKER_HOSTS=5 PULSE_MOCK_DOCKER_CONTAINERS=14 diff --git a/tests/integration/tests/49-demo-scenario-curation.spec.ts b/tests/integration/tests/49-demo-scenario-curation.spec.ts index 36f1393777..df0075b3af 100644 --- a/tests/integration/tests/49-demo-scenario-curation.spec.ts +++ b/tests/integration/tests/49-demo-scenario-curation.spec.ts @@ -135,33 +135,109 @@ test.describe.serial("Demo scenario curation", () => { // split into per-platform surfaces; the curated estate proof follows. await page.goto("/proxmox", { waitUntil: "domcontentloaded" }); await waitForAppRoute(page); - await expect(page.getByTestId("proxmox-page")).toBeVisible({ timeout: 60_000 }); + await expect(page.getByTestId("proxmox-page")).toBeVisible({ + timeout: 60_000, + }); await expectSomeRowContains(page, "West Production A"); + await expectSomeRowContains(page, "East Production A"); + await expectSomeRowContains(page, "Edge Sites A"); + await expectSomeRowContains(page, "London Edge Standalone"); + const workloadSearch = page.getByPlaceholder( + "Search VMs and LXCs by name, VMID, node, or status", + ); + await workloadSearch.fill("checkout-web-01"); await expectSomeRowContains(page, "checkout-web-01"); + await workloadSearch.press("ControlOrMeta+A"); + await workloadSearch.press("Backspace"); + await expect(workloadSearch).toHaveValue(""); await expectNoRowContains(page, "mock-cluster"); + const guestTotals = page.getByTestId("proxmox-guest-totals"); + await expect(guestTotals).toBeVisible({ timeout: 30_000 }); + await expect + .poll(async () => { + const text = (await guestTotals.innerText()).replace(/\s+/g, " "); + return Array.from( + text.matchAll(/(\d+) (?:running|attention|stopped)/g), + ).reduce((total, match) => total + Number(match[1]), 0); + }) + .toBeGreaterThan(500); + + // The authored estate deliberately crosses the shared workload table's + // windowing threshold. Fleet scale must not mean mounting every guest row. + await expect + .poll(() => + page + .getByTestId("workloads-table-surface") + .locator("tr.workload-row") + .count(), + ) + .toBeGreaterThan(0); + expect( + await page + .getByTestId("workloads-table-surface") + .locator("tr.workload-row") + .count(), + ).toBeLessThanOrEqual(140); + await page.goto("/docker", { waitUntil: "domcontentloaded" }); await waitForAppRoute(page); - await expect(page.getByTestId("docker-page")).toBeVisible({ timeout: 60_000 }); + await expect(page.getByTestId("docker-page")).toBeVisible({ + timeout: 60_000, + }); await expectSomeRowContains(page, "customer-portal"); await expectSomeRowContains(page, "backup-coordinator"); await page.goto("/kubernetes", { waitUntil: "domcontentloaded" }); await waitForAppRoute(page); - await expect(page.getByTestId("kubernetes-page")).toBeVisible({ timeout: 60_000 }); - await expect(page.getByText('Production EU', { exact: true }).first()).toBeVisible({ + await expect(page.getByTestId("kubernetes-page")).toBeVisible({ + timeout: 60_000, + }); + await expect( + page.getByText("Production EU", { exact: true }).first(), + ).toBeVisible({ timeout: 30_000, }); await page.goto("/proxmox/storage", { waitUntil: "domcontentloaded" }); await waitForAppRoute(page); - await expect(page.getByTestId("storage-page")).toBeVisible({ timeout: 60_000 }); + await expect(page.getByTestId("storage-page")).toBeVisible({ + timeout: 60_000, + }); await expectSomeRowContains(page, "shared-backup-fabric"); await expectSomeRowContains(page, "west-a-service-pool"); await expectNoRowContains(page, "mock-cluster"); await page.goto("/proxmox/backups", { waitUntil: "domcontentloaded" }); await waitForAppRoute(page); + const coverageTable = page.locator( + '[data-proxmox-backups-table="coverage"]', + ); + await expect(coverageTable).toHaveAttribute( + "data-proxmox-backups-windowed", + "true", + ); + expect( + await coverageTable + .locator('[data-proxmox-backup-row="coverage"]') + .count(), + ).toBeLessThanOrEqual(140); + const backupSearch = page.getByPlaceholder( + "Search backups by workload, node, source, or status", + ); + await backupSearch.fill("checkout-web-01"); await expectSomeRowContains(page, "checkout-web-01"); + await backupSearch.fill(""); + await page.getByRole("button", { name: "By date", exact: true }).click(); + const recoverableTable = page.locator( + '[data-proxmox-backups-table="recoverable"]', + ); + await expect(recoverableTable).toHaveAttribute( + "data-proxmox-backups-windowed", + "true", + ); + expect( + await recoverableTable.locator("[data-proxmox-backup-row]").count(), + ).toBeLessThanOrEqual(140); }); });