mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
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:
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -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({})],
|
||||
|
||||
+3
-4
@@ -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,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user