Show the last backup in Proxmox coverage and keep its tables whole on phones (#2448)

Supersedes #2435, whose branch conflicted with the Tailwind 4 move
(#2443) in `proxmoxHostTableModel.test.ts`. Same change, rebuilt as one
commit on current main with a fresh browser pass and receipt.

- **Coverage:** the Restore column is now Last backup, meaning the
newest completed PBS snapshot or PVE backup file. Guest snapshots no
longer count, matching the posture engine.
- **Phones:** the node, backup server, coverage and By date tables keep
their values whole on phones.
- **By date Details:** shows the archive format (`vma.zst`, `tar.zst`)
with the full volid on hover and in search.

Verified on a private mock stack at 1440, 560, 440 and 390px, in light
and dark. On phones, only identity strings clip.

A second commit restores two `storage-recovery.md` notes from main,
covering History touch inspection and pool-to-disk ownership. The
original branch had dropped them with a stale copy of the file.
This commit is contained in:
rcourtman
2026-10-03 22:33:52 +01:00
committed by GitHub
20 changed files with 379 additions and 89 deletions
@@ -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
@@ -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
@@ -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"
]
}
@@ -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' });
});
@@ -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;
};
@@ -422,14 +422,10 @@ export function ProxmoxBackupServersTable(props: {
}
header={
<>
<TableHead
class={`${getPlatformTableHeadClassForKind('name')} platform-table-mobile-w-30 md:w-[15%]`}
>
<TableHead class={`${getPlatformTableHeadClassForKind('name')}`}>
<PlatformResponsiveTableLabel compact="Server" full="Backup server" />
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-15 md:w-[10%]`}
>
<TableHead class={`${getPlatformTableHeadClassForKind('text')}`}>
<PlatformResponsiveTableLabel compact="State" full="Status" />
</TableHead>
<Show when={columnVisible('version')}>
@@ -449,20 +445,16 @@ export function ProxmoxBackupServersTable(props: {
</TableHead>
</Show>
<Show when={columnVisible('datastore')}>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-20 md:w-[13%]`}
>
<TableHead class={`${getPlatformTableHeadClassForKind('text')}`}>
<PlatformResponsiveTableLabel compact="Store" full="Datastore" />
</TableHead>
</Show>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-20 md:w-[15%]`}
>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')}`}>
Used
</TableHead>
<Show when={columnVisible('backups')}>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[8%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')}`}
aria-label="Backups"
title="Backups"
>
@@ -597,12 +589,14 @@ export function ProxmoxBackupServersTable(props: {
>
{(datastore) => (
<div class="flex items-center justify-end gap-2">
<StatusDot
size="sm"
variant={usageVariant(pct())}
title={`Datastore ${formatPlatformTablePercentValue(pct())} used`}
ariaHidden
/>
<Show when={layoutMode() !== 'compact' && layoutMode() !== 'basic'}>
<StatusDot
size="sm"
variant={usageVariant(pct())}
title={`Datastore ${formatPlatformTablePercentValue(pct())} used`}
ariaHidden
/>
</Show>
<span class={`tabular-nums font-medium ${usageToneClass(pct())}`}>
<PlatformTablePercentValue value={pct()} />
</span>
@@ -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':
@@ -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')}
/>
<SortableHead
label={layoutMode() === 'compact' ? 'Age' : 'Restore'}
label={
layoutMode() === 'compact'
? 'Age'
: layoutMode() === 'basic'
? 'Backup'
: 'Last backup'
}
sortKey="latest"
currentSort={props.sortKey}
direction={props.sortDirection}
@@ -407,14 +417,26 @@ export function ProxmoxCoverageTable(props: {
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
<Show
when={row.latestRecovery}
when={row.latestBackup}
fallback={
<span class="text-muted" title="No restore point">
<span
class="text-muted"
title={
row.latestSnapshot
? COVERAGE_SNAPSHOT_ONLY_TITLE
: COVERAGE_NO_BACKUP_TITLE
}
>
{COVERAGE_EMPTY_LABEL}
</span>
}
>
{(artifact) => <ProxmoxBackupAgeText artifact={artifact()} />}
{(artifact) => (
<ProxmoxBackupAgeText
artifact={artifact()}
compact={layoutMode() === 'compact'}
/>
)}
</Show>
</TableCell>
<Show when={columnVisible('pbs')}>
@@ -429,7 +451,12 @@ export function ProxmoxCoverageTable(props: {
</span>
}
>
{(artifact) => <ProxmoxBackupAgeText artifact={artifact()} />}
{(artifact) => (
<ProxmoxBackupAgeText
artifact={artifact()}
compact={layoutMode() === 'compact'}
/>
)}
</Show>
</TableCell>
</Show>
@@ -703,7 +730,7 @@ export function ProxmoxCoverageTable(props: {
>
<span
class="inline-block max-w-[24rem] truncate"
title={artifact.detail}
title={artifact.detailTitle ?? artifact.detail}
>
{artifact.detail || '—'}
</span>
@@ -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: {
</TableCell>
</Show>
<TableCell class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}>
<ProxmoxBackupAgeText artifact={artifact} />
<ProxmoxBackupAgeText artifact={artifact} compact={layoutMode() === 'compact'} />
</TableCell>
<Show when={columnVisible('size')}>
<TableCell class={`${getPlatformTableCellClassForKind('metric-bar')} text-base-content`}>
@@ -189,7 +192,10 @@ export function ProxmoxRecoverableTable(props: {
</TableCell>
<Show when={columnVisible('details')}>
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
<span class="inline-block max-w-[20rem] truncate" title={artifact.detail}>
<span
class="inline-block max-w-[20rem] truncate"
title={artifact.detailTitle ?? artifact.detail}
>
{artifact.detail || '—'}
</span>
</TableCell>
@@ -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',
@@ -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');
});
@@ -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')],
@@ -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({})],
@@ -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',
@@ -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(() => <ProxmoxBackupAgeText artifact={artifact} compact />);
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(');
@@ -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', () => {
@@ -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
@@ -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<Record<BackupServerColumnId, number>>
> = {
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<CoverageColumnId>[] =
{ 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<Record<CoverageColumnId, number>>
> = {
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<ProxmoxBackupsTableLayoutMode, readonly Recove
// A recovery-point feed starts with identity, source, location, age, and
// verification. Size and verbose details progressively return with usable
// room; the full values remain available through their title affordance.
compact: ['workload', 'source', 'location', 'created', 'state'],
// The phone projection keeps the source and state badges and the age whole;
// the repository location returns with the first wider layout and stays in
// the row's hover title meanwhile.
compact: ['workload', 'source', 'created', 'state'],
basic: ['workload', 'source', 'location', 'created', 'state'],
operational: ['workload', 'type', 'source', 'location', 'created', 'size', 'state'],
expanded: ['workload', 'type', 'targetId', 'source', 'location', 'created', 'size', 'state'],
@@ -301,7 +311,7 @@ const RECOVERABLE_WEIGHTS: Record<
// Reserve enough phone-width space for complete state badges (including
// Failed and Running) rather than clipping the recovery answer at the
// scroll edge. Identity remains on the canonical 40% anchor.
compact: { workload: 40, source: 12, location: 17, created: 15, state: 16 },
compact: { workload: 40, source: 16, created: 14, state: 30 },
basic: { workload: 28, source: 14, location: 23, created: 18, state: 17 },
operational: {
workload: 24,
@@ -1,4 +1,4 @@
import type { Accessor } from 'solid-js';
import { Show, type Accessor } from 'solid-js';
import { filterChipStatusDot } from '@/components/shared/FilterBar';
import { type FilterOption } from '@/components/shared/FilterButtonGroup';
import { MetadataBadge } from '@/components/shared/MetadataBadge';
@@ -6,7 +6,10 @@ import { ProgressBar } from '@/components/shared/ProgressBar';
import { TableHead } from '@/components/shared/Table';
import { getTableSortIndicator } from '@/components/shared/tableSortPresentation';
import { WorkloadTypeBadge as SharedWorkloadTypeBadge } from '@/components/shared/WorkloadTypeBadge';
import { PlatformTableRelativeTimeValue } from '@/features/platformPage/sharedPlatformPage';
import {
PlatformTableRelativeTimeValue,
formatPlatformTableRelativeTimeValue,
} from '@/features/platformPage/sharedPlatformPage';
import {
getRecoveryAgeBand,
@@ -219,7 +222,15 @@ export function RowMetricBar(props: {
);
}
export function ProxmoxBackupAgeText(props: { artifact: RecoverableArtifact }) {
// Phone columns are too narrow for "18h ago", so the compact form drops the
// suffix ("18h", "now") and keeps the full age band and timestamp on hover.
export function formatCompactBackupAge(createdAt: string): string {
const relative = formatPlatformTableRelativeTimeValue(createdAt);
if (relative === 'just now') return 'now';
return relative.replace(/ ago$/, '');
}
export function ProxmoxBackupAgeText(props: { artifact: RecoverableArtifact; compact?: boolean }) {
const band = () => 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 (
<span class={`font-semibold tabular-nums ${recoveryAgeClassByBand[band()]}`} title={title()}>
<PlatformTableRelativeTimeValue value={props.artifact.createdAt} />
<Show
when={props.compact}
fallback={<PlatformTableRelativeTimeValue value={props.artifact.createdAt} />}
>
{formatCompactBackupAge(props.artifact.createdAt)}
</Show>
</span>
);
}
@@ -31,7 +31,7 @@ const HOST_COLUMN_MIN_LAYOUT: Record<ProxmoxHostTableColumnId, WorkloadTableLayo
cpu: 'narrow',
memory: 'narrow',
disk: 'narrow',
temp: 'phone',
temp: 'mobile',
uptime: 'narrow',
cluster: 'tablet',
vms: 'compact',
@@ -46,8 +46,8 @@ const HOST_COLUMN_MIN_LAYOUT: Record<ProxmoxHostTableColumnId, WorkloadTableLayo
// columns (version pill, uptime, temp gauge, vms/cts badges, cluster
// pill) take only what they need.
const HOST_COLUMN_DESKTOP_WIDTHS: Record<ProxmoxHostTableColumnId, number> = {
node: 18,
version: 7,
node: 20,
version: 6,
uptime: 7,
cpu: 13,
memory: 13,
@@ -55,7 +55,7 @@ const HOST_COLUMN_DESKTOP_WIDTHS: Record<ProxmoxHostTableColumnId, number> = {
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,
);
};