Keep incomplete backup entries out of completed restore totals

Use the same completion predicate for latest backups, restore-point and byte totals, PBS coverage and per-day count/volume charts. Keep running and failed entries visible, with separate counts and honest inventory-entry labels. Completion is not verification, restore or guest thaw.

Add registered model and consuming-table regressions for mixed and incomplete-only inventories, unknown chronology and completion transitions. Guest operations and server policy are unchanged.

Change-source: pulse-maintainer
Contract-Neutral: Apply established completion semantics and inventory-entry wording only; resource transport, identity, persistence and shared table contracts are unchanged.
This commit is contained in:
pulse-triage[bot]
2026-10-24 18:43:03 +01:00
parent bf5d2f55e6
commit a91d1ed89d
10 changed files with 272 additions and 21 deletions
@@ -20,6 +20,18 @@
## Purpose
### Backups distinguishes completed points from inventory entries (24 October 2026)
The existing health strip names completed restore points and separately counts
running and failed entries. The existing per-day chart measures only completed
restore points in both count and volume modes, while the dated table and its
filters keep incomplete entries available for inspection. Table and day-group
counters name entries so they cannot imply completion. The incomplete-only
chart state says that no completed dated points are visible, not that no
backup activity occurred. The consuming Backups tests cover summary, chart,
volume, date selection and route views together; existing table interaction
primitives, access boundaries and routes are unchanged.
### Backup posture failure and retry stay source-local (24 October 2026)
The Backups posture notice offers a keyboard-operable retry with the existing
@@ -21,6 +21,20 @@
## Purpose
### Completed restore totals exclude incomplete inventory (24 October 2026)
The Backups restore-point total, completed-byte total, PBS-target count and
per-day count/volume chart all exclude explicitly running or terminally
incomplete artifacts. The same completion predicate selects the last backup.
Running and failed entries remain in the dated inventory and row evidence,
with separate summary counts; inventory and day-group counts name entries,
not completed backups. Unknown dates and unverified completed points remain
evidence, not absent or failed points. Completion establishes neither an
independent backup (guest snapshots remain distinct), tested restore nor guest
thaw. Registered recovery-model and consuming-table tests cover mixed,
incomplete-only, unknown-date and completion-transition controls without any
new backup execution, QGA requests, schema or persisted state.
### Failed posture refreshes withdraw retained assessments (24 October 2026)
Protection posture remains visible while a refresh is pending, but a settled
@@ -43,6 +43,7 @@ import {
import {
buildProxmoxBackupRecoveryModel,
coverageRowMatchesSearch,
isCompletedRecoveryArtifact,
selectWorkloadRecoveryArtifacts,
recoverableArtifactMatchesSearch,
type RecoverableArtifact,
@@ -502,11 +503,13 @@ export const ProxmoxBackupsTable: Component<{
recoveryModel().recoverableArtifacts,
(artifact) => artifact.createdMs,
(artifact) =>
artifact.sourceKind === 'pbs'
? 'pbs'
: artifact.sourceKind === 'archive'
? 'archive'
: 'snapshot',
!isCompletedRecoveryArtifact(artifact)
? null
: artifact.sourceKind === 'pbs'
? 'pbs'
: artifact.sourceKind === 'archive'
? 'archive'
: 'snapshot',
{
now: new Date(nowMs()),
getValue:
@@ -753,8 +756,17 @@ export const ProxmoxBackupsTable: Component<{
tail={
<span>
{liveTotalCount()} targets ·{' '}
{recoveryModel().coverageSummary.recoverableArtifacts} restore points
{recoveryModel().coverageSummary.recoverableArtifacts} completed restore{' '}
{recoveryModel().coverageSummary.recoverableArtifacts === 1 ? 'point' : 'points'}
{inventoryIncomplete() ? ' read' : ''}
<Show when={recoveryModel().coverageSummary.runningArtifacts > 0}>
{' · '}
{recoveryModel().coverageSummary.runningArtifacts} running
</Show>
<Show when={recoveryModel().coverageSummary.failedArtifacts > 0}>
{' · '}
{recoveryModel().coverageSummary.failedArtifacts} failed
</Show>
<Show when={recoveryModel().coverageSummary.withPBS > 0}>
{' · '}
{recoveryModel().coverageSummary.withPBS} with{' '}
@@ -830,7 +842,10 @@ export const ProxmoxBackupsTable: Component<{
when={hasRecoverableActivityInRange()}
fallback={
<div class="flex flex-col gap-2 rounded-md border border-border-subtle px-3 py-3 text-xs text-muted sm:flex-row sm:items-center sm:justify-between">
<span>No dated backup activity in the selected {chartRange()}-day window.</span>
<span>
No completed restore points with known dates in the selected {chartRange()}
-day window.
</span>
<div class="flex items-center gap-2">
<button
type="button"
@@ -854,10 +869,10 @@ export const ProxmoxBackupsTable: Component<{
<BackupActivityChart
title={
recoverableMetricMode() === 'volume'
? 'Backup volume per day'
: 'Backups per day'
? 'Completed restore volume per day'
: 'Completed restore points per day'
}
noun="backup"
noun="restore point"
segmentKinds={RECOVERABLE_SEGMENT_KINDS}
range={chartRange}
onRangeChange={setChartRange}
@@ -906,8 +921,8 @@ export const ProxmoxBackupsTable: Component<{
rowNoun={
view() === 'date'
? inventoryIncomplete()
? 'backups read'
: 'backups'
? 'backup entries read'
: 'backup entries'
: 'targets'
}
/>
@@ -475,7 +475,7 @@ export function ProxmoxRecoverableTable(props: {
{item.kind === 'day' ? item.label : ''}{' '}
<span class="ml-2 normal-case tracking-normal text-muted">
{item.kind === 'day' ? item.count : 0}{' '}
{item.kind === 'day' && item.count === 1 ? 'backup' : 'backups'}
{item.kind === 'day' && item.count === 1 ? 'entry' : 'entries'}
</span>
</TableCell>
</TableRow>
@@ -438,7 +438,7 @@ describe('ProxmoxBackupsTable', () => {
expect(screen.getByText('main / minipc')).toBeInTheDocument();
expect(
screen.getByRole('cell', {
name: `${getRecoveryFullDateLabel('2026-05-25')} 3 backups`,
name: `${getRecoveryFullDateLabel('2026-05-25')} 3 entries`,
}),
).toBeInTheDocument();
const tables = screen.getAllByRole('table');
@@ -994,7 +994,7 @@ describe('independent Proxmox backup inventory reads', () => {
mountBackupReadState();
await screen.findByText(/PVE backup inventory is loading/);
await waitFor(() => expect(recoverableRows()).toHaveLength(1));
expect(screen.getByText(/1 restore points read/)).toBeInTheDocument();
expect(screen.getByText(/1 completed restore point read/)).toBeInTheDocument();
pending.resolve(jsonResponse(readPvePayload));
await waitFor(() => expect(recoverableRows()).toHaveLength(2));
expect(screen.queryByText(/inventory is loading/)).not.toBeInTheDocument();
@@ -1303,7 +1303,7 @@ describe('backup-date-evidence connected inventory', () => {
);
expect(warning).toHaveAttribute('role', 'status');
expect(warning).toHaveTextContent('excluded from the activity chart and day filters');
expect(screen.getByText(/No dated backup activity/)).toBeInTheDocument();
expect(screen.getByText(/No completed restore points with known dates/)).toBeInTheDocument();
const future = [...document.querySelectorAll('[data-proxmox-backup-row="recoverable"]')].find(
(row) => row.textContent?.includes('PBS'),
)! as HTMLElement;
@@ -1609,3 +1609,91 @@ describe('Coverage withdraws failed protection assessments', () => {
}
});
});
describe('completed restore-point summary and activity', () => {
afterEach(() => vi.restoreAllMocks());
const mountInventory = (incompleteOnly = false) => {
vi.spyOn(Date, 'now').mockReturnValue(BACKUP_IDENTITY_NOW);
const completedPBS = identityPBS({ id: 'complete', size: 2048 });
const runningPBS = identityPBS({ id: 'running', inProgress: true, size: 1024 * 1024 });
const failedPBS = identityPBS({
id: 'failed',
inProgress: true,
writeActivityObserved: true,
writeActive: false,
size: 2 * 1024 * 1024,
});
apiFetchMock.mockImplementation((url: string) =>
Promise.resolve(
jsonResponse({
data: url.endsWith('/pbs')
? { backups: [...(incompleteOnly ? [] : [completedPBS]), runningPBS, failedPBS] }
: {
storageBackups: incompleteOnly ? [] : [identityArchive({ size: 1024 })],
guestSnapshots: incompleteOnly ? [] : [identitySnapshot()],
backupTasks: [],
},
}),
),
);
apiFetchJSONMock.mockResolvedValue({ data: [], policy: {} });
window.history.replaceState({}, '', '/proxmox/backups/date');
return renderInRouter(() => (
<ProxmoxBackupsTable
emptyIcon={<span />}
workloads={[identityGuest('east-100', 'east', 'pve-1')]}
/>
));
};
it('counts completed points and volume separately while retaining running and failed rows for inspection', async () => {
const { container } = mountInventory();
await screen.findByText(/3 completed restore points · 1 running · 1 failed/);
expect(container.querySelectorAll('[data-proxmox-backup-row="recoverable"]')).toHaveLength(5);
expect(
screen.getByRole('cell', { name: `${getRecoveryFullDateLabel('2026-10-04')} 5 entries` }),
).toBeInTheDocument();
const day = screen.getByRole('button', { name: /: 3 restore points$/ });
expect(day).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /: 5 restore points$/ })).not.toBeInTheDocument();
expect(screen.getByText('Completed restore points per day')).toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: 'Volume', exact: true }));
expect(screen.getByText('Completed restore volume per day')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /: 3.00 KB$/ })).toBeInTheDocument();
expect(screen.getAllByText('Running').length).toBeGreaterThan(0);
expect(screen.getAllByText('Failed').length).toBeGreaterThan(0);
});
it('does not draw completed activity or claim PBS restore coverage for an incomplete-only inventory', async () => {
const { container } = mountInventory(true);
const summary = await screen.findByText(/0 completed restore points · 1 running · 1 failed/);
expect(summary).not.toHaveTextContent('with PBS snapshots');
expect(screen.getByText(/No completed restore points with known dates/)).toBeInTheDocument();
expect(screen.queryByRole('group', { name: 'Activity metric' })).not.toBeInTheDocument();
expect(container.querySelectorAll('[data-proxmox-backup-row="recoverable"]')).toHaveLength(2);
expect(
screen.getByRole('cell', { name: `${getRecoveryFullDateLabel('2026-10-04')} 2 entries` }),
).toBeInTheDocument();
await fireEvent.click(screen.getByRole('link', { name: 'Coverage', exact: true }));
expect(screen.queryByText(/completed restore points with known dates/)).not.toBeInTheDocument();
expect(
screen.getByText(/0 completed restore points · 1 running · 1 failed/),
).toBeInTheDocument();
});
it('keeps incomplete entries available under a completed day filter and when it is cleared', async () => {
const { container } = mountInventory();
await screen.findByText(/3 completed restore points/);
await fireEvent.click(screen.getByRole('button', { name: /: 3 restore points$/ }));
await waitFor(() => expect(window.location.search).toContain('day=2026-10-04'));
expect(container.querySelectorAll('[data-proxmox-backup-row="recoverable"]')).toHaveLength(5);
await fireEvent.click(screen.getByRole('button', { name: 'Clear date filter' }));
await waitFor(() => expect(window.location.search).not.toContain('day='));
expect(screen.getByRole('button', { name: /: 3 restore points$/ })).toHaveAttribute(
'aria-pressed',
'false',
);
expect(container.querySelectorAll('[data-proxmox-backup-row="recoverable"]')).toHaveLength(5);
});
});
@@ -425,6 +425,8 @@ describe('proxmoxBackupActivityPresentation', () => {
[0, 'backup', '0 backups'],
[1, 'backup', '1 backup'],
[2, 'backup', '2 backups'],
[1, 'restore point', '1 restore point'],
[3, 'restore point', '3 restore points'],
[1, 'snapshot', '1 snapshot'],
[5, 'task', '5 tasks'],
[2, 'archive', '2 archives'],
@@ -500,6 +500,8 @@ describe('buildProxmoxBackupRecoveryModel summary, sort, and empty edges', () =>
unknown: 0,
withPBS: 0,
recoverableArtifacts: 0,
runningArtifacts: 0,
failedArtifacts: 0,
totalBytes: 0,
});
});
@@ -598,3 +598,104 @@ describe('backup-date-evidence model', () => {
expect(getRecoveryAgeBand(nowMs - 30 * 86400000 - 1, nowMs)).toBe('stale');
});
});
describe('completed restore-point totals', () => {
const nowMs = Date.parse('2026-05-26T08:00:00Z');
const build = (
pbsBackups: PBSBackup[],
archives: StorageBackup[] = [],
snapshots: GuestSnapshot[] = [],
) =>
buildProxmoxBackupRecoveryModel({
workloads: [workload({})],
pbsBackups,
archives,
snapshots,
tasks: [],
nowMs,
});
it('keeps incomplete PBS and PVE entries visible without inflating completed counts or bytes', () => {
const model = build(
[
pbsBackup({ id: 'complete', size: 100 }),
pbsBackup({ id: 'running', size: 10_000, inProgress: true }),
pbsBackup({
id: 'failed',
size: 20_000,
inProgress: true,
writeActivityObserved: true,
writeActive: false,
}),
],
[
archive({ id: 'complete-file', size: 200 }),
archive({ id: 'writing-file', size: 40_000, inProgress: true }),
],
[snapshot({ sizeBytes: 300 })],
);
expect(model.recoverableArtifacts).toHaveLength(6);
expect(model.coverageRows[0].artifacts).toHaveLength(6);
expect(model.coverageSummary).toMatchObject({
recoverableArtifacts: 3,
runningArtifacts: 2,
failedArtifacts: 1,
totalBytes: 600,
withPBS: 1,
});
expect(model.coverageRows[0].latestBackup?.nativeId).toBe('complete');
});
it.each([false, true])(
'does not count an incomplete-only PBS target when write activity is %s',
(writeActive) => {
const model = build([
pbsBackup({
inProgress: true,
writeActivityObserved: true,
writeActive,
protected: true,
verified: true,
}),
]);
expect(model.coverageSummary).toMatchObject({
recoverableArtifacts: 0,
withPBS: 0,
totalBytes: 0,
runningArtifacts: writeActive ? 1 : 0,
failedArtifacts: writeActive ? 0 : 1,
});
expect(model.recoverableArtifacts).toHaveLength(1);
expect(model.recoverableArtifacts[0].verified).toBeUndefined();
expect(model.coverageRows[0].latestBackup).toBeUndefined();
},
);
it('keeps unknown chronology and missing verification separate from completion', () => {
const model = build([pbsBackup({ backupTime: 'not-a-date', verified: false, size: 123 })]);
expect(model.coverageSummary).toMatchObject({
recoverableArtifacts: 1,
totalBytes: 123,
withPBS: 1,
});
expect(model.coverageRows[0].latestBackup).toBeUndefined();
expect(model.coverageRows[0].ageUnknown?.backup).toBe(true);
expect(model.recoverableArtifacts[0].verified).toBe(false);
});
it('moves the same observed entry into completed totals only when its producer reports completion', () => {
const pending = build([pbsBackup({ inProgress: true, size: 100 })]);
const complete = build([pbsBackup({ inProgress: false, size: 200 })]);
expect(pending.coverageSummary).toMatchObject({
recoverableArtifacts: 0,
runningArtifacts: 1,
totalBytes: 0,
});
expect(complete.coverageSummary).toMatchObject({
recoverableArtifacts: 1,
runningArtifacts: 0,
totalBytes: 200,
});
expect(complete.recoverableArtifacts[0].id).toBe(pending.recoverableArtifacts[0].id);
});
});
@@ -148,7 +148,8 @@ export interface BackupActivityTooltipRow {
muted: boolean;
}
export type BackupActivityNoun = 'archive' | 'artifact' | 'backup' | 'task' | 'snapshot';
export type BackupActivityNoun =
'archive' | 'artifact' | 'backup' | 'task' | 'snapshot' | 'restore point';
function formatActivityValue(value: number, mode: BackupActivityMetricMode): string {
if (mode === 'volume') return formatBytes(Math.max(0, value));
@@ -100,6 +100,7 @@ export interface WorkloadCoverageRow {
export interface ProxmoxBackupRecoveryModel {
coverageRows: WorkloadCoverageRow[];
/** All observed entries, including incomplete artifacts retained for inspection. */
recoverableArtifacts: RecoverableArtifact[];
coverageSummary: {
totalWorkloads: number;
@@ -108,7 +109,10 @@ export interface ProxmoxBackupRecoveryModel {
unprotected: number;
unknown: number;
withPBS: number;
/** Completed restore points only; completion is not a successful restore or thaw. */
recoverableArtifacts: number;
runningArtifacts: number;
failedArtifacts: number;
totalBytes: number;
};
}
@@ -154,6 +158,11 @@ export function isBackupArtifact(artifact: RecoverableArtifact): boolean {
return artifact.sourceKind !== 'snapshot';
}
/** Incomplete artifacts remain evidence, but are not completed restore points. */
export function isCompletedRecoveryArtifact(artifact: RecoverableArtifact): boolean {
return !artifact.running && !artifact.failed;
}
function parseTimestampMs(value: string | undefined): number | undefined {
if (!value) return undefined;
const ms = Date.parse(value);
@@ -511,7 +520,7 @@ export function selectWorkloadRecoveryArtifacts(
> {
// Completion remains independent of age. Keep every artifact listed, but
// don't guess the latest when even one completed date cannot be ordered.
const completed = artifacts.filter((artifact) => !artifact.running && !artifact.failed);
const completed = artifacts.filter(isCompletedRecoveryArtifact);
const select = (items: RecoverableArtifact[]) => {
const unknown = items.some(
(artifact) => getRecoveryAgeBand(artifact.createdMs, nowMs) === 'unknown',
@@ -782,7 +791,8 @@ export function buildProxmoxBackupRecoveryModel(
});
artifacts.sort((left, right) => (right.createdMs ?? 0) - (left.createdMs ?? 0));
const totalBytes = artifacts.reduce((sum, artifact) => sum + (artifact.size ?? 0), 0);
const completedArtifacts = artifacts.filter(isCompletedRecoveryArtifact);
const totalBytes = completedArtifacts.reduce((sum, artifact) => sum + (artifact.size ?? 0), 0);
return {
coverageRows,
recoverableArtifacts: artifacts,
@@ -792,8 +802,14 @@ export function buildProxmoxBackupRecoveryModel(
attention: coverageRows.filter((row) => isCoverageAttention(row.posture)).length,
unprotected: coverageRows.filter((row) => row.posture === 'unprotected').length,
unknown: coverageRows.filter((row) => row.posture === 'unknown').length,
withPBS: coverageRows.filter((row) => row.pbsCount > 0).length,
recoverableArtifacts: artifacts.length,
withPBS: coverageRows.filter((row) =>
row.artifacts.some(
(artifact) => artifact.sourceKind === 'pbs' && isCompletedRecoveryArtifact(artifact),
),
).length,
recoverableArtifacts: completedArtifacts.length,
runningArtifacts: artifacts.filter((artifact) => artifact.running).length,
failedArtifacts: artifacts.filter((artifact) => artifact.failed).length,
totalBytes,
},
};