Filter Proxmox backups by repository

Change-source: pulse-maintainer
This commit is contained in:
rcourtman 2026-08-26 06:44:50 +01:00
parent 52a3373ae6
commit 95f57f446a
13 changed files with 229 additions and 26 deletions

View file

@ -19,6 +19,13 @@ Own server installation, deployment bootstrap behavior, provider-hosted MSP
deployment artifacts, update planning, and server-side update execution
surfaces.
Operator-facing prerelease notes may accumulate already-landed customer
outcomes before the next candidate is cut. Each added product claim must stay
in the existing customer-facing section and have exact packet proof in
`render_release_body_test.py`; a note about repository-scoped Proxmox backup
review must name both PBS server and datastore rather than implying that a
workload or node filter provides the same boundary.
Provider-hosted MSP deploy artifacts must package the provider control plane as
a least-privilege Docker provisioner. The packaged compose/setup path must avoid
whole-host and Docker-data read mounts, expose storage admission only through

View file

@ -51,6 +51,14 @@ the workload state, table, panel, and row contracts carry the selected basis
and resolved parent-node data to the canonical memory bar, and the memory
column header must expose the non-default Host basis after the control closes.
Feature-owned scope controls that use the shared filter rail must keep their
state in the owning route and use stable, domain-authored option identities.
The Proxmox Backups `Backup location` control composes the shared filter
catalog, reads PBS instance plus datastore identity from the recovery model,
and persists unchanged between the By date and Coverage views. Clearing the
shared filter rail must remove that route value along with the other active
facets; the feature must not replace the shared rail with a page-local select.
Large-estate platform pages must keep one canonical inventory snapshot for the
initial read and explicit refresh path. The Proxmox overview owns its unified
resource request and passes that snapshot into the shared workloads state; the

View file

@ -484,6 +484,18 @@ The Proxmox backup view selector follows the same ownership split:
storage/recovery owns the chronological-versus-coverage view values and backup
semantics, while the visible segmented selector shell must compose the
frontend-primitives `FilterButtonGroup`.
Backup-location filtering is repository-scoped rather than host-scoped. Its
identity combines source kind, provider instance, and datastore/storage so two
PBS instances with the same datastore name remain distinct and the workload's
PVE node never masquerades as backup ownership. The route query owns that scope
across the chronological and coverage views. Coverage rows must narrow their
artifact collection, latest evidence, and per-source counts before rendering;
they retain the server-owned canonical protection posture rather than deriving
a location-local posture from incomplete client evidence. The focused model and
rendered-route proofs in
`frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts`
and `frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx`
pin this boundary.
Tenant report branding settings are adjacent tenant-local configuration, not a
storage or recovery product state. `reportBranding` persisted in a tenant
runtime's `system.json` should be preserved by the existing tenant data

View file

@ -2128,6 +2128,11 @@ in `frontend-modern/src/routing/resourceLinks.ts` now serialize query state
only; the owning platform/runtime route supplies the pathname. New drawer or
correlation surfaces must anchor on platform routes (or stay-in-place drawer
expansion) instead of resurrecting the retired top-level paths.
The Proxmox Backups route-state vocabulary includes the opaque `location`
facet. Its value is owned by the backup recovery model, not by canonical
resource identity: PBS instance and datastore form the repository scope, and
the same value must survive the date/coverage route handoff so both views
apply one repository selection without inventing a top-level recovery route.
Unified-resource drawers and Kubernetes drill-down controls may own resource
timeline filter semantics, namespace choices, and destination routes, but their
native select chrome must compose the frontend-primitives-owned `FormSelect`.

View file

@ -35,7 +35,9 @@ build path.
longer modulepreloads every lazy route chunk, while integrity coverage stays
enforced for dynamically loaded assets.
- Proxmox backup views use canonical routes, compact healthy-state shields, and
recovery pagination derived from the normalized query limit.
recovery pagination derived from the normalized query limit. Backup-location
filters distinguish PBS servers and datastores so local and off-site restore
points can be reviewed independently.
- Mobile and touch layouts keep native page scrolling and gestures, avoid hover
tooltips, use consistent disclosure affordances, and retain reachable table
actions at narrow widths.

View file

