fix(proxmox): keep backup details on backups tab

This commit is contained in:
Pulse Test
2026-08-28 20:49:13 +01:00
parent 0dc2c8c16d
commit 47afce8171
10 changed files with 56 additions and 236 deletions
@@ -181,13 +181,9 @@ focus restoration, and responsive drawer behavior on one primitive. Proxmox
Backup Server rows follow this rule and request the shared host-details
disclosure open initially so a merged agent's system, hardware, network, disk,
and thermal facts remain discoverable from the PBS surface.
`ProxmoxBackupServersTable` is the single PBS host-health composition on both
Proxmox Overview and Backups. Overview places it between PVE nodes and guests
so a standalone PBS host remains part of the estate scan; Backups places the
same table beside recovery evidence. Overview must omit the backup-artifact
count column because it does not own the PBS artifact request, while both
placements retain the same responsive columns, row interaction, and canonical
resource drawer rather than introducing an overview-only PBS row variant.
`ProxmoxBackupServersTable` belongs to the Proxmox Backups tab. Proxmox
Overview must not duplicate that domain table between its node and guest
regions; the tab boundary owns PBS server, datastore, and artifact detail.
When that surface receives the provider-owned PBS resource and its host Agent
as separate canonical resources, `ProxmoxBackupServersTable` may assemble a
presentation-only drawer resource only after one unique normalized host
@@ -42,12 +42,12 @@ backup artifact, recovery point, protection posture, or restore operation, and
storage/recovery consumers must continue to use the canonical resource and
provider identities for all domain authority.
The Proxmox overview is also a large-estate read-side consumer: it owns one
canonical unified-resource snapshot for its PVE node, PBS host-health, and guest
regions, and its shared workloads adapter must consume that snapshot without
starting a second workload or infrastructure inventory request. Backup and
recovery tables keep their domain-specific evidence requests, while an overview
refresh must update all overview regions from the same owner snapshot rather
than mixing reads from different inventory generations.
canonical unified-resource snapshot for its node and guest regions, and its
shared workloads adapter must consume that snapshot without starting a second
workload or infrastructure inventory request. Backup and recovery tables keep
their domain-specific evidence requests, while an overview refresh must update
all overview regions from the same owner snapshot rather than mixing reads
from different inventory generations.
The physical-disks surface consumes every page in the server-declared unified
resource result, scopes same-named Proxmox nodes by instance, and never hides a
disk behind a client-side page ceiling. Search includes vendor, WWN, transport,
@@ -486,18 +486,17 @@ summary row. Compact layouts must not stack those secondary facts beneath the
resource name and make the row taller: keep essential identity inline when no
detail disclosure exists, and otherwise preserve supporting context in its
existing column, the row's accessible title, or the expanded detail content.
The Backup servers summary owns PBS reachability and datastore capacity and is
shared by Proxmox Overview and Backups, but its row expansion is the canonical
unified-resource drawer, not a second storage-local detail model. Overview
places this health summary after PVE nodes and before guests so bare-metal PBS
hosts remain in the estate scan. Because Overview consumes only the canonical
resource snapshot, it omits the artifact-count column rather than rendering
zero from an unrequested PBS backup feed; the Backups placement supplies that
domain API evidence and retains the count. A PBS resource merged with a Pulse
Agent keeps the PBS service evidence and exposes the agent's system, hardware,
network, disk, thermal, history, and management context through that shared
drawer. Storage/recovery does not duplicate agent telemetry or invent a
competing provider drawer hierarchy.
The Backup servers summary on the Proxmox Backups tab owns PBS reachability and
datastore capacity, but its row expansion is the canonical unified-resource
drawer, not a second storage-local detail model. A PBS resource merged with a
Pulse Agent keeps the
PBS service evidence and exposes the agent's system, hardware, network, disk,
thermal, history, and management context through that shared drawer. Bare-metal
PBS hosts therefore remain fully inspectable after leaving the standalone
Machines surface, while storage/recovery does not duplicate agent telemetry or
invent a competing provider drawer hierarchy.
Proxmox Overview must not duplicate this domain table; its route-scoped
snapshot remains limited to the node and guest regions it owns.
The product-family projection may still deliver the PBS service and its Agent
host as separate resources. In that case the Backup servers table correlates
only a unique normalized host-identity match for drawer presentation, preserves
@@ -590,17 +589,15 @@ complete client-side result set; windowing must not become pagination, a partial
count, or a recovery-local `Next` / `Load more` control. Expanded coverage
evidence remains owned by the visible workload row.
The Proxmox shell hydrates Overview, Storage, Replication, Backups, Ceph, and
Mail from route-scoped resource families. Overview includes PBS service rows
and Agent facets in its bounded snapshot so host health and the canonical PBS
drawer do not depend on background hydration of Backups or Storage. Only the
visible tab consumes realtime reconciliation. Desktop background hydration may
prefetch the other route families, while phone background hydration is bounded
to Storage because the phone shell retains at most the two most-recent tab
trees. Storage reuses the shell's bounded agent/PBS/storage/physical-disk/Ceph
source instead of opening a duplicate page query or an all-estate subscription,
and is prewarmed for the common Overview/Storage transition; hidden route-state
effects remain dormant and a forced Proxmox storage scope is not serialized as
redundant URL state.
Mail from route-scoped resource families. Only the visible tab consumes
realtime reconciliation. Desktop background hydration may prefetch the other
route families, while phone background hydration is bounded to Storage because
the phone shell retains at most the two most-recent tab trees. Storage reuses
the shell's bounded agent/PBS/storage/physical-disk/Ceph source instead of
opening a duplicate page query or an all-estate subscription, and is prewarmed
for the common Overview/Storage transition; hidden route-state effects remain
dormant and a forced Proxmox storage scope is not serialized as redundant URL
state.
PBS server/datastore rows may display backup counts, but the
counts must come from the PBS backup API artifact identity, not from a
datastore-capacity approximation. The table owns which PBS artifact count is
@@ -426,14 +426,6 @@ inventory: operator-visible guest names, VMIDs, node identities, status, and
provider scope may retain a parent node, but opaque unified-resource `id`
hashes must not create node matches that the normalized workload table cannot
show.
That Overview snapshot also includes PBS service resources and their Agent
facets. The page composes the same `ProxmoxBackupServersTable` used by Backups
between PVE nodes and guests, so standalone PBS health and the canonical
resource drawer remain discoverable without returning the Agent to Machines or
building a second host projection. Overview does not own PBS artifact evidence
and therefore suppresses that table's backup-count column; service identity,
datastore capacity, current Agent telemetry, and History continue to use the
same fail-closed correlation boundary as the Backups placement.
Large platform inventories may bound their initial row projection through the
shared platform-table preview primitive so the next canonical surface remains
in the initial reading flow. Proxmox node consumers retain the page model's
@@ -832,10 +824,9 @@ container inventory table.
10. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts` shared with `performance-and-scalability`: unified resource table state, grouping, and windowing are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
11. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts` shared with `performance-and-scalability`: unified resource table viewport sync and selected-row reveal are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
12. `frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx` shared with `storage-recovery`: Proxmox backup server table rows are both a storage/recovery backup-health surface and a unified-resource platform-table consumer boundary.
The same table is composed on Proxmox Overview and Backups. Overview reads
PBS and Agent facets from its owner snapshot and suppresses artifact counts;
Backups may add counts from its PBS artifact request. Both placements keep
the canonical PBS row and drawer contract below.
The table is composed on the Proxmox Backups tab and must not be duplicated
on Overview. The Backups placement keeps the canonical PBS row and drawer
contract below while owning the domain-specific artifact request.
Each row carries the canonical PBS `Resource` into the shared platform
resource-detail row. When coalescence has attached an agent facet, or when
the product-family projection contains exactly one separate Agent resource
+17 -19
View File
@@ -1,23 +1,21 @@
{
"version": 1,
"base_sha": "aea8d3497b0474d985602c81fe17be228446c954",
"verified_at": "2026-08-28T14:52:18Z",
"base_sha": "0dc2c8c16d9612f981a29055cc703f6100cdf8cb",
"verified_at": "2026-08-28T19:44:59Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx",
"frontend-modern/src/components/shared/DrawerAttentionSection.tsx"
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx": "ec60044cf9b250c9d049230ee7252bf73a389956b3ab611929520ad6c70dac40",
"frontend-modern/src/components/shared/DrawerAttentionSection.tsx": "6f1b0d01ef1e3bdcb21faff305e2c2d7763cc226cb92c0f4c4daca7f1fbf04c0"
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "58e4c06e98108a69f1e62f631bb248eec626b3c6fc204e60507e377cd1633d8c",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "1e6daf19456ff79e7181e8117bbb2064feb53b852438294a58dd2fa5197b3817"
},
"routes": [
"/attention-verification.html"
],
"routes": ["/proxmox/overview", "/proxmox/backups/date"],
"viewports": [
{
"width": 1440,
"height": 900
"width": 1280,
"height": 800
},
{
"width": 390,
@@ -25,15 +23,15 @@
}
],
"states": [
"collapsed five-alert node summary showing the first three resource-specific alerts",
"expanded node summary showing warning, critical, info, and acknowledged severity badges",
"desktop summary bounded to 768 pixels with no horizontal page overflow",
"390 pixel summary contained within the viewport with no horizontal page overflow"
"mock-backed Proxmox Overview with the bounded node preview flowing directly into Guests and no PBS server table",
"Proxmox Backups By date view retaining the PBS server and datastore table with artifact counts",
"expanded PBS server row retaining canonical resource details on the Backups tab",
"desktop and narrow layouts contained within the viewport without horizontal document overflow"
],
"interactions": [
"confirmed node and child VM resource names plus Memory, Disk, and Backup Age metrics remain visible beside their problem text",
"selected Show 2 more alerts and confirmed both hidden alerts appeared with aria-expanded=true",
"selected Show fewer alerts and confirmed the summary returned to three visible alerts with aria-expanded=false",
"repeated collapsed and expanded disclosure states at 1440x900 and 390x844 and confirmed no horizontal overflow"
"opened Proxmox Overview at 1280x800 and confirmed Nodes precede Guests with a 16 pixel section gap and no backup-server table",
"opened Proxmox Backups at 1280x800 and confirmed three PBS server/datastore rows remain visible",
"repeated Overview at 390x844 and confirmed Nodes and Guests remain visible without a duplicated PBS table or horizontal overflow",
"opened Proxmox Backups at 390x844, expanded the first PBS row, and confirmed canonical resource details remain usable without horizontal overflow"
]
}
@@ -444,7 +444,7 @@ describe('platform overview layout guardrails', () => {
}
expect(proxmoxPageSurfaceSource).toContain('<ProxmoxNodesTable');
expect(proxmoxPageSurfaceSource).toContain('<ProxmoxBackupServersTable');
expect(proxmoxPageSurfaceSource).not.toContain('<ProxmoxBackupServersTable');
expect(proxmoxPageSurfaceSource).toContain('<WorkloadsSurface');
expect(proxmoxPageSurfaceSource).toContain(
"const PROXMOX_WORKLOAD_EXCLUDED_TYPES = ['app-container'] as const",
@@ -40,9 +40,8 @@ import {
// so it lives here on the Backups page, not buried on the platform Storage tab
// where the rows read as generic "PVE" storage. One row per datastore, labelled
// by its server; a server with no datastore data still gets a reachability row.
// Host CPU/memory/uptime ride along on each of the server's rows. The shared
// table appears on Overview for estate health and on Backups beside recovery
// evidence, so both paths open the same canonical PBS/agent detail drawer.
// Host CPU/memory/uptime ride along on each of the server's rows: PBS hosts
// left the v5 nodes table in the v6 IA, so this is where their health lives.
interface BackupServerRow {
key: string;
@@ -245,7 +244,6 @@ export function buildBackupServerRows(
export function ProxmoxBackupServersTable(props: {
servers: readonly Resource[];
backups?: readonly PBSBackup[];
showBackupCounts?: boolean;
emptyIcon?: JSX.Element;
layoutWidth?: Accessor<number | null | undefined>;
}) {
@@ -257,11 +255,7 @@ export function ProxmoxBackupServersTable(props: {
? getBackupServerLayoutForContainer(width)
: 'full';
});
const visibleColumns = createMemo(() =>
getBackupServerColumns(layoutMode()).filter(
(column) => props.showBackupCounts !== false || column.id !== 'backups',
),
);
const visibleColumns = createMemo(() => getBackupServerColumns(layoutMode()));
const detail = createPlatformResourceDetailState({ idPrefix: 'proxmox-backup-server-detail' });
const columnVisible = (column: BackupServerColumnId) =>
visibleColumns().some((candidate) => candidate.id === column);
@@ -51,7 +51,6 @@ import {
PlatformTableLoadingState,
} from '@/features/platformPage/sharedPlatformPage';
import { ProxmoxBackupsTable } from './ProxmoxBackupsTable';
import { ProxmoxBackupServersTable } from './ProxmoxBackupServersTable';
import { ProxmoxCephTable } from './ProxmoxCephTable';
import { ProxmoxMailGatewayTable } from './ProxmoxMailGatewayTable';
import { ProxmoxNodesTable } from './ProxmoxNodesTable';
@@ -71,7 +70,7 @@ import {
// REST page also carries global type aggregations, so evidence-gated tabs do
// not require the old 1,000+ row workspace request.
const PROXMOX_RESOURCE_QUERY_BY_TAB: Record<ProxmoxPageTabId, string> = {
overview: 'type=agent,vm,system-container,oci-container,pbs',
overview: 'type=agent,vm,system-container,oci-container',
storage: 'type=agent,pbs,storage,physical_disk,ceph',
replication: 'type=agent',
backups: 'type=agent,vm,system-container,pbs',
@@ -531,11 +530,6 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
inventoryCountsVisible={props.inventoryCountsVisible}
/>
</section>
<ProxmoxBackupServersTable
servers={currentModel().pbs}
showBackupCounts={false}
layoutWidth={overviewWidth.width}
/>
<section
id="proxmox-guests-section"
aria-label="Guests"
@@ -56,15 +56,6 @@ const makePbsResource = (): Resource =>
}) as Resource;
describe('ProxmoxBackupServersTable details', () => {
it('omits artifact counts when composed as overview health', () => {
render(() => (
<ProxmoxBackupServersTable servers={[makePbsResource()]} showBackupCounts={false} />
));
expect(screen.queryByRole('columnheader', { name: 'Backups' })).not.toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /Backup server/ })).toBeInTheDocument();
});
it('opens the canonical resource drawer with merged host details expanded', () => {
render(() => <ProxmoxBackupServersTable servers={[makePbsResource()]} />);
@@ -14,7 +14,6 @@ const mockVersionInfo = vi.hoisted(() => vi.fn());
const mockStorageProps = vi.hoisted(() => vi.fn());
const mockTotalStats = vi.hoisted(() => vi.fn());
const mockNodesTableProps = vi.hoisted(() => vi.fn());
const mockBackupServersTableProps = vi.hoisted(() => vi.fn());
const mockWorkloadSearch = vi.hoisted(() => vi.fn(() => ''));
const makeResource = (resource: Partial<Resource> & Pick<Resource, 'id' | 'type'>): Resource =>
@@ -116,13 +115,6 @@ vi.mock('../ProxmoxBackupsTable', () => ({
ProxmoxBackupsTable: () => <div data-testid="backups-table" />,
}));
vi.mock('../ProxmoxBackupServersTable', () => ({
ProxmoxBackupServersTable: (props: { servers: Resource[] }) => {
mockBackupServersTableProps(props);
return <div data-testid="backup-servers-table" data-rows={props.servers.length} />;
},
}));
vi.mock('../ProxmoxCephTable', () => ({
ProxmoxCephTable: () => <div data-testid="ceph-table" />,
}));
@@ -334,36 +326,6 @@ describe('ProxmoxPageSurface contract', () => {
);
});
it('keeps standalone PBS host health on the Proxmox overview', () => {
const pbsServer = makeResource({
id: 'pbs:standalone',
type: 'pbs',
name: 'pbs-standalone',
platformType: 'proxmox-pbs',
sources: ['pbs'],
pbs: { instanceId: 'pbs-standalone', connectionHealth: 'healthy' },
});
const pbsAgent = makeResource({
id: 'agent:pbs-standalone',
type: 'agent',
name: 'pbs-standalone',
platformType: 'proxmox-pbs',
sources: ['agent', 'pbs'],
agent: { agentId: 'agent-pbs-standalone', hostname: 'pbs-standalone' },
});
setResources([pbsServer, pbsAgent]);
renderSurface();
expect(screen.getByTestId('backup-servers-table')).toHaveAttribute('data-rows', '2');
expect(mockBackupServersTableProps).toHaveBeenCalledWith(
expect.objectContaining({
servers: [pbsServer, pbsAgent],
showBackupCounts: false,
}),
);
});
it('shares workload search with the node inventory', () => {
mockWorkloadSearch.mockReturnValue('pve-1');
setResources([
@@ -430,7 +392,7 @@ describe('ProxmoxPageSurface contract', () => {
'proxmox-mail',
]);
expect(options.map((value) => value.query)).toEqual([
'type=agent,vm,system-container,oci-container,pbs',
'type=agent,vm,system-container,oci-container',
'type=agent,pbs,storage,physical_disk,ceph',
'type=agent',
'type=agent,vm,system-container,pbs',
@@ -442,8 +404,6 @@ describe('ProxmoxPageSurface contract', () => {
expect(proxmoxPageSurfaceSource).toContain('requestIdleCallback');
expect(proxmoxPageSurfaceSource).toContain("phoneViewport\n ? ['storage']");
expect(proxmoxPageSurfaceSource).toContain('resourceSource={storageResources}');
expect(proxmoxPageSurfaceSource).toContain('servers={currentModel().pbs}');
expect(proxmoxPageSurfaceSource).toContain('showBackupCounts={false}');
});
it('places workload controls beside the workload table they affect', () => {
@@ -454,12 +414,7 @@ describe('ProxmoxPageSurface contract', () => {
expect(nodesTableIndex).toBeGreaterThan(-1);
expect(workloadFilterIndex).toBeGreaterThan(nodesTableIndex);
expect(workloadsSurfaceIndex).toBeGreaterThan(workloadFilterIndex);
expect(proxmoxPageSurfaceSource.indexOf('<ProxmoxBackupServersTable')).toBeGreaterThan(
nodesTableIndex,
);
expect(proxmoxPageSurfaceSource.indexOf('<ProxmoxBackupServersTable')).toBeLessThan(
workloadFilterIndex,
);
expect(proxmoxPageSurfaceSource).not.toContain('<ProxmoxBackupServersTable');
});
it('keeps the bounded node preview before guests at every viewport', () => {
@@ -1,96 +0,0 @@
import { expect, test } from "@playwright/test";
import { ensureAuthenticated, getMockMode, setMockMode } from "./helpers";
test.describe("Proxmox PBS overview", () => {
test.setTimeout(180_000);
test("keeps standalone PBS health and details in the estate scan", async ({
page,
}) => {
await ensureAuthenticated(page);
const initialMockMode = await getMockMode(page);
try {
if (!initialMockMode.enabled) {
await setMockMode(page, true);
}
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/proxmox/overview", { waitUntil: "domcontentloaded" });
const overview = page.getByTestId("proxmox-page");
const nodes = overview.locator(".proxmox-nodes-card");
const servers = overview.locator(
'[data-proxmox-backups-table="servers"]',
);
const guests = overview.locator("#proxmox-guests-section");
await expect(nodes).toBeVisible({ timeout: 60_000 });
await expect(servers).toBeVisible({ timeout: 60_000 });
const desktopRow = servers.locator("tbody tr[aria-expanded]").first();
await expect(desktopRow).toBeVisible();
await expect(desktopRow.locator("td").first()).not.toHaveText("");
await expect(
servers.getByRole("columnheader", { name: "Backups" }),
).toHaveCount(0);
await expect(guests).toBeVisible();
const placement = await overview.evaluate((root) => {
const nodesElement = root.querySelector(".proxmox-nodes-card");
const serversElement = root.querySelector(
'[data-proxmox-backups-table="servers"]',
);
const guestsElement = root.querySelector("#proxmox-guests-section");
if (!nodesElement || !serversElement || !guestsElement) return null;
return {
nodesBeforeServers: Boolean(
nodesElement.compareDocumentPosition(serversElement) &
Node.DOCUMENT_POSITION_FOLLOWING,
),
serversBeforeGuests: Boolean(
serversElement.compareDocumentPosition(guestsElement) &
Node.DOCUMENT_POSITION_FOLLOWING,
),
};
});
expect(placement).toEqual({
nodesBeforeServers: true,
serversBeforeGuests: true,
});
const desktopToggle = desktopRow.getByRole("button", {
name: /details for/,
});
await desktopToggle.click();
await expect(desktopRow).toHaveAttribute("aria-expanded", "true");
await expect(
servers.getByRole("tab", { name: "History" }).first(),
).toBeVisible();
await desktopToggle.click();
await page.setViewportSize({ width: 390, height: 844 });
await page.reload({ waitUntil: "domcontentloaded" });
await expect(servers).toBeVisible({ timeout: 60_000 });
const narrowRow = servers.locator("tbody tr[aria-expanded]").first();
await narrowRow.click();
await expect(narrowRow).toHaveAttribute("aria-expanded", "true");
await expect(
servers.getByRole("tab", { name: "History" }).first(),
).toBeVisible();
const pageWidth = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
}));
expect(pageWidth.scrollWidth).toBeLessThanOrEqual(
pageWidth.clientWidth + 1,
);
} finally {
if (!initialMockMode.enabled) {
await setMockMode(page, false);
}
}
});
});