diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index d2ac577097..0fb5937549 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -2827,6 +2827,20 @@ transport-induced absence. Preserving this read-only evidence adds no backup, restore, retention, or recovery authority. The behavior is pinned by `useUnifiedResources.test.ts` and `useWorkloads.test.ts`. +### Coverage rows name the last backup, not the newest restore point + +The Proxmox coverage row's `Last backup` cell is the newest completed PBS +snapshot or PVE backup file. A guest snapshot never fills it: it shares the +guest's own storage, and the protection posture engine already refuses to count +snapshots as independent recovery, so a fresh snapshot beside a stale posture +must not read as a fresh backup. Snapshot-only rows show `None` and carry the +engine's own reason on hover; the guest snapshot keeps its dedicated column and +the restore-evidence disclosure. The backup-location filter re-derives that +pointer from completed, non-snapshot artifacts under the same rule. This is a +presentation and sort change only; it adds no backup, restore, or retention +authority. Pinned by `proxmoxBackupRecoveryModel.test.ts` and +`ProxmoxCoverageTable.test.tsx`. + ### Retained Patrol objectives do not create recovery authority The shared `internal/api` retained-objective endpoints may scope an outcome to diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index c46f3d2046..b28f928956 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -855,6 +855,23 @@ the daemon tally as width shrinks, keeps one five-value phone projection (cluster, health, OSDs, pools, capacity) whose values each fit whole, and leaves quorum membership and the FSID to the cluster disclosure, where the FSID stays searchable and is shown in full. +The Proxmox host table drops temperature from its phone projection +(container widths under 440px) so the node name, expansion toggle, and +status dot share the same five-column shape as the guest table beneath it, +and the identity cell takes a wider phone anchor than the shared default +because of that leading toggle. `ProxmoxBackupServersTable`, +`ProxmoxCoverageTable`, and `ProxmoxRecoverableTable` keep their phone +projections to values that fit whole: the server row keeps its status word +and used percentage and defers the backup count to the next width, backup +ages use a suffix-free compact form with the timestamp on hover, and the +recoverable row keeps its source and state badges while the repository +location moves to the row title. Header width classes must not compete with +the colgroup those tables size through the shared weighted helper. +The shared guest table's phone name cell carries the same leading toggle, +status dot, and backup badge, so it takes the same wider phone anchor. The +recoverable row's Details cell names the PVE archive format rather than the +volid, which repeats the storage, guest type, VMID, and timestamp the row +already shows in their own columns; the volid stays on hover and in search. Optional numeric table cells follow the same split: unified-resource consumers own which count or replica field is meaningful, whether the domain should zero-default an absent scheduler/service/inventory count, whether a diff --git a/frontend-modern/browser-verification/b87770c76dcc9d03.json b/frontend-modern/browser-verification/b87770c76dcc9d03.json new file mode 100644 index 0000000000..af97241487 --- /dev/null +++ b/frontend-modern/browser-verification/b87770c76dcc9d03.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "base_sha": "1f140a5d380c20dc5eaee14cde07666b75436a9d", + "verified_at": "2026-10-03T20:25:42Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/guestRowModel.tsx", + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx", + "frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts", + "frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts", + "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx", + "frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/guestRowModel.tsx": "7787854145257e13bc347c2513f89e595060e606857a9a7c4ae6c0a897b6c639", + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "b791a50845befbdcfdca979355188fb1e977878ce69e8a72cbbdf90fda5e8cbe", + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "97a95f97c94795005300fe55fdca81756b6ce9f11e977d1a3f683775eb0dc88d", + "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "f85558ab1092078b9a3d160c986450d2167552b10b2f76c10087fceef5af0689", + "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx": "1dc95dd84f499e38a8fca77650ae72ff6c903b7c03c8fdc3cf366b309817e488", + "frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts": "b872437fb4cfb61b07e0c949ae6f323d6fd3f2622e57480931f7ef74fafe79c2", + "frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts": "0037e6f97e1180ef0c0d585ec5e9385dcbe4c4e1b51a7bc29d8631b4b0e87745", + "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx": "41202d1ae6095d882b124a55aed4110c74f38047d51b83b07bf52a6e95ca6b01", + "frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts": "908af893071386d1a11a7191694b9fef399e4b134e78188bee90faa80f758e91" + }, + "routes": [ + "/proxmox/overview", + "/proxmox/backups/date", + "/proxmox/backups/coverage" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 560, + "height": 900 + }, + { + "width": 440, + "height": 900 + }, + { + "width": 390, + "height": 900 + } + ], + "states": [ + "PR #2435 rebuilt as one commit on origin/main 1f140a5d38 (after Tailwind 4, #2443/#2444), checked on a private mock stack built from main with Vite serving this tree, light at every width and dark at 1440 and 390", + "Overview: the nodes table shows Node, CPU, Mem, Disk, Age at 390 and 440 with no clipped cell and adds Temp from 560; the guests table phone name cell is 95px wide (artifact-cache-310 needs 101px, so long mock names still truncate by a few characters); no table scrolls horizontally", + "Backups By date at 1440: Details reads vma.zst or the PBS file count, zero clipped cells in either table; at 560/440/390 only workload and datastore identity strings clip", + "Backups Coverage at 1440: the column is Last backup with ages such as 9d ago and the timestamp in the hover title; only London Edge Standalone clips; at 440/390 the row shows Workload, Posture, Age, PBS, Job and the only clipped hits are sr-only task labels; the mock emitted no snapshot-only workload, so the None state is covered by unit tests rather than seen live" + ], + "interactions": [ + "scrollWidth-over-clientWidth sweep of every visible table cell on the three routes at 1440, 560, 440 and 390, then DOM probes to separate sr-only spans from real clipping", + "Read the Last backup and Details column values and hover titles across all rows at 1440", + "vitest for src/features/proxmox, src/components/Workloads and src/features/platformPage on the rebuilt tree: 125 files, 2192 tests passed" + ] +} diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx index 6e72824233..d6fb813c6b 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx @@ -1308,23 +1308,26 @@ describe('GUEST_COLUMNS', () => { }); it('derives mobile overrides from the canonical guest column model', () => { + // The phone name cell carries the toggle, status dot, and backup badge, so + // it takes a wider anchor than the shared 30% and the metric chips share + // the rest. expect(getGuestColumnStyle('name', true)).toEqual({ - width: '30%', - 'max-width': '30%', + width: '38%', + 'max-width': '38%', }); expect(getGuestColumnStyle('cpu', true)).toEqual({ - width: '11.3235%', - 'max-width': '11.3235%', + width: '10.0294%', + 'max-width': '10.0294%', }); expect(getGuestColumnStyle('availability', true)).toEqual({ - width: '7.2059%', - 'max-width': '7.2059%', + width: '6.3824%', + 'max-width': '6.3824%', }); expect(getGuestColumnStyle('type', true)).toEqual({ - width: '9.2647%', - 'max-width': '9.2647%', + width: '8.2059%', + 'max-width': '8.2059%', }); - expect(getGuestColumnWidthStyle('name', true)).toEqual({ width: '30%' }); + expect(getGuestColumnWidthStyle('name', true)).toEqual({ width: '38%' }); expect(getGuestColumnWidthStyle('diskIo', true)).toEqual({ width: '170px' }); }); diff --git a/frontend-modern/src/components/Workloads/guestRowModel.tsx b/frontend-modern/src/components/Workloads/guestRowModel.tsx index 40b81bf47e..0c7c67ea17 100644 --- a/frontend-modern/src/components/Workloads/guestRowModel.tsx +++ b/frontend-modern/src/components/Workloads/guestRowModel.tsx @@ -469,6 +469,13 @@ const percentageColumn = (width: string): GuestColumnWidthOverride => ({ maxWidth: width, }); +// The phone name cell carries the expansion toggle, status dot, and backup +// badge before the name, so the shared 30% identity anchor left a 390px phone +// 66px for the name and clipped "artifact-cache-310" to "artifact-ca…". The +// four metric chips beside it need far less than an even split, so the name +// takes the same wider phone anchor the Proxmox host table uses. +const GUEST_PHONE_IDENTITY_WIDTH_PERCENT = 38; + // Responsive weights are normalized against the currently visible column set. // That keeps each workload view mode full-width without assuming one fixed set. const GUEST_COLUMN_RESPONSIVE_WEIGHTS: Record< @@ -548,9 +555,11 @@ const getResponsiveColumnOverride = ( activeIds, layoutMode === 'narrow' ? { columnId: 'name', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT } - : layoutMode === 'phone' || layoutMode === 'mobile' - ? { columnId: 'name', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT } - : undefined, + : layoutMode === 'phone' + ? { columnId: 'name', widthPercent: GUEST_PHONE_IDENTITY_WIDTH_PERCENT } + : layoutMode === 'mobile' + ? { columnId: 'name', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT } + : undefined, ); return typeof style.width === 'string' ? percentageColumn(style.width) : undefined; }; diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx index 0ed2264d9a..ec407b7b8c 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx @@ -422,14 +422,10 @@ export function ProxmoxBackupServersTable(props: { } header={ <> - + - + @@ -449,20 +445,16 @@ export function ProxmoxBackupServersTable(props: { - + - + Used @@ -597,12 +589,14 @@ export function ProxmoxBackupServersTable(props: { > {(datastore) => (
- + + + diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx index ef84f0d194..f90874b0d5 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx @@ -43,6 +43,7 @@ import { import { buildProxmoxBackupRecoveryModel, coverageRowMatchesSearch, + isBackupArtifact, recoverableArtifactMatchesSearch, type RecoverableArtifact, type WorkloadCoverageRow, @@ -347,17 +348,20 @@ export const ProxmoxBackupsTable: Component<{ return recoveryModel().coverageRows.flatMap((row) => { const artifacts = row.artifacts.filter(locationMatches); if (artifacts.length === 0) return []; + // Same rule as the model: running or failed artifacts stay listed but + // never become a "latest" pointer, and snapshots never become the backup. + const completed = artifacts.filter((artifact) => !artifact.running && !artifact.failed); return [ { ...row, artifacts, - latestRecovery: newestArtifact(artifacts), - latestPBS: newestArtifact(artifacts.filter((artifact) => artifact.sourceKind === 'pbs')), + latestBackup: newestArtifact(completed.filter(isBackupArtifact)), + latestPBS: newestArtifact(completed.filter((artifact) => artifact.sourceKind === 'pbs')), latestArchive: newestArtifact( - artifacts.filter((artifact) => artifact.sourceKind === 'archive'), + completed.filter((artifact) => artifact.sourceKind === 'archive'), ), latestSnapshot: newestArtifact( - artifacts.filter((artifact) => artifact.sourceKind === 'snapshot'), + completed.filter((artifact) => artifact.sourceKind === 'snapshot'), ), pbsCount: artifacts.filter((artifact) => artifact.sourceKind === 'pbs').length, archiveCount: artifacts.filter((artifact) => artifact.sourceKind === 'archive').length, @@ -385,7 +389,7 @@ export const ProxmoxBackupsTable: Component<{ case 'workload': return cmpString(a.workload.label, b.workload.label, direction); case 'latest': - return cmpNumber(a.latestRecovery?.createdMs, b.latestRecovery?.createdMs, direction); + return cmpNumber(a.latestBackup?.createdMs, b.latestBackup?.createdMs, direction); case 'pbs': return cmpNumber(a.latestPBS?.createdMs, b.latestPBS?.createdMs, direction); case 'archive': diff --git a/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx index dcafa1e9ae..ce0c4e3d86 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx @@ -50,6 +50,10 @@ import { useProxmoxBackupTableWindowing } from './useProxmoxBackupTableWindowing // "None" instead of restating the source ("No PVE backup file"). The long form // truncated at most widths and stays available as the hover title. const COVERAGE_EMPTY_LABEL = 'None'; +const COVERAGE_NO_BACKUP_TITLE = 'No PBS snapshot or PVE backup file'; +// Matches the posture engine's own wording for a snapshot-only subject. +const COVERAGE_SNAPSHOT_ONLY_TITLE = + 'No PBS snapshot or PVE backup file. Guest snapshots alone do not prove independent recovery.'; const coveragePostureVariant = ( posture: WorkloadCoverageRow['posture'], @@ -240,7 +244,13 @@ export function ProxmoxCoverageTable(props: { headClass={getPlatformTableHeadClassForKind('text')} /> + {COVERAGE_EMPTY_LABEL} } > - {(artifact) => } + {(artifact) => ( + + )} @@ -429,7 +451,12 @@ export function ProxmoxCoverageTable(props: { } > - {(artifact) => } + {(artifact) => ( + + )} @@ -703,7 +730,7 @@ export function ProxmoxCoverageTable(props: { > {artifact.detail || '—'} diff --git a/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx index 367d630696..f5a514a569 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx @@ -123,6 +123,9 @@ export function ProxmoxRecoverableTable(props: { artifact.workload.name || artifact.workload.label, artifact.workload.typeLabel, artifact.workload.vmid, + // The phone projection has no location column, so the row's + // hover title carries the repository instead. + columnVisible('location') ? undefined : artifact.location, ] .filter(Boolean) .join(' · ')} @@ -163,7 +166,7 @@ export function ProxmoxRecoverableTable(props: { - + @@ -189,7 +192,10 @@ export function ProxmoxRecoverableTable(props: { - + {artifact.detail || '—'} diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx index 50adeb3dd8..b9b7e75eaf 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx @@ -80,7 +80,7 @@ describe('ProxmoxCoverageTable column visibility', () => { expect(headers).toContain('Target ID'); expect(headers).toContain('Node'); expect(headers).toContain('Posture▲'); - expect(headers).toContain('Restore'); + expect(headers).toContain('Last backup'); expect(headers).toContain('PBS snapshot'); expect(headers).toContain('Guest snapshot'); expect(headers).not.toContain('PVE file'); @@ -239,7 +239,7 @@ describe('ProxmoxCoverageTable column visibility', () => { // cannot truncate to "No PVE backup f…". const empty = (title: string) => document.querySelector(`td span[title="${title}"]`); for (const title of [ - 'No restore point', + 'No PBS snapshot or PVE backup file', 'No PBS snapshot', 'No PVE backup file', 'No guest snapshot', diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx index 0dea167612..a8c0248ff5 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx @@ -55,12 +55,13 @@ describe('ProxmoxRecoverableTable responsive columns', () => { expect([...document.querySelectorAll('thead th')].map((th) => th.textContent?.trim())).toEqual([ 'Workload', 'Via', - 'Loc', 'Age▼', 'State', ]); expect(document.body.textContent).toContain('VM 100'); - expect(document.body.textContent).toContain('main / pve1'); + // The phone projection has no location column; the row title carries it. + expect(document.body.textContent).not.toContain('main / pve1'); + expect(document.querySelector('td [title*="main / pve1"]')).not.toBeNull(); expect(document.body.textContent).not.toContain('5 PBS files'); }); diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts index 8ae3acda04..6ab7884a78 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts @@ -522,7 +522,7 @@ describe('buildProxmoxBackupRecoveryModel summary, sort, and empty edges', () => expect(model.coverageSummary.attention).toBe(0); }); - it('sorts rows by postureRank asc, then latestRecovery createdMs desc', () => { + it('sorts rows by postureRank asc, then latestBackup createdMs desc', () => { const postures = new Map([ ['vm-830', posture('vm-830', 'unprotected')], ['vm-840', posture('vm-840', 'protected')], diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts index a7e222ac29..43fc7e5a41 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts @@ -162,6 +162,35 @@ describe('proxmoxBackupRecoveryModel', () => { ); }); + it('shows the archive format as its detail and keeps the volid on hover and in search', () => { + const model = buildProxmoxBackupRecoveryModel({ + workloads: [workload({})], + pbsBackups: [], + archives: [archive({ format: 'tar.zst' })], + snapshots: [], + tasks: [], + nowMs: Date.parse('2026-05-26T08:00:00Z'), + }); + + const [artifact] = model.recoverableArtifacts; + expect(artifact.detail).toBe('tar.zst'); + expect(artifact.detailTitle).toBe('local:backup/vzdump-lxc-112-2026_05_24-02_00_00.tar.zst'); + expect(recoverableArtifactMatchesSearch(artifact, 'vzdump-lxc-112')).toBe(true); + }); + + it('falls back to the archive file name when the provider reports no format', () => { + const model = buildProxmoxBackupRecoveryModel({ + workloads: [workload({})], + pbsBackups: [], + archives: [archive({ format: '' })], + snapshots: [], + tasks: [], + nowMs: Date.parse('2026-05-26T08:00:00Z'), + }); + + expect(model.recoverableArtifacts[0].detail).toBe('vzdump-lxc-112-2026_05_24-02_00_00.tar.zst'); + }); + it('keeps a terminally incomplete PBS artifact out of latest recovery', () => { const model = buildProxmoxBackupRecoveryModel({ workloads: [workload({})], @@ -186,10 +215,49 @@ describe('proxmoxBackupRecoveryModel', () => { const failed = model.recoverableArtifacts.find((artifact) => artifact.id.includes('failed')); expect(failed).toMatchObject({ failed: true, running: false, verified: undefined }); - expect(model.coverageRows[0].latestRecovery?.createdAt).toBe('2026-05-25T01:34:25Z'); + expect(model.coverageRows[0].latestBackup?.createdAt).toBe('2026-05-25T01:34:25Z'); expect(recoverableArtifactMatchesSearch(failed!, 'failed incomplete')).toBe(true); }); + it('keeps a fresh guest snapshot from standing in for the last backup', () => { + const model = buildProxmoxBackupRecoveryModel({ + workloads: [workload({})], + pbsBackups: [pbsBackup({ backupTime: '2026-05-05T01:34:25Z' })], + archives: [], + snapshots: [snapshot({ time: '2026-05-26T03:00:00Z' })], + tasks: [], + nowMs: Date.parse('2026-05-26T08:00:00Z'), + }); + + const row = model.coverageRows[0]; + expect(row.latestSnapshot?.createdAt).toBe('2026-05-26T03:00:00Z'); + expect(row.latestBackup?.sourceKind).toBe('pbs'); + expect(row.latestBackup?.createdAt).toBe('2026-05-05T01:34:25Z'); + expect( + getRecoveryAgeBand( + row.latestBackup?.createdMs, + model.coverageRows.length && Date.parse('2026-05-26T08:00:00Z'), + ), + ).toBe('aging'); + }); + + it('reports no last backup for a snapshot-only workload', () => { + const model = buildProxmoxBackupRecoveryModel({ + workloads: [workload({})], + pbsBackups: [], + archives: [], + snapshots: [snapshot()], + tasks: [], + nowMs: Date.parse('2026-05-26T08:00:00Z'), + }); + + const row = model.coverageRows[0]; + expect(row.latestBackup).toBeUndefined(); + expect(row.latestSnapshot?.sourceKind).toBe('snapshot'); + expect(row.artifacts).toHaveLength(1); + expect(model.coverageSummary.recoverableArtifacts).toBe(1); + }); + it('uses canonical workload attention while retaining the failed task as evidence', () => { const model = buildProxmoxBackupRecoveryModel({ workloads: [workload({})], diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts index 8817787792..a52bd7c075 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts @@ -44,10 +44,10 @@ describe('Proxmox backups responsive table presentation', () => { 'status', 'datastore', 'used', - 'backups', ]); expect(getBackupServerColumnWidthStyle('server', 'compact')).toEqual({ width: '40%' }); - expect(getBackupServerColumnWidthStyle('used', 'compact')).toEqual({ width: '20%' }); + expect(getBackupServerColumnWidthStyle('status', 'compact')).toEqual({ width: '23%' }); + expect(getBackupServerColumnWidthStyle('used', 'compact')).toEqual({ width: '15%' }); expect(ids(getBackupServerColumns('basic'))).toEqual([ 'server', 'status', @@ -124,12 +124,11 @@ describe('Proxmox backups responsive table presentation', () => { expect(ids(getRecoverableColumns('compact'))).toEqual([ 'workload', 'source', - 'location', 'created', 'state', ]); expect(getRecoverableColumnWidthStyle('workload', 'compact')).toEqual({ width: '40%' }); - expect(getRecoverableColumnWidthStyle('state', 'compact')).toEqual({ width: '16%' }); + expect(getRecoverableColumnWidthStyle('state', 'compact')).toEqual({ width: '30%' }); expect(ids(getRecoverableColumns('basic'))).toEqual([ 'workload', 'source', diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx index 64f5895ce9..4ffe9073bd 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx @@ -8,8 +8,10 @@ import proxmoxBackupsTableSharedSource from '../proxmoxBackupsTableShared.tsx?ra import { ArtifactSourceBadge, ArtifactStateBadge, + ProxmoxBackupAgeText, ProxmoxBackupWorkloadTypeBadge, SortableHead, + formatCompactBackupAge, } from '../proxmoxBackupsTableShared'; import type { RecoverableArtifact } from '../proxmoxBackupRecoveryModel'; @@ -86,6 +88,28 @@ describe('proxmoxBackupsTableShared', () => { } }); + it('drops the age suffix in the phone projection and keeps the timestamp on hover', () => { + const createdAt = new Date(Date.now() - 18 * 60 * 60 * 1000).toISOString(); + expect(formatCompactBackupAge(createdAt)).toBe('18h'); + expect(formatCompactBackupAge(new Date().toISOString())).toBe('now'); + + const artifact = { + id: 'pbs:1', + nativeId: '1', + sourceKind: 'pbs', + sourceLabel: 'PBS', + workload: { key: 'w', type: 'vm', typeLabel: 'VM', vmid: '100', label: 'VM 100' }, + createdAt, + createdMs: Date.parse(createdAt), + location: 'main', + detail: '', + protected: false, + } as RecoverableArtifact; + render(() => ); + const age = screen.getByText('18h'); + expect(age.closest('[title]')?.getAttribute('title')).toContain(createdAt); + }); + it('keeps backup age text on the shared relative-time primitive', () => { expect(proxmoxBackupsTableSharedSource).toContain('PlatformTableRelativeTimeValue'); expect(proxmoxBackupsTableSharedSource).not.toContain('formatRelativeTime('); diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts index 66ee4d61d0..1b587b374a 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts @@ -19,16 +19,16 @@ describe('proxmoxHostTableModel', () => { expect(getProxmoxHostColumnWidthStyle('uptime', 'narrow', ids)).toEqual({ width: '15%' }); }); - it('shows the full operational metric track at phone width', () => { + it('matches the guest table columns and keeps node names whole at phone width', () => { const columns = getProxmoxHostVisibleColumnsForLayout('phone'); const ids = columns.map((column) => column.id); - expect(ids).toEqual(['node', 'cpu', 'memory', 'disk', 'temp', 'uptime']); + expect(ids).toEqual(['node', 'cpu', 'memory', 'disk', 'uptime']); expect(getProxmoxHostTableMinWidthClass('phone')).toBe('min-w-0'); - expect(getProxmoxHostColumnWidthStyle('node', 'phone', ids)).toEqual({ width: '30%' }); - expect(getProxmoxHostColumnWidthStyle('disk', 'phone', ids)).toEqual({ width: '13.3824%' }); - expect(getProxmoxHostColumnWidthStyle('temp', 'phone', ids)).toEqual({ width: '15.4412%' }); - expect(getProxmoxHostColumnWidthStyle('uptime', 'phone', ids)).toEqual({ width: '14.4118%' }); + expect(getProxmoxHostColumnWidthStyle('node', 'phone', ids)).toEqual({ width: '38%' }); + expect(getProxmoxHostColumnWidthStyle('cpu', 'phone', ids)).toEqual({ width: '15.5%' }); + expect(getProxmoxHostColumnWidthStyle('disk', 'phone', ids)).toEqual({ width: '15.5%' }); + expect(getProxmoxHostColumnWidthStyle('uptime', 'phone', ids)).toEqual({ width: '15.5%' }); }); it('prioritizes live utilization columns in the mobile host table', () => { diff --git a/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts b/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts index c71846a6ae..b4288cf9f6 100644 --- a/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts +++ b/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts @@ -47,6 +47,8 @@ export interface RecoverableArtifact { /** Operator-facing source/repository label used by the backup-location filter. */ locationLabel?: string; detail: string; + /** Full provider identifier behind a shortened `detail`, shown on hover. */ + detailTitle?: string; protected: boolean; verified?: boolean; /** A still-running backup is writing this artifact; it is not recoverable yet. */ @@ -70,7 +72,13 @@ export interface WorkloadCoverageRow { key: string; workload: WorkloadReference; artifacts: RecoverableArtifact[]; - latestRecovery?: RecoverableArtifact; + /** + * Newest completed PBS snapshot or PVE backup file. Guest snapshots never + * fill this slot: they live on the guest's own storage and the protection + * posture engine does not count them as independent recovery, so a fresh + * snapshot must not read as a fresh backup beside a stale posture. + */ + latestBackup?: RecoverableArtifact; latestPBS?: RecoverableArtifact; latestArchive?: RecoverableArtifact; latestSnapshot?: RecoverableArtifact; @@ -138,12 +146,25 @@ export function getRecoveryAgeBand( return 'stale'; } +// A guest snapshot shares the guest's storage, so it is listed as restore +// evidence but never counted as a backup. Mirrors the server posture rule that +// "snapshots alone do not prove independent recovery". +export function isBackupArtifact(artifact: RecoverableArtifact): boolean { + return artifact.sourceKind !== 'snapshot'; +} + function parseTimestampMs(value: string | undefined): number | undefined { if (!value) return undefined; const ms = Date.parse(value); return Number.isFinite(ms) ? ms : undefined; } +function archiveFileName(volid: string | undefined): string { + const trimmed = volid?.trim() ?? ''; + if (!trimmed) return ''; + return trimmed.slice(trimmed.lastIndexOf('/') + 1); +} + function pbsBackupFileDetailLabel(fileCount: number): string { if (fileCount <= 0) return 'PBS files not listed'; if (fileCount === 1) return '1 PBS file'; @@ -572,7 +593,12 @@ export function buildProxmoxBackupRecoveryModel( size: archive.size, location: archive.storage || archive.node || '—', ...artifactLocationScope('archive', archive.instance, archive.storage || archive.node), - detail: archive.volid || archive.format || archiveSource.detailFallbackLabel, + // The volid repeats the storage, guest type, VMID, and timestamp the row + // already shows in their own columns and clips at every width; the + // archive format is the one fact it adds, so that is the cell and the + // volid stays on hover and in search. + detail: archive.format || archiveFileName(archive.volid) || archiveSource.detailFallbackLabel, + detailTitle: archive.volid || undefined, protected: archive.protected, verified: archive.inProgress ? undefined : archive.isPBS ? archive.verified : undefined, running: archive.inProgress === true, @@ -626,6 +652,7 @@ export function buildProxmoxBackupRecoveryModel( // the "latest" pointers (which answer "what can I recover to?") skip them. // The artifacts themselves stay listed with an honest state. const completed = row.artifacts.filter((artifact) => !artifact.running && !artifact.failed); + row.latestBackup = newest(completed.filter(isBackupArtifact), (artifact) => artifact.createdMs); row.latestPBS = newest( completed.filter((artifact) => artifact.sourceKind === 'pbs'), (artifact) => artifact.createdMs, @@ -638,7 +665,6 @@ export function buildProxmoxBackupRecoveryModel( completed.filter((artifact) => artifact.sourceKind === 'snapshot'), (artifact) => artifact.createdMs, ); - row.latestRecovery = newest(completed, (artifact) => artifact.createdMs); } const coverageRows = Array.from(rows.values()).map((row) => { @@ -665,7 +691,7 @@ export function buildProxmoxBackupRecoveryModel( coverageRows.sort((left, right) => { if (left.postureRank !== right.postureRank) return left.postureRank - right.postureRank; - return (right.latestRecovery?.createdMs ?? 0) - (left.latestRecovery?.createdMs ?? 0); + return (right.latestBackup?.createdMs ?? 0) - (left.latestBackup?.createdMs ?? 0); }); artifacts.sort((left, right) => (right.createdMs ?? 0) - (left.createdMs ?? 0)); @@ -730,6 +756,7 @@ export function recoverableArtifactMatchesSearch( artifact.sourceTitle, artifact.location, artifact.detail, + artifact.detailTitle, artifact.verified === true ? 'verified' : artifact.verified === false diff --git a/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts b/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts index b1342d3396..f0ad8ec765 100644 --- a/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts +++ b/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts @@ -93,8 +93,10 @@ const BACKUP_SERVER_VISIBLE: Record< > = { // Reachability and datastore exhaustion are the two risks that can stop all // future backups. Host telemetry and space-efficiency context return only as - // the table gains enough room to keep those headline answers readable. - compact: ['server', 'status', 'datastore', 'used', 'backups'], + // the table gains enough room to keep those headline answers readable. The + // phone projection keeps those two answers whole ("Healthy", "33.6%") and + // lets the backup count return with the first wider layout. + compact: ['server', 'status', 'datastore', 'used'], basic: ['server', 'status', 'datastore', 'used', 'backups'], operational: ['server', 'status', 'cpu', 'memory', 'datastore', 'used', 'backups'], expanded: ['server', 'status', 'cpu', 'memory', 'uptime', 'datastore', 'used', 'backups'], @@ -105,8 +107,10 @@ const BACKUP_SERVER_WEIGHTS: Record< ProxmoxBackupsTableLayoutMode, Partial> > = { - compact: { server: 40, status: 15, datastore: 15, used: 20, backups: 10 }, - basic: { server: 24, status: 14, datastore: 20, used: 28, backups: 14 }, + compact: { server: 40, status: 23, datastore: 22, used: 15 }, + // Just above the phone projection the used cell carries its used/total + // pair again, the longest value in the row, so it takes the most room. + basic: { server: 24, status: 13.6, datastore: 18.2, used: 28.4, backups: 9.8 }, operational: { server: 20, status: 12, @@ -175,7 +179,7 @@ export const COVERAGE_COLUMNS: readonly BackupTableColumn[] = { id: 'targetId', label: 'Target ID', kind: 'text' }, { id: 'node', label: 'Node', kind: 'text' }, { id: 'posture', label: 'Posture', kind: 'text' }, - { id: 'latest', label: 'Restore', kind: 'numeric-value' }, + { id: 'latest', label: 'Last backup', kind: 'numeric-value' }, { id: 'pbs', label: 'PBS snapshot', kind: 'text' }, { id: 'archive', label: 'PVE file', kind: 'text' }, { id: 'snapshot', label: 'Guest snapshot', kind: 'text' }, @@ -198,11 +202,14 @@ const COVERAGE_WEIGHTS: Record< ProxmoxBackupsTableLayoutMode, Partial> > = { - compact: { workload: 40, posture: 18, latest: 16, pbs: 16, task: 10 }, + // Posture keeps its whole compact word ("Unknown"), the two age cells hold + // the suffix-free compact age, and the job dot takes what a "Job" header + // needs. + compact: { workload: 40, posture: 21, latest: 14.5, pbs: 14.5, task: 10 }, // Above the phone projection the identity columns (workload, node) take the // slack that the short type badge, target id, and age cells cannot use, so // names stay whole instead of truncating beside empty space. - basic: { workload: 31, node: 20, posture: 20, latest: 14, task: 15 }, + basic: { workload: 31, node: 18, posture: 22, latest: 15, task: 14 }, operational: { workload: 27, type: 8, node: 17, posture: 17, latest: 15, task: 16 }, expanded: { workload: 19, @@ -287,7 +294,10 @@ const RECOVERABLE_VISIBLE: Record getRecoveryAgeBand(props.artifact.createdMs); const title = () => { const parts = [recoveryAgeTitleByBand[band()], props.artifact.createdAt].filter(Boolean); @@ -228,7 +239,12 @@ export function ProxmoxBackupAgeText(props: { artifact: RecoverableArtifact }) { return ( - + } + > + {formatCompactBackupAge(props.artifact.createdAt)} + ); } diff --git a/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts b/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts index 76ce153aa8..be6aaafe31 100644 --- a/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts +++ b/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts @@ -31,7 +31,7 @@ const HOST_COLUMN_MIN_LAYOUT: Record = { - node: 18, - version: 7, + node: 20, + version: 6, uptime: 7, cpu: 13, memory: 13, @@ -55,7 +55,7 @@ const HOST_COLUMN_DESKTOP_WIDTHS: Record = { temp: 6, vms: 5, cts: 5, - cluster: 13, + cluster: 12, }; const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record< @@ -69,13 +69,15 @@ const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record< disk: 15, uptime: 15, }, + // Phones carry the same five columns as the guest table below them. A sixth + // (temperature) left the node name with four characters, which made a + // cluster of "Analytics A".."F" rows unreadable. phone: { node: 32, - cpu: 13, - memory: 13, - disk: 13, - temp: 15, - uptime: 14, + cpu: 15, + memory: 15, + disk: 15, + uptime: 15, }, mobile: { node: 32, @@ -146,6 +148,11 @@ export const getProxmoxHostVisibleColumnsForLayout = ( ); }; +// The node cell carries the expansion toggle and status dot before the name, +// so it needs more than the shared 30% phone identity anchor to show a +// typical node name whole. +const PROXMOX_HOST_PHONE_IDENTITY_WIDTH_PERCENT = 38; + export const getProxmoxHostColumnWidthStyle = ( columnId: ProxmoxHostTableColumnId, layoutMode: WorkloadTableLayoutMode, @@ -159,9 +166,11 @@ export const getProxmoxHostColumnWidthStyle = ( visibleColumnIds, layoutMode === 'narrow' ? { columnId: 'node', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT } - : layoutMode === 'phone' || layoutMode === 'mobile' - ? { columnId: 'node', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT } - : undefined, + : layoutMode === 'phone' + ? { columnId: 'node', widthPercent: PROXMOX_HOST_PHONE_IDENTITY_WIDTH_PERCENT } + : layoutMode === 'mobile' + ? { columnId: 'node', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT } + : undefined, ); };