@ -1,21 +1,22 @@
{
"version": 1,
"base_sha": "8f7d5c2e98120adeb11502d897733a6947a6b479",
"verified_at": "2026-08-25T23:41:08Z",
"base_sha": "52a3373ae6cafaffcd97bb466c42216b896fe5ce",
"verified_at": "2026-08-26T05:42:38Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/api/charts.ts",
"frontend-modern/src/components/Workloads/NodeDrawer.tsx",
"frontend-modern/src/components/Workloads/nodeDrawerModel.ts",
"frontend-modern/src/types/resource.ts"
"frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx",
"frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts",
"frontend-modern/src/routing/resourceLinks.ts"
],
"content_sha256": {
"frontend-modern/src/api/charts.ts": "b50cbfac778a32cc6a0ad51f48bcd673e5fc4242729a6ea81fcada1b76058ff1",
"frontend-modern/src/components/Workloads/NodeDrawer.tsx": "b64d2f349d951fd134294118334421f022aa8cf01fab7c0caed176d9140d6ed1",
"frontend-modern/src/components/Workloads/nodeDrawerModel.ts": "671738353bf22d5047743343b8e2f8ba7576870dc59e53843b52db131f11fb76",
"frontend-modern/src/types/resource.ts": "b13c44c92ef18da5a895b5378b8874c353d55b605c22ae02ca00cbff5d2f0eb9"
"frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "8f4b168bc6748f9318d04d67f3b2a614bdeb0e5c6d884d5aec309e1b7d202886",
"frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts": "5fb8dd36ee2144095e3ad979109dc750649c5818f36e9587caafaef1313be4ec",
"frontend-modern/src/routing/resourceLinks.ts": "cdd803824f883dab64654fcceb92da963bd18013eae3d1cf768880a3f77bb96b"
},
"routes": ["/proxmox/overview"],
"routes": [
"/proxmox/backups/date",
"/proxmox/backups/coverage"
],
"viewports": [
{
"width": 1280,
@ -27,16 +28,15 @@
}
],
"states": [
"collapsed and expanded Proxmox node rows in the loaded overview table",
"agent-backed node history with utilization, network I/O, disk I/O, and thermal groups",
"API-only PVE node history with utilization, network I/O, and thermal groups and no unsupported disk I/O group",
"24-hour history with populated series and collecting states for unavailable mock series"
"unfiltered 3018-point By date inventory with multiple PBS servers and datastores",
"By date narrowed to backup-vault / offsite-vault with unrelated primary-vault and PVE evidence absent",
"Coverage narrowed to backup-vault / primary-vault with 381 of 932 targets and location-scoped expanded restore evidence",
"mobile Coverage filter rail with the active backup-location chip, clear control, and filtered target count"
],
"interactions": [
"signed in to the current development build, expanded Analytics A, and opened its History tab",
"inspected the complete agent-backed chart row at 1280x800 and the stacked cards, scrolling, and thermals at 390x844",
"injected an API-only Analytics A websocket fixture into the committed frontend build and repeated the History interaction at both viewports",
"confirmed the API-only drawer requested resourceType=node with a canonical node ID and received HTTP 200",
"confirmed API-only history remained aligned and unclipped while omitting Disk I/O, and agent-backed history retained Disk I/O"
"opened the Backup location filter and selected backup-vault / offsite-vault on the current development build",
"confirmed the URL-owned location query appeared and the By date table retained offsite-vault evidence while removing primary-vault evidence",
"switched to Coverage and confirmed the location query persisted, the target count narrowed, and an expanded row showed only primary-vault restore evidence",
"repeated the filtered Coverage state at 390x844, expanded the mobile filter rail, checked placement and clipping, and removed the location chip"
]
}

View file

@ -45,6 +45,7 @@ import {
coverageRowMatchesSearch,
recoverableArtifactMatchesSearch,
type RecoverableArtifact,
type WorkloadCoverageRow,
} from './proxmoxBackupRecoveryModel';
import {
COVERAGE_SORT_DEFAULT_DIRECTION,
@ -184,6 +185,14 @@ export const ProxmoxBackupsTable: Component<{
return !selected || node === selected;
};
const locationFilter = (): string => queryValue(PROXMOX_BACKUPS_QUERY_PARAMS.location);
const setLocationFilter = (value: string): void =>
setSearchParams({ [PROXMOX_BACKUPS_QUERY_PARAMS.location]: value || null }, { replace: true });
const locationMatches = (artifact: RecoverableArtifact): boolean => {
const selected = locationFilter();
return !selected || artifact.locationKey === selected;
};
// Type scope normalizes vm/qemu and ct/lxc to a canonical token before
// comparing, since the recovery model keeps PBS-native ct internally.
const typeFilter = (): string => queryValue(PROXMOX_BACKUPS_QUERY_PARAMS.type);
@ -290,6 +299,21 @@ export const ProxmoxBackupsTable: Component<{
];
});
const locationOptions = createMemo<FilterSelectOption[]>(() => {
const locations = new Map<string, string>();
for (const artifact of recoveryModel().recoverableArtifacts) {
if (artifact.locationKey && artifact.locationLabel) {
locations.set(artifact.locationKey, artifact.locationLabel);
}
}
return [
{ value: '', label: 'All backup locations' },
...[...locations]
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([value, label]) => ({ value, label })),
];
});
// 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.
@ -308,10 +332,45 @@ export const ProxmoxBackupsTable: Component<{
recoveryModel().coverageRows.some((row) => Boolean(row.latestTask)),
);
const coverageRowsForLocation = createMemo<WorkloadCoverageRow[]>(() => {
const selected = locationFilter();
if (!selected) return recoveryModel().coverageRows;
const newestArtifact = (artifacts: RecoverableArtifact[]): RecoverableArtifact | undefined =>
artifacts.reduce<RecoverableArtifact | undefined>((latest, artifact) => {
if (!latest) return artifact;
return (artifact.createdMs ?? Number.NEGATIVE_INFINITY) >
(latest.createdMs ?? Number.NEGATIVE_INFINITY)
? artifact
: latest;
}, undefined);
return recoveryModel().coverageRows.flatMap((row) => {
const artifacts = row.artifacts.filter(locationMatches);
if (artifacts.length === 0) return [];
return [
{
...row,
artifacts,
latestRecovery: newestArtifact(artifacts),
latestPBS: newestArtifact(artifacts.filter((artifact) => artifact.sourceKind === 'pbs')),
latestArchive: newestArtifact(
artifacts.filter((artifact) => artifact.sourceKind === 'archive'),
),
latestSnapshot: newestArtifact(
artifacts.filter((artifact) => artifact.sourceKind === 'snapshot'),
),
pbsCount: artifacts.filter((artifact) => artifact.sourceKind === 'pbs').length,
archiveCount: artifacts.filter((artifact) => artifact.sourceKind === 'archive').length,
snapshotCount: artifacts.filter((artifact) => artifact.sourceKind === 'snapshot').length,
},
];
});
});
const filteredCoverageRows = createMemo(() => {
const term = search().trim().toLowerCase();
const filter = coverageFilter();
const list = recoveryModel().coverageRows.filter((row) => {
const list = coverageRowsForLocation().filter((row) => {
if (!nodeMatches(row.workload.node)) return false;
if (!typeMatches(row.workload.type)) return false;
if (filter !== 'all' && row.posture !== filter) return false;
@ -432,6 +491,7 @@ export const ProxmoxBackupsTable: Component<{
const list = recoveryModel().recoverableArtifacts.filter((artifact) => {
if (!nodeMatches(artifact.workload.node)) return false;
if (!typeMatches(artifact.workload.type)) return false;
if (!locationMatches(artifact)) return false;
if (
(filter === 'pbs' || filter === 'archive' || filter === 'snapshot') &&
artifact.sourceKind !== filter
@ -493,6 +553,15 @@ export const ProxmoxBackupsTable: Component<{
setValue: setNodeFilter,
defaultValue: '',
},
{
id: 'location',
label: 'Backup location',
group: 'scope',
options: locationOptions,
value: locationFilter,
setValue: setLocationFilter,
defaultValue: '',
},
{
id: 'type',
label: 'Type',
@ -532,6 +601,7 @@ export const ProxmoxBackupsTable: Component<{
Boolean(
search().trim() ||
nodeFilter() ||
locationFilter() ||
typeFilter() ||
coverageFilter() !== 'all' ||
recoverableFilter() !== 'all' ||
@ -543,6 +613,7 @@ export const ProxmoxBackupsTable: Component<{
[PROXMOX_BACKUPS_QUERY_PARAMS.query]: null,
[PROXMOX_BACKUPS_QUERY_PARAMS.view]: null,
[PROXMOX_BACKUPS_QUERY_PARAMS.node]: null,
[PROXMOX_BACKUPS_QUERY_PARAMS.location]: null,
[PROXMOX_BACKUPS_QUERY_PARAMS.type]: null,
[PROXMOX_BACKUPS_QUERY_PARAMS.source]: null,
[PROXMOX_BACKUPS_QUERY_PARAMS.posture]: null,

View file

@ -113,9 +113,12 @@ const pbsPayload = {
meta: { totalBackups: 1 },
};
function mockBackupAPIs(state: 'protected' | 'attention' = 'protected') {
function mockBackupAPIs(
state: 'protected' | 'attention' = 'protected',
pbsResponse: typeof pbsPayload = pbsPayload,
) {
apiFetchMock.mockImplementation((url: string) => {
if (url === '/api/backups/pbs') return Promise.resolve(jsonResponse(pbsPayload));
if (url === '/api/backups/pbs') return Promise.resolve(jsonResponse(pbsResponse));
if (url === '/api/backups/pve') return Promise.resolve(jsonResponse(pvePayload));
return Promise.resolve(jsonResponse({}));
});
@ -352,7 +355,7 @@ describe('ProxmoxBackupsTable', () => {
window.history.replaceState(
{},
'',
'/?view=date&q=pbs-docker&source=pbs&node=pve-a&type=ct&day=2026-05-25',
'/?view=date&q=pbs-docker&source=pbs&location=pbs%3Apbs-main%3Amain&node=pve-a&type=ct&day=2026-05-25',
);
renderInRouter(() => (
@ -367,6 +370,9 @@ describe('ProxmoxBackupsTable', () => {
'true',
);
expect(screen.getByRole('button', { name: /clear date filter/i })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Backup location: pbs-main / main' }),
).toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: /clear filters/i }));
@ -375,6 +381,7 @@ describe('ProxmoxBackupsTable', () => {
expect(params.get('view')).toBeNull();
expect(params.get('q')).toBeNull();
expect(params.get('source')).toBeNull();
expect(params.get('location')).toBeNull();
expect(params.get('node')).toBeNull();
expect(params.get('type')).toBeNull();
expect(params.get('day')).toBeNull();
@ -382,6 +389,57 @@ describe('ProxmoxBackupsTable', () => {
expect(screen.getByPlaceholderText(/search backups by workload/i)).toHaveValue('');
});
it('filters restore points and coverage by PBS server and datastore', async () => {
const offsitePayload = {
...pbsPayload,
data: {
backups: [
...pbsPayload.data.backups,
{
...pbsPayload.data.backups[0],
id: 'pbs-offsite/offsite/minipc/ct/112/2026-05-24T01:34:25Z',
instance: 'pbs-offsite',
datastore: 'offsite',
backupTime: '2026-05-24T01:34:25Z',
},
],
},
};
mockBackupAPIs('protected', offsitePayload);
renderInRouter(() => (
<ProxmoxBackupsTable emptyIcon={<span />} workloads={[workloadResource]} />
));
await screen.findByText('offsite / minipc');
const filterSelect = screen.getByRole('combobox', { name: 'Filter' });
const offsiteOption = screen.getByRole('option', {
name: 'Backup location: pbs-offsite / offsite',
}) as HTMLOptionElement;
await fireEvent.change(filterSelect, { target: { value: offsiteOption.value } });
await waitFor(() =>
expect(new URLSearchParams(window.location.search).get('location')).toBe(
'pbs:pbs-offsite:offsite',
),
);
expect(screen.getByText('offsite / minipc')).toBeInTheDocument();
expect(screen.queryByText('main / minipc')).not.toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Backup location: pbs-offsite / offsite' }),
).toBeInTheDocument();
await fireEvent.click(screen.getByRole('link', { name: /coverage/i }));
await waitFor(() => expect(window.location.pathname).toBe('/proxmox/backups/coverage'));
expect(new URLSearchParams(window.location.search).get('location')).toBe(
'pbs:pbs-offsite:offsite',
);
expect(screen.getAllByText('pbs-docker').length).toBeGreaterThan(0);
await fireEvent.click(screen.getByRole('button', { name: /expand details for pbs-docker/i }));
expect(screen.getByText('offsite / minipc')).toBeInTheDocument();
expect(screen.queryByText('main / minipc')).not.toBeInTheDocument();
});
it('clears incompatible URL facets when switching backup views', async () => {
mockBackupAPIs();
window.history.replaceState({}, '', '/?view=date&source=pbs&day=2026-05-25');

View file

@ -133,6 +133,13 @@ describe('proxmoxBackupRecoveryModel', () => {
'PVE file',
'Snapshot',
]);
expect(
model.recoverableArtifacts.map((artifact) => [artifact.locationKey, artifact.locationLabel]),
).toEqual([
['pbs:pbs-main:main', 'pbs-main / main'],
['archive:homelab:local', 'homelab / local'],
['snapshot:homelab:minipc', 'homelab / minipc'],
]);
expect(model.recoverableArtifacts[0].detail).toBe('2 PBS files');
expect(coverageRowMatchesSearch(row, 'pbs-docker')).toBe(true);
expect(coverageRowMatchesSearch(row, 'PVE backup file')).toBe(true);

View file

@ -42,6 +42,10 @@ export interface RecoverableArtifact {
createdMs?: number;
size?: number;
location: string;
/** Stable source/repository identity used by the backup-location filter. */
locationKey?: string;
/** Operator-facing source/repository label used by the backup-location filter. */
locationLabel?: string;
detail: string;
protected: boolean;
verified?: boolean;
@ -150,6 +154,25 @@ function normalizeKey(value: string | number | undefined | null): string {
.toLowerCase();
}
function artifactLocationScope(
sourceKind: RecoverableSourceKind,
instance: string | undefined,
repository: string | undefined,
): Pick<RecoverableArtifact, 'locationKey' | 'locationLabel'> {
const parts = [instance?.trim(), repository?.trim()].filter((part): part is string =>
Boolean(part),
);
const distinctParts = parts.filter(
(part, index) =>
parts.findIndex((candidate) => normalizeKey(candidate) === normalizeKey(part)) === index,
);
if (distinctParts.length === 0) return {};
return {
locationKey: [sourceKind, ...distinctParts.map(normalizeKey)].join(':'),
locationLabel: distinctParts.join(' / '),
};
}
function isZeroWorkloadId(vmid: string): boolean {
return normalizeKey(vmid) === '0';
}
@ -511,6 +534,7 @@ export function buildProxmoxBackupRecoveryModel(
createdMs,
size: backup.size,
location: `${backup.datastore || '—'} / ${backup.namespace?.trim() || '(root)'}`,
...artifactLocationScope('pbs', backup.instance, backup.datastore),
detail: pbsBackupFileDetailLabel(backup.files.length),
protected: backup.protected,
verified: backup.inProgress ? undefined : backup.verified,
@ -537,6 +561,7 @@ export function buildProxmoxBackupRecoveryModel(
createdMs,
size: archive.size,
location: archive.storage || archive.node || '—',
...artifactLocationScope('archive', archive.instance, archive.storage || archive.node),
detail: archive.volid || archive.format || archiveSource.detailFallbackLabel,
protected: archive.protected,
verified: archive.inProgress ? undefined : archive.isPBS ? archive.verified : undefined,
@ -562,6 +587,7 @@ export function buildProxmoxBackupRecoveryModel(
createdMs,
size: snapshot.sizeBytes,
location: snapshot.node || snapshot.instance || '—',
...artifactLocationScope('snapshot', snapshot.instance, snapshot.node),
detail: snapshot.description || snapshot.name || snapshotSource.detailFallbackLabel,
protected: false,
});

View file

@ -173,6 +173,7 @@ describe('resource link routing contract', () => {
node: 'node',
type: 'type',
source: 'source',
location: 'location',
posture: 'posture',
day: 'day',
});

View file

@ -35,6 +35,7 @@ export const PROXMOX_BACKUPS_QUERY_PARAMS = {
node: 'node',
type: 'type',
source: 'source',
location: 'location',
posture: 'posture',
day: 'day',
} as const;

View file

@ -224,7 +224,7 @@ No manual migration is required.
render_release_body._requires_customer_facing_standard("6.4.0")
)
def test_v640_rc1_packet_records_stale_container_health_fix(self) -> None:
def test_v640_rc1_packet_records_current_customer_fixes(self) -> None:
notes = (
_REPO_ROOT / "docs" / "releases" / "RELEASE_NOTES_v6.4.0-rc.1.md"
).read_text(encoding="utf-8")
@ -236,6 +236,11 @@ No manual migration is required.
"from the stale health-check result retained by the container runtime.",
normalized_notes,
)
self.assertIn(
"Backup-location filters distinguish PBS servers and datastores so "
"local and off-site restore points can be reviewed independently.",
normalized_notes,
)
def test_future_release_notes_reject_internal_release_control_sections(self) -> None:
notes = """# Pulse v6.4.0-rc.2 Release Notes