mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Say what backup health counts mean and drop per-source age columns (#2483)
"17 attention / 10 unprotected" were counts without a rule, and users have asked what Pulse treats as stale. The Backup health strip now carries one line built from the server's posture policy window that names the causes internal/recovery/posture.go evaluates for attention, unprotected and unknown. Nothing renders until the policy loads, so the page never shows a guessed window. The coverage table showed up to four time columns (last backup, PBS snapshot, PVE file, guest snapshot). A fresh green guest snapshot beside an amber stale backup read as protection that posture does not grant. Rows now carry the one counted backup age. The row expansion keeps the newest point and the newest completed point of every source in its bounded evidence list, so neither a run of snapshots nor a running or failed job can hide the latest usable backup.
This commit is contained in:
@@ -7438,6 +7438,11 @@ presentation. The compact row remains actionable: plain-language rationale and
|
||||
provider history/permission limitations live one disclosure deeper beside
|
||||
bounded restore evidence. A posture fetch failure must keep the evidence
|
||||
inspectable and show unknown, never a locally inferred healthy fallback.
|
||||
The bounded restore evidence keeps the newest point and the newest completed
|
||||
point of each source before filling with the newest overall, so a run of guest
|
||||
snapshots or a running job cannot push the latest usable backup out of the
|
||||
disclosure. A coverage strip footnote is
|
||||
plain muted text under the legend, not another counter or badge row.
|
||||
|
||||
Protection table controls continue to compose shared filter, table, status,
|
||||
counter, loading, error, disclosure, and inline-detail primitives. The bounded
|
||||
|
||||
@@ -171,6 +171,16 @@ backup files, never guest-local snapshots. Guest snapshots remain listed as
|
||||
restore evidence but cannot make an old independent backup look current.
|
||||
Archive detail uses its format (or filename fallback), preserving the full
|
||||
provider volid for hover and search in both Coverage and By date.
|
||||
Coverage rows carry that one counted backup age at every width; per-source
|
||||
PBS, PVE-file and guest-snapshot age columns are not row columns, because a
|
||||
fresh snapshot beside a stale backup read as protection that posture does not
|
||||
grant. The row expansion keeps the newest point and the newest completed point
|
||||
of every source inside its bounded evidence list, so neither recent snapshots
|
||||
nor a running or failed job hide the latest usable backup. The Backup health
|
||||
strip states what its attention, unprotected and unknown counts mean from the
|
||||
server posture policy (`freshnessWindowSeconds`) using the causes
|
||||
`internal/recovery/posture.go` evaluates, and renders no rule until that policy
|
||||
has loaded rather than a guessed window.
|
||||
|
||||
Phone pool/disk rows preserve health and identity while using weighted widths,
|
||||
consistent gutters and compact capacity/health labels. Full disk risk reasons
|
||||
|
||||
@@ -3299,7 +3299,9 @@ metadata.
|
||||
### Protection posture identity consumer
|
||||
|
||||
`ProxmoxCoverageTable` remains a unified-resource identity consumer while
|
||||
storage/recovery owns protection truth. Live VM/LXC rows carry the exact
|
||||
storage/recovery owns protection truth. Dropping the per-source age columns
|
||||
from its rows changes no identity input: rows still key posture by the exact
|
||||
canonical `Resource.id`. Live VM/LXC rows carry the exact
|
||||
canonical `Resource.id` into one bounded posture batch; the table must not parse
|
||||
its presentation key, VMID, name, node, or instance to mint a replacement
|
||||
resource identity. Host and orphaned backup artifacts have no canonical live
|
||||
|
||||
@@ -319,9 +319,6 @@ function Fixture() {
|
||||
: new Set([...current, key]),
|
||||
)
|
||||
}
|
||||
showPbsColumn
|
||||
showArchiveColumn
|
||||
showSnapshotColumn
|
||||
showTaskColumn={false}
|
||||
/>
|
||||
<h2>By date</h2>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "4c266391c3753eaa1e6bea1cfd8339c5d40c01de",
|
||||
"verified_at": "2026-10-05T01:10:06Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupsCoverageStrip.tsx",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx",
|
||||
"frontend-modern/src/features/proxmox/proxmoxBackupsTableModel.ts",
|
||||
"frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupsCoverageStrip.tsx": "97d3f7a23a959406dd8cb9e92a8921fd38118194d5f757e31b6e0e2eec74f27f",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "2cd4cfb0c470ba51efcdb0beafef0b630acac34373b92dd9b77de03f99b16743",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "2568f261531b4b601df7da9e1b8c1826bacc70e8a28420bbe909f2eb3524c6eb",
|
||||
"frontend-modern/src/features/proxmox/proxmoxBackupsTableModel.ts": "8f7f8a80bb994404c21cce7b75c621bdf844f80b38a9def79d6dc61fb42b8dd6",
|
||||
"frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts": "55dd4787cd47e9ccbf056ba67809fd45638b1e3f21889c6a31ce192ce87b8e67"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox/backups"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Coverage workspace, mock fleet of 68 targets: Backup health strip footnote explains attention, unprotected and unknown with the 7-day window from the server policy",
|
||||
"Coverage table at 1440: Workload / Type / Target ID / Node / Posture / Last backup / Task, no PBS snapshot, PVE file or Guest snapshot columns",
|
||||
"Coverage table at 390: Workload / Posture / Age / Job, footnote wraps under the legend without a reserved column",
|
||||
"Row expansion for a Needs attention guest with a failed newer job: posture explanation, Provider evidence and Restore evidence listing PVE file and Snapshot sources",
|
||||
"No console errors on either viewport"
|
||||
],
|
||||
"interactions": [
|
||||
"Expanded and collapsed coverage rows to inspect provider and restore evidence",
|
||||
"Compared before/after screenshots at both viewports"
|
||||
]
|
||||
}
|
||||
@@ -24,6 +24,9 @@ export interface CoverageStripProps {
|
||||
segments: readonly CoverageStripSegment[];
|
||||
// Compact context shown to the right of the title (e.g. "1.2 TB on disk").
|
||||
tail?: JSX.Element;
|
||||
// The rule behind the segments, so a count such as "17 attention" says what
|
||||
// it is counting.
|
||||
footnote?: JSX.Element;
|
||||
}
|
||||
|
||||
export const ProxmoxBackupsCoverageStrip: Component<CoverageStripProps> = (props) => {
|
||||
@@ -80,6 +83,11 @@ export const ProxmoxBackupsCoverageStrip: Component<CoverageStripProps> = (props
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
<Show when={props.footnote}>
|
||||
<p class="mt-1.5 text-[11px] leading-4 text-muted" data-coverage-strip-footnote>
|
||||
{props.footnote}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
type RecoverableFilterValue,
|
||||
type RecoverableSortKey,
|
||||
} from './proxmoxBackupsTableModel';
|
||||
import { getBackupPostureRuleText } from './proxmoxBackupsTablePresentation';
|
||||
import {
|
||||
COVERAGE_FILTERS,
|
||||
RECOVERABLE_FILTERS,
|
||||
@@ -364,20 +365,8 @@ export const ProxmoxBackupsTable: Component<{
|
||||
];
|
||||
});
|
||||
|
||||
// Source columns auto-hide when no workload anywhere has that data (computed
|
||||
// over the full set so columns don't flicker as filters change). A PBS-only
|
||||
// fleet drops the Archive and Snapshot columns.
|
||||
const coverageHasPbs = createMemo(() =>
|
||||
recoveryModel().coverageRows.some((row) => Boolean(row.latestPBS) || row.pbsCount > 0),
|
||||
);
|
||||
const coverageHasArchive = createMemo(() =>
|
||||
recoveryModel().coverageRows.some((row) => Boolean(row.latestArchive) || row.archiveCount > 0),
|
||||
);
|
||||
const coverageHasSnapshot = createMemo(() =>
|
||||
recoveryModel().coverageRows.some(
|
||||
(row) => Boolean(row.latestSnapshot) || row.snapshotCount > 0,
|
||||
),
|
||||
);
|
||||
// The task column auto-hides when no workload has task history (computed
|
||||
// over the full set so it does not flicker as filters change).
|
||||
const coverageHasTask = createMemo(() =>
|
||||
recoveryModel().coverageRows.some((row) => Boolean(row.latestTask)),
|
||||
);
|
||||
@@ -420,12 +409,6 @@ export const ProxmoxBackupsTable: Component<{
|
||||
return cmpString(a.workload.label, b.workload.label, direction);
|
||||
case 'latest':
|
||||
return cmpNumber(a.latestBackup?.createdMs, b.latestBackup?.createdMs, direction);
|
||||
case 'pbs':
|
||||
return cmpNumber(a.latestPBS?.createdMs, b.latestPBS?.createdMs, direction);
|
||||
case 'archive':
|
||||
return cmpNumber(a.latestArchive?.createdMs, b.latestArchive?.createdMs, direction);
|
||||
case 'snapshot':
|
||||
return cmpNumber(a.latestSnapshot?.createdMs, b.latestSnapshot?.createdMs, direction);
|
||||
case 'task':
|
||||
return cmpNumber(a.latestTask?.startedMs, b.latestTask?.startedMs, direction);
|
||||
}
|
||||
@@ -735,6 +718,7 @@ export const ProxmoxBackupsTable: Component<{
|
||||
{/* The health strip renders identically in both route-backed views. */}
|
||||
<ProxmoxBackupsCoverageStrip
|
||||
title="Backup health"
|
||||
footnote={getBackupPostureRuleText(protectionPostures.policy()) ?? undefined}
|
||||
tail={
|
||||
<span>
|
||||
{liveTotalCount()} targets · {recoveryModel().coverageSummary.recoverableArtifacts}{' '}
|
||||
@@ -925,9 +909,6 @@ export const ProxmoxBackupsTable: Component<{
|
||||
onSort={handleCoverageSort}
|
||||
expandedKeys={expandedCoverageRows()}
|
||||
onToggleExpand={toggleCoverageExpansion}
|
||||
showPbsColumn={coverageHasPbs()}
|
||||
showArchiveColumn={coverageHasArchive()}
|
||||
showSnapshotColumn={coverageHasSnapshot()}
|
||||
showTaskColumn={coverageHasTask()}
|
||||
/>
|
||||
|
||||
@@ -964,9 +945,6 @@ export const ProxmoxBackupsTable: Component<{
|
||||
onSort={handleCoverageSort}
|
||||
expandedKeys={expandedCoverageRows()}
|
||||
onToggleExpand={toggleCoverageExpansion}
|
||||
showPbsColumn={coverageHasPbs()}
|
||||
showArchiveColumn={coverageHasArchive()}
|
||||
showSnapshotColumn={coverageHasSnapshot()}
|
||||
showTaskColumn={coverageHasTask()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
type WorkloadCoverageRow,
|
||||
} from './proxmoxBackupRecoveryModel';
|
||||
import type { CoverageSortKey } from './proxmoxBackupsTableModel';
|
||||
import { getProxmoxBackupSourcePresentation } from './proxmoxBackupSourcePresentation';
|
||||
import {
|
||||
ArtifactSourceBadge,
|
||||
ArtifactStateBadge,
|
||||
@@ -42,6 +41,7 @@ import {
|
||||
getCoverageColumnWidthStyle,
|
||||
getCoverageLayoutForContainer,
|
||||
isCoverageEvidenceColumnVisible,
|
||||
selectCoverageRestoreEvidence,
|
||||
type CoverageColumnId,
|
||||
} from './proxmoxBackupsTablePresentation';
|
||||
import { useProxmoxBackupTableWindowing } from './useProxmoxBackupTableWindowing';
|
||||
@@ -155,12 +155,8 @@ export function ProxmoxCoverageTable(props: {
|
||||
onSort: (key: CoverageSortKey) => void;
|
||||
expandedKeys: ReadonlySet<string>;
|
||||
onToggleExpand: (key: string) => void;
|
||||
// Source columns auto-hide when no workload anywhere has that data (e.g. a
|
||||
// PBS-only fleet drops the PVE files and Snapshots columns), matching how the
|
||||
// source-detail tables already drop their conditional columns.
|
||||
showPbsColumn: boolean;
|
||||
showArchiveColumn: boolean;
|
||||
showSnapshotColumn: boolean;
|
||||
// The task column auto-hides when no workload has task history. Per-source
|
||||
// ages (PBS snapshot, PVE file, guest snapshot) live in the row expansion.
|
||||
showTaskColumn: boolean;
|
||||
layoutWidth?: Accessor<number | null | undefined>;
|
||||
}) {
|
||||
@@ -171,9 +167,6 @@ export function ProxmoxCoverageTable(props: {
|
||||
});
|
||||
const visibleColumns = createMemo(() =>
|
||||
getCoverageColumns(layoutMode(), {
|
||||
pbs: props.showPbsColumn,
|
||||
archive: props.showArchiveColumn,
|
||||
snapshot: props.showSnapshotColumn,
|
||||
task: props.showTaskColumn,
|
||||
}),
|
||||
);
|
||||
@@ -184,9 +177,6 @@ export function ProxmoxCoverageTable(props: {
|
||||
// ages were short enough for the usual gutters; compact ages use that space.
|
||||
const ageCellClass = (kind: 'text' | 'numeric-value') =>
|
||||
`${layoutMode() === 'compact' ? `px-0.5 py-1 ${kind === 'numeric-value' ? 'text-right' : ''}` : getPlatformTableCellClassForKind(kind)} text-base-content`;
|
||||
const pbsSource = getProxmoxBackupSourcePresentation('pbs');
|
||||
const archiveSource = getProxmoxBackupSourcePresentation('archive');
|
||||
const snapshotSource = getProxmoxBackupSourcePresentation('snapshot');
|
||||
// Polling rebuilds recovery-model objects. Preserve logical row identity so
|
||||
// Solid does not replace the focused toggle (or the browser's scroll anchor).
|
||||
const [stableRows, setStableRows] = createStore<WorkloadCoverageRow[]>([]);
|
||||
@@ -279,47 +269,6 @@ export function ProxmoxCoverageTable(props: {
|
||||
align="right"
|
||||
headClass={getPlatformTableHeadClassForKind('numeric-value')}
|
||||
/>
|
||||
<Show when={columnVisible('pbs')}>
|
||||
<SortableHead
|
||||
label={
|
||||
layoutMode() === 'compact' || layoutMode() === 'expanded'
|
||||
? 'PBS'
|
||||
: 'PBS snapshot'
|
||||
}
|
||||
sortKey="pbs"
|
||||
currentSort={props.sortKey}
|
||||
direction={props.sortDirection}
|
||||
onSort={props.onSort}
|
||||
align="left"
|
||||
headClass={getPlatformTableHeadClassForKind('text')}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={columnVisible('archive')}>
|
||||
<SortableHead
|
||||
label={layoutMode() === 'compact' ? 'PVE' : 'PVE file'}
|
||||
sortKey="archive"
|
||||
currentSort={props.sortKey}
|
||||
direction={props.sortDirection}
|
||||
onSort={props.onSort}
|
||||
align="left"
|
||||
headClass={getPlatformTableHeadClassForKind('text')}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={columnVisible('snapshot')}>
|
||||
<SortableHead
|
||||
label={
|
||||
layoutMode() === 'compact' || layoutMode() === 'expanded'
|
||||
? 'Guest'
|
||||
: 'Guest snapshot'
|
||||
}
|
||||
sortKey="snapshot"
|
||||
currentSort={props.sortKey}
|
||||
direction={props.sortDirection}
|
||||
onSort={props.onSort}
|
||||
align="left"
|
||||
headClass={getPlatformTableHeadClassForKind('text')}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={columnVisible('task')}>
|
||||
<SortableHead
|
||||
label={layoutMode() === 'compact' ? 'Job' : 'Task'}
|
||||
@@ -347,10 +296,7 @@ export function ProxmoxCoverageTable(props: {
|
||||
<For each={tableWindow.visibleItems()}>
|
||||
{(row) => {
|
||||
const isExpanded = () => props.expandedKeys.has(row.key);
|
||||
const evidence = () =>
|
||||
[...row.artifacts]
|
||||
.sort((left, right) => (right.createdMs ?? 0) - (left.createdMs ?? 0))
|
||||
.slice(0, 8);
|
||||
const evidence = () => selectCoverageRestoreEvidence(row.artifacts);
|
||||
const detailRowId = () => `proxmox-coverage-evidence-${row.key}`;
|
||||
return (
|
||||
<>
|
||||
@@ -457,59 +403,6 @@ export function ProxmoxCoverageTable(props: {
|
||||
)}
|
||||
</Show>
|
||||
</TableCell>
|
||||
<Show when={columnVisible('pbs')}>
|
||||
<TableCell class={ageCellClass('text')}>
|
||||
<Show
|
||||
when={row.latestPBS}
|
||||
fallback={
|
||||
<CoverageAgeFallback
|
||||
unknown={row.ageUnknown?.pbs}
|
||||
compact={layoutMode() === 'compact'}
|
||||
emptyTitle={pbsSource.coverageFallbackLabel}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(artifact) => (
|
||||
<ProxmoxBackupAgeText
|
||||
artifact={artifact()}
|
||||
compact={layoutMode() === 'compact'}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</TableCell>
|
||||
</Show>
|
||||
<Show when={columnVisible('archive')}>
|
||||
<TableCell class={ageCellClass('text')}>
|
||||
<Show
|
||||
when={row.latestArchive}
|
||||
fallback={
|
||||
<CoverageAgeFallback
|
||||
unknown={row.ageUnknown?.archive}
|
||||
compact={layoutMode() === 'compact'}
|
||||
emptyTitle={archiveSource.coverageFallbackLabel}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(artifact) => <ProxmoxBackupAgeText artifact={artifact()} />}
|
||||
</Show>
|
||||
</TableCell>
|
||||
</Show>
|
||||
<Show when={columnVisible('snapshot')}>
|
||||
<TableCell class={ageCellClass('text')}>
|
||||
<Show
|
||||
when={row.latestSnapshot}
|
||||
fallback={
|
||||
<CoverageAgeFallback
|
||||
unknown={row.ageUnknown?.snapshot}
|
||||
compact={layoutMode() === 'compact'}
|
||||
emptyTitle={snapshotSource.coverageFallbackLabel}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(artifact) => <ProxmoxBackupAgeText artifact={artifact()} />}
|
||||
</Show>
|
||||
</TableCell>
|
||||
</Show>
|
||||
<Show when={columnVisible('task')}>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
|
||||
@@ -66,9 +66,6 @@ describe('ProxmoxCoverageTable column visibility', () => {
|
||||
onSort={() => {}}
|
||||
expandedKeys={new Set<string>()}
|
||||
onToggleExpand={() => {}}
|
||||
showPbsColumn={true}
|
||||
showArchiveColumn={false}
|
||||
showSnapshotColumn={true}
|
||||
showTaskColumn={false}
|
||||
layoutWidth={() => 1_200}
|
||||
/>
|
||||
@@ -81,8 +78,9 @@ describe('ProxmoxCoverageTable column visibility', () => {
|
||||
expect(headers).toContain('Node');
|
||||
expect(headers).toContain('Posture▲');
|
||||
expect(headers).toContain('Last backup');
|
||||
expect(headers).toContain('PBS snapshot');
|
||||
expect(headers).toContain('Guest snapshot');
|
||||
// Per-source ages are expansion detail at every width.
|
||||
expect(headers).not.toContain('PBS snapshot');
|
||||
expect(headers).not.toContain('Guest snapshot');
|
||||
expect(headers).not.toContain('PVE file');
|
||||
expect(headers).not.toContain('Task');
|
||||
// Identity data lives in dedicated cells, not stacked under the name.
|
||||
@@ -93,7 +91,7 @@ describe('ProxmoxCoverageTable column visibility', () => {
|
||||
expect(document.body.textContent).not.toContain('Node pve1');
|
||||
});
|
||||
|
||||
it('keeps posture and provider evidence visible in compact rows', () => {
|
||||
it('keeps posture, backup age and job visible in compact rows', () => {
|
||||
render(() => (
|
||||
<ProxmoxCoverageTable
|
||||
rows={[row]}
|
||||
@@ -106,15 +104,12 @@ describe('ProxmoxCoverageTable column visibility', () => {
|
||||
onSort={() => {}}
|
||||
expandedKeys={new Set<string>()}
|
||||
onToggleExpand={() => {}}
|
||||
showPbsColumn={true}
|
||||
showArchiveColumn={true}
|
||||
showSnapshotColumn={true}
|
||||
showTaskColumn={true}
|
||||
layoutWidth={() => 330}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(headerTexts()).toEqual(['Workload', 'Posture▲', 'Age', 'PBS', 'Job']);
|
||||
expect(headerTexts()).toEqual(['Workload', 'Posture▲', 'Age', 'Job']);
|
||||
expect(document.body.textContent).toContain('Prot.');
|
||||
expect(document.body.textContent).not.toContain('VM 100 · pve1');
|
||||
});
|
||||
@@ -132,9 +127,6 @@ describe('ProxmoxCoverageTable column visibility', () => {
|
||||
onSort={() => {}}
|
||||
expandedKeys={new Set<string>()}
|
||||
onToggleExpand={() => {}}
|
||||
showPbsColumn={true}
|
||||
showArchiveColumn={false}
|
||||
showSnapshotColumn={false}
|
||||
showTaskColumn={false}
|
||||
layoutWidth={() => 1_200}
|
||||
/>
|
||||
@@ -158,9 +150,6 @@ describe('ProxmoxCoverageTable column visibility', () => {
|
||||
onSort={() => {}}
|
||||
expandedKeys={new Set<string>(['w1'])}
|
||||
onToggleExpand={() => {}}
|
||||
showPbsColumn={true}
|
||||
showArchiveColumn={false}
|
||||
showSnapshotColumn={false}
|
||||
showTaskColumn={false}
|
||||
layoutWidth={() => 1_200}
|
||||
/>
|
||||
@@ -197,9 +186,6 @@ describe('ProxmoxCoverageTable column visibility', () => {
|
||||
onSort={() => {}}
|
||||
expandedKeys={new Set<string>()}
|
||||
onToggleExpand={() => {}}
|
||||
showPbsColumn={true}
|
||||
showArchiveColumn={false}
|
||||
showSnapshotColumn={false}
|
||||
showTaskColumn={false}
|
||||
layoutWidth={() => 1_200}
|
||||
/>
|
||||
@@ -227,24 +213,15 @@ describe('ProxmoxCoverageTable column visibility', () => {
|
||||
onSort={() => {}}
|
||||
expandedKeys={new Set<string>()}
|
||||
onToggleExpand={() => {}}
|
||||
showPbsColumn={true}
|
||||
showArchiveColumn={true}
|
||||
showSnapshotColumn={true}
|
||||
showTaskColumn={true}
|
||||
layoutWidth={() => 1_200}
|
||||
/>
|
||||
));
|
||||
|
||||
// The header already names the source, so the cell does not repeat it and
|
||||
// cannot truncate to "No PVE backup f…".
|
||||
// The cell says None and the full reason sits on hover, so it cannot
|
||||
// truncate to "No PBS snapshot or PVE b…".
|
||||
const empty = (title: string) => document.querySelector(`td span[title="${title}"]`);
|
||||
for (const title of [
|
||||
'No PBS snapshot or PVE backup file',
|
||||
'No PBS snapshot',
|
||||
'No PVE backup file',
|
||||
'No guest snapshot',
|
||||
'No recent task',
|
||||
]) {
|
||||
for (const title of ['No PBS snapshot or PVE backup file', 'No recent task']) {
|
||||
expect(empty(title)).toHaveTextContent(/^None$/);
|
||||
}
|
||||
});
|
||||
|
||||
+89
-21
@@ -4,6 +4,7 @@ import {
|
||||
getBackupServerColumns,
|
||||
getBackupServerColumnWidthStyle,
|
||||
getBackupServerLayoutForContainer,
|
||||
getBackupPostureRuleText,
|
||||
getCoverageColumns,
|
||||
getCoverageColumnWidthStyle,
|
||||
getCoverageLayoutForContainer,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
getRecoverableColumnWidthStyle,
|
||||
getRecoverableLayoutForContainer,
|
||||
isCoverageEvidenceColumnVisible,
|
||||
selectCoverageRestoreEvidence,
|
||||
} from '../proxmoxBackupsTablePresentation';
|
||||
|
||||
const ids = <T extends string>(columns: readonly { id: T }[]) => columns.map((column) => column.id);
|
||||
@@ -69,24 +71,23 @@ describe('Proxmox backups responsive table presentation', () => {
|
||||
});
|
||||
|
||||
it('prioritizes workload posture, restore freshness, and task failures in coverage', () => {
|
||||
const everySource = { pbs: true, archive: true, snapshot: true, task: true };
|
||||
expect(ids(getCoverageColumns('compact', everySource))).toEqual([
|
||||
const withTasks = { task: true };
|
||||
expect(ids(getCoverageColumns('compact', withTasks))).toEqual([
|
||||
'workload',
|
||||
'posture',
|
||||
'latest',
|
||||
'pbs',
|
||||
'task',
|
||||
]);
|
||||
expect(
|
||||
getCoverageColumnWidthStyle(
|
||||
'workload',
|
||||
'compact',
|
||||
ids(getCoverageColumns('compact', everySource)),
|
||||
ids(getCoverageColumns('compact', withTasks)),
|
||||
),
|
||||
).toEqual({
|
||||
width: '40%',
|
||||
});
|
||||
expect(ids(getCoverageColumns('operational', everySource))).toEqual([
|
||||
expect(ids(getCoverageColumns('operational', withTasks))).toEqual([
|
||||
'workload',
|
||||
'type',
|
||||
'node',
|
||||
@@ -94,31 +95,98 @@ describe('Proxmox backups responsive table presentation', () => {
|
||||
'latest',
|
||||
'task',
|
||||
]);
|
||||
expect(ids(getCoverageColumns('expanded', everySource))).toEqual([
|
||||
// Per-source ages (PBS snapshot, PVE file, guest snapshot) are expansion
|
||||
// detail at every width; the widest layout only adds the target id.
|
||||
expect(ids(getCoverageColumns('expanded', withTasks))).toEqual(
|
||||
ids(getCoverageColumns('operational', withTasks)),
|
||||
);
|
||||
expect(ids(getCoverageColumns('full', withTasks))).toEqual([
|
||||
'workload',
|
||||
'type',
|
||||
'targetId',
|
||||
'node',
|
||||
'posture',
|
||||
'latest',
|
||||
'pbs',
|
||||
'archive',
|
||||
'snapshot',
|
||||
'task',
|
||||
]);
|
||||
expect(ids(getCoverageColumns('full', everySource))).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('does not reserve empty optional coverage columns', () => {
|
||||
expect(
|
||||
ids(
|
||||
getCoverageColumns('full', {
|
||||
pbs: true,
|
||||
archive: false,
|
||||
snapshot: false,
|
||||
task: true,
|
||||
}),
|
||||
),
|
||||
).toEqual(['workload', 'type', 'targetId', 'node', 'posture', 'latest', 'pbs', 'task']);
|
||||
it('does not reserve an empty task column', () => {
|
||||
expect(ids(getCoverageColumns('full', { task: false }))).toEqual([
|
||||
'workload',
|
||||
'type',
|
||||
'targetId',
|
||||
'node',
|
||||
'posture',
|
||||
'latest',
|
||||
]);
|
||||
});
|
||||
|
||||
it('states the backup health rule from the server posture policy', () => {
|
||||
expect(getBackupPostureRuleText({ freshnessWindowSeconds: 7 * 86_400 })).toBe(
|
||||
'Attention: the newest backup is older than 7 days, a newer backup job failed, expected verification is missing or overdue, or Pulse can see only part of the backup history. Unprotected: Pulse sees the full history and no backup it can count. Guest snapshots alone do not count. Unknown: Pulse cannot read the backup history or has no backup source linked to the guest.',
|
||||
);
|
||||
expect(getBackupPostureRuleText({ freshnessWindowSeconds: 86_400 })).toContain(
|
||||
'older than 1 day,',
|
||||
);
|
||||
expect(getBackupPostureRuleText({ freshnessWindowSeconds: 3_600 })).toContain(
|
||||
'older than 1 hour,',
|
||||
);
|
||||
expect(getBackupPostureRuleText({ freshnessWindowSeconds: 36 * 3_600 })).toContain(
|
||||
'older than 36 hours,',
|
||||
);
|
||||
expect(getBackupPostureRuleText({ freshnessWindowSeconds: 0 })).toBeNull();
|
||||
expect(getBackupPostureRuleText(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the newest restore point of every source in the coverage expansion', () => {
|
||||
const snapshots = Array.from({ length: 10 }, (_, index) => ({
|
||||
id: `snap-${index}`,
|
||||
sourceKind: 'snapshot',
|
||||
createdMs: 1_000 + index,
|
||||
}));
|
||||
const artifacts = [
|
||||
{ id: 'pbs-old', sourceKind: 'pbs', createdMs: 10 },
|
||||
{ id: 'pbs-new', sourceKind: 'pbs', createdMs: 500 },
|
||||
{ id: 'archive', sourceKind: 'archive', createdMs: 400 },
|
||||
...snapshots,
|
||||
];
|
||||
|
||||
const evidence = selectCoverageRestoreEvidence(artifacts);
|
||||
|
||||
expect(evidence).toHaveLength(8);
|
||||
expect(evidence.map((artifact) => artifact.id)).toEqual([
|
||||
'snap-9',
|
||||
'snap-8',
|
||||
'snap-7',
|
||||
'snap-6',
|
||||
'snap-5',
|
||||
'snap-4',
|
||||
'pbs-new',
|
||||
'archive',
|
||||
]);
|
||||
expect(selectCoverageRestoreEvidence(artifacts.slice(0, 3))).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('keeps the newest completed backup when a newer job is running or failed', () => {
|
||||
const snapshots = Array.from({ length: 10 }, (_, index) => ({
|
||||
id: `snap-${index}`,
|
||||
sourceKind: 'snapshot',
|
||||
createdMs: 1_000 + index,
|
||||
}));
|
||||
const artifacts = [
|
||||
{ id: 'pbs-done', sourceKind: 'pbs', createdMs: 300 },
|
||||
{ id: 'pbs-failed', sourceKind: 'pbs', createdMs: 600, failed: true },
|
||||
{ id: 'pbs-running', sourceKind: 'pbs', createdMs: 700, running: true },
|
||||
...snapshots,
|
||||
];
|
||||
|
||||
const ids = selectCoverageRestoreEvidence(artifacts).map((artifact) => artifact.id);
|
||||
|
||||
expect(ids).toHaveLength(8);
|
||||
expect(ids).toContain('pbs-running');
|
||||
expect(ids).toContain('pbs-done');
|
||||
expect(ids).not.toContain('pbs-failed');
|
||||
});
|
||||
|
||||
it('keeps the recovery-point answer visible before verbose metadata', () => {
|
||||
|
||||
@@ -153,15 +153,11 @@ export function cmpBool(a: boolean, b: boolean, direction: 'asc' | 'desc'): numb
|
||||
// sysadmin-default question on a backup page. String columns default to
|
||||
// `asc` (A→Z). Boolean columns default to `desc` so "true" sorts first.
|
||||
|
||||
export type CoverageSortKey =
|
||||
'posture' | 'workload' | 'latest' | 'pbs' | 'archive' | 'snapshot' | 'task';
|
||||
export type CoverageSortKey = 'posture' | 'workload' | 'latest' | 'task';
|
||||
export const COVERAGE_SORT_DEFAULT_DIRECTION: Record<CoverageSortKey, 'asc' | 'desc'> = {
|
||||
posture: 'asc',
|
||||
workload: 'asc',
|
||||
latest: 'desc',
|
||||
pbs: 'desc',
|
||||
archive: 'desc',
|
||||
snapshot: 'desc',
|
||||
task: 'desc',
|
||||
};
|
||||
|
||||
|
||||
@@ -23,16 +23,7 @@ export type BackupServerColumnId =
|
||||
| 'dedup';
|
||||
|
||||
export type CoverageColumnId =
|
||||
| 'workload'
|
||||
| 'type'
|
||||
| 'targetId'
|
||||
| 'node'
|
||||
| 'posture'
|
||||
| 'latest'
|
||||
| 'pbs'
|
||||
| 'archive'
|
||||
| 'snapshot'
|
||||
| 'task';
|
||||
'workload' | 'type' | 'targetId' | 'node' | 'posture' | 'latest' | 'task';
|
||||
|
||||
export type RecoverableColumnId =
|
||||
| 'workload'
|
||||
@@ -182,66 +173,48 @@ export const COVERAGE_COLUMNS: readonly BackupTableColumn<CoverageColumnId>[] =
|
||||
{ id: 'node', label: 'Node', kind: 'text' },
|
||||
{ id: 'posture', label: 'Posture', kind: 'text' },
|
||||
{ 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' },
|
||||
{ id: 'task', label: 'Task', kind: 'text' },
|
||||
];
|
||||
|
||||
const COVERAGE_VISIBLE: Record<ProxmoxBackupsTableLayoutMode, readonly CoverageColumnId[]> = {
|
||||
// On narrow surfaces answer: which workload, what posture, how recent is the
|
||||
// newest independent backup, and did the latest task succeed? Provider-by-provider
|
||||
// evidence is progressive detail in the expansion row; target identity folds
|
||||
// beneath the name so the scan stays legible at 320px.
|
||||
compact: ['workload', 'posture', 'latest', 'pbs', 'task'],
|
||||
// Every layout answers: which workload, what posture, how recent is the
|
||||
// newest independent backup, and did the latest task succeed? The
|
||||
// provider-by-provider ages (PBS snapshot, PVE file, guest snapshot) are
|
||||
// progressive detail in the expansion row at every width: side by side they
|
||||
// made a stale backup look fresh beside a recent guest snapshot, which does
|
||||
// not count as a backup. Target identity folds beneath the name on phones.
|
||||
compact: ['workload', 'posture', 'latest', 'task'],
|
||||
basic: ['workload', 'node', 'posture', 'latest', 'task'],
|
||||
operational: ['workload', 'type', 'node', 'posture', 'latest', 'task'],
|
||||
expanded: ['workload', 'type', 'node', 'posture', 'latest', 'pbs', 'archive', 'snapshot', 'task'],
|
||||
full: COVERAGE_COLUMNS.map((column) => column.id),
|
||||
expanded: ['workload', 'type', 'node', 'posture', 'latest', 'task'],
|
||||
full: ['workload', 'type', 'targetId', 'node', 'posture', 'latest', 'task'],
|
||||
};
|
||||
|
||||
const COVERAGE_WEIGHTS: Record<
|
||||
ProxmoxBackupsTableLayoutMode,
|
||||
Partial<Record<CoverageColumnId, number>>
|
||||
> = {
|
||||
// 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: 20, latest: 15, pbs: 15, task: 10 },
|
||||
// Posture keeps its whole compact word ("Unknown"), the age cell holds the
|
||||
// suffix-free compact age, and the job dot takes what a "Job" header needs.
|
||||
compact: { workload: 46, posture: 22, latest: 18, task: 14 },
|
||||
// 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: 18, posture: 22, latest: 15, task: 14 },
|
||||
operational: { workload: 27, type: 8, node: 17, posture: 17, latest: 15, task: 16 },
|
||||
expanded: {
|
||||
workload: 19,
|
||||
type: 6.5,
|
||||
node: 13.5,
|
||||
posture: 13.5,
|
||||
latest: 9.5,
|
||||
pbs: 9.5,
|
||||
archive: 10,
|
||||
snapshot: 9.5,
|
||||
task: 9,
|
||||
},
|
||||
expanded: { workload: 27, type: 8, node: 17, posture: 17, latest: 15, task: 16 },
|
||||
full: {
|
||||
workload: 17,
|
||||
type: 5.5,
|
||||
targetId: 8,
|
||||
node: 11.5,
|
||||
posture: 11,
|
||||
latest: 8.5,
|
||||
pbs: 10.5,
|
||||
archive: 8.5,
|
||||
snapshot: 12,
|
||||
task: 7.5,
|
||||
workload: 24,
|
||||
type: 7,
|
||||
targetId: 9,
|
||||
node: 16,
|
||||
posture: 16,
|
||||
latest: 13,
|
||||
task: 15,
|
||||
},
|
||||
};
|
||||
|
||||
export interface CoverageSourceVisibility {
|
||||
pbs: boolean;
|
||||
archive: boolean;
|
||||
snapshot: boolean;
|
||||
task: boolean;
|
||||
}
|
||||
|
||||
@@ -252,9 +225,6 @@ export const getCoverageColumns = (
|
||||
const layoutColumns = new Set(COVERAGE_VISIBLE[layout]);
|
||||
return COVERAGE_COLUMNS.filter((column) => {
|
||||
if (!layoutColumns.has(column.id)) return false;
|
||||
if (column.id === 'pbs') return sourceVisibility.pbs;
|
||||
if (column.id === 'archive') return sourceVisibility.archive;
|
||||
if (column.id === 'snapshot') return sourceVisibility.snapshot;
|
||||
if (column.id === 'task') return sourceVisibility.task;
|
||||
return true;
|
||||
});
|
||||
@@ -385,3 +355,67 @@ export const isCoverageEvidenceColumnVisible = (
|
||||
return layout === 'operational' || layout === 'expanded' || layout === 'full';
|
||||
return layout === 'expanded' || layout === 'full';
|
||||
};
|
||||
|
||||
// What the backup health counts mean, stated from the server's own posture
|
||||
// policy so "17 attention" is never a number without a rule. The causes mirror
|
||||
// internal/recovery/posture.go. Null until the policy has loaded; the strip
|
||||
// then shows no rule rather than a guessed one.
|
||||
export const getBackupPostureRuleText = (
|
||||
policy: { freshnessWindowSeconds: number } | null | undefined,
|
||||
): string | null => {
|
||||
const seconds = policy?.freshnessWindowSeconds ?? 0;
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
||||
return `Attention: the newest backup is older than ${formatPostureWindow(seconds)}, a newer backup job failed, expected verification is missing or overdue, or Pulse can see only part of the backup history. Unprotected: Pulse sees the full history and no backup it can count. Guest snapshots alone do not count. Unknown: Pulse cannot read the backup history or has no backup source linked to the guest.`;
|
||||
};
|
||||
|
||||
const formatPostureWindow = (seconds: number): string => {
|
||||
if (seconds >= 86_400 && seconds % 86_400 === 0) {
|
||||
const days = seconds / 86_400;
|
||||
return days === 1 ? '1 day' : `${days} days`;
|
||||
}
|
||||
const hours = Math.max(1, Math.round(seconds / 3_600));
|
||||
return hours === 1 ? '1 hour' : `${hours} hours`;
|
||||
};
|
||||
|
||||
const COVERAGE_RESTORE_EVIDENCE_LIMIT = 8;
|
||||
|
||||
// Newest restore points for a coverage row's expansion. The newest point and
|
||||
// the newest completed point of every source always make the cut, so neither a
|
||||
// burst of recent guest snapshots nor a running or failed job can hide the
|
||||
// latest usable PBS or PVE backup now that the table carries no per-source
|
||||
// age columns.
|
||||
export const selectCoverageRestoreEvidence = <
|
||||
T extends {
|
||||
id: string;
|
||||
sourceKind: string;
|
||||
createdMs?: number;
|
||||
running?: boolean;
|
||||
failed?: boolean;
|
||||
},
|
||||
>(
|
||||
artifacts: readonly T[],
|
||||
limit = COVERAGE_RESTORE_EVIDENCE_LIMIT,
|
||||
): T[] => {
|
||||
const newestFirst = [...artifacts].sort(
|
||||
(left, right) => (right.createdMs ?? 0) - (left.createdMs ?? 0),
|
||||
);
|
||||
const picked = new Set<string>();
|
||||
const newestSeen = new Set<string>();
|
||||
const completedSeen = new Set<string>();
|
||||
for (const artifact of newestFirst) {
|
||||
if (!newestSeen.has(artifact.sourceKind)) {
|
||||
newestSeen.add(artifact.sourceKind);
|
||||
picked.add(artifact.id);
|
||||
}
|
||||
if (!artifact.running && !artifact.failed && !completedSeen.has(artifact.sourceKind)) {
|
||||
completedSeen.add(artifact.sourceKind);
|
||||
picked.add(artifact.id);
|
||||
}
|
||||
}
|
||||
const cap = Math.max(limit, picked.size);
|
||||
for (const artifact of newestFirst) {
|
||||
if (picked.size >= cap) break;
|
||||
picked.add(artifact.id);
|
||||
}
|
||||
return newestFirst.filter((artifact) => picked.has(artifact.id));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user