diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md
index 6265060bec..848c054f05 100644
--- a/docs/release-control/v6/internal/subsystems/monitoring.md
+++ b/docs/release-control/v6/internal/subsystems/monitoring.md
@@ -1557,6 +1557,13 @@ service-history reads plus denial/recovery without fabricated samples.
failing job's last good sync falls behind by its failure count, and a
long-running demo never reports healthy jobs as overdue. A mock job never
targets its own source node, and an estate without a second node has none.
+ The mock estate carries shared storage in the shape the poller produces,
+ not the per-node shape the PVE API returns: a shared PBS storage is one
+ cluster-scoped row listing its reporting nodes (issue 1049), never one row
+ per node with a different usage figure on each.
+ Demo Docker container names are unique per host, as Docker itself enforces:
+ a container profile reused for a second round on one host takes a numbered
+ suffix instead of producing two rows with one name.
Mock metrics history must also stay bounded independently of estate size:
eager multi-day PVE guest history is limited to a deterministic sample spread
across the estate, while every omitted guest continues to receive the same
diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
index 8df6300caa..a071784ef5 100644
--- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
+++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
@@ -359,6 +359,12 @@ every tick.
Mock replication timing is advanced in that same full-rotation refresh, and
only a job whose schedule phase has passed is rewritten, so keeping demo
replication on schedule adds no per-tick change for jobs that did not run.
+Shared storage in the mock estate stays one row per cluster per store, so the
+Storage surface grows with the number of storages rather than with the node
+count times the number of shared storages.
+Demo Docker container names stay unique per host (numbered suffixes on reused
+profiles), so image "used by" lists and container rows never collapse two
+distinct containers into what reads as a duplicated row.
The browser applies resource deltas to its connection-scoped raw baseline, but
canonicalizes and reconciles only changed resources plus the host-merge groups
@@ -2510,6 +2516,13 @@ resize-observer plus tooltip lifecycle live in
`frontend-modern/src/components/Workloads/useStackedDiskBarState.ts`.
Future disk-bar runtime changes must extend through those owners instead of
reintroducing mixed resize state and presentation branching into the shell.
+The multi-disk `vertical-bars` mode (Proxmox nodes, Machines, and Docker
+hosts with more than one agent-reported disk) draws one equal-weight micro-bar
+per disk and labels the cell with the fullest disk's usage percentage, derived
+in the model from the existing max-disk summary, so a multi-disk host answers
+"how close is this host to running out of space?" at a glance in Bars mode
+instead of only on hover or in Trends mode. The label names no disk in the
+row; the mount and the per-disk breakdown stay in the title and tooltip.
The dashboard stacked memory bar now follows that same pattern: the shell
stays in `frontend-modern/src/components/Workloads/StackedMemoryBar.tsx`,
while memory-capacity math, balloon/swap tooltip derivation, anomaly label
diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md
index 97ffabafe7..b261249403 100644
--- a/docs/release-control/v6/internal/subsystems/storage-recovery.md
+++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md
@@ -2668,6 +2668,14 @@ capability over storage or recovery data.
discriminator` and `falls back to the topology discriminator when no
vdev layout is reported` in
`frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts`.
+ When neither is reported, a Proxmox storage plugin type resolves to the
+ name the Proxmox UI gives it (`lvmthin` is LVM-Thin, `nfs` is NFS,
+ `cifs` is SMB/CIFS, `iscsi` is iSCSI, `zfs`, `btrfs`, `glusterfs`,
+ `esxi` likewise); title-casing the raw plugin name is not a label.
+ A storage the poller merged across the cluster carries the synthetic
+ `cluster` node (the mock fixture's `shared`), and its host label reads
+ "Shared" with the reporting node count when the storage lists its
+ nodes, never the raw marker.
27. A rejected legacy RBAC import must not destroy the store. The import is
transactional and leaves the legacy files in place, so a failure leaves the
diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md
index ae311147f4..61b7cd908b 100644
--- a/docs/release-control/v6/internal/subsystems/unified-resources.md
+++ b/docs/release-control/v6/internal/subsystems/unified-resources.md
@@ -2030,6 +2030,14 @@ application resource-provider or WebSocket lifecycle.
populates only the canonical field on the REST contract, so without
landing on `v2.uptime` ESXi hosts and VMware-backed VMs lose uptime on
the unified-resources side even though the API payload carries it.
+ The same mapping must carry every facet the frontend reads at the top
+ level of `Resource`: the provider facets (`pmg` included) and the
+ per-source `sourceStatus`, `actionReadiness`, and `technology` fields the
+ API returns. The REST and websocket transports race on a fresh route
+ load, and a facet present only under `platformData` on the REST row left
+ the Mail Gateway table rendering every PMG column as a dash, and Docker
+ lifecycle refusals and storage freshness reading undefined, whenever the
+ REST snapshot landed last.
That same shared cache boundary must normalize route/query type filters
through the canonical frontend-to-`ResourceType` resolver before slicing
the snapshot, so compatibility values such as `disk` / `physical_disk`
@@ -2553,6 +2561,11 @@ 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.
+A PBS datastore resource takes its status from the storage vocabulary that PVE
+storage already uses (`available`/`unavailable` alongside online, degraded, and
+offline), so a healthy datastore is online and one PBS reports an error for is
+offline; the generic string mapper that left every datastore unknown is not a
+valid datastore status source.
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`.
@@ -5408,6 +5421,16 @@ read-only status and governed UpdateButton content. The boundary must preserve
full action names, review semantics and native agent/container identity; it must
not hide update labels to satisfy width constraints. DockerNativeTables.test.ts
checks that the governed update action remains inside that boundary.
+A phone row (under 440px) keeps container, state, CPU, memory and the update
+control and defers the restart count to the row expansion, so the state word
+and the Update control fit whole instead of clipping to "runni…" beside an
+unreadable Restarts header. At compact width (a 1280-1536px laptop window) the
+Host, Engine and Updates columns take enough share that "docker 26.1.3", a
+host name and the Update control render whole; the percentage-only CPU and
+memory bars yield the difference. Swarm services show the rollout state as
+words ("Rollback started") with the raw token, message and completion time in
+the title, and Swarm tasks show Started as an age with the RFC3339 stamp in
+the title, matching every other platform table's time cells.
ResourceDetailDrawer headings wrap long display names rather than ellipsising
them. Overview rows may still truncate identity; keyboard expansion exposes the
diff --git a/frontend-modern/browser-verification/dceabcf6f551939d.json b/frontend-modern/browser-verification/dceabcf6f551939d.json
new file mode 100644
index 0000000000..4187797721
--- /dev/null
+++ b/frontend-modern/browser-verification/dceabcf6f551939d.json
@@ -0,0 +1,67 @@
+{
+ "version": 1,
+ "base_sha": "bb4a1372583e28204f9ac825d54855e5e00557ca",
+ "verified_at": "2026-10-03T20:35:27Z",
+ "result": "passed",
+ "changed_paths": [
+ "frontend-modern/src/components/Workloads/StackedDiskBar.tsx",
+ "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts",
+ "frontend-modern/src/features/docker/DockerImagesTable.tsx",
+ "frontend-modern/src/features/docker/DockerServicesTable.tsx",
+ "frontend-modern/src/features/docker/DockerTasksTable.tsx",
+ "frontend-modern/src/features/docker/dockerContainerTableModel.ts",
+ "frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts",
+ "frontend-modern/src/features/storageBackups/storageAdapters.ts",
+ "frontend-modern/src/hooks/useUnifiedResources.ts"
+ ],
+ "content_sha256": {
+ "frontend-modern/src/components/Workloads/StackedDiskBar.tsx": "100cc005912a2bef11c55fa8b6961d473d245e8a3fb71ab8a640186c294b26eb",
+ "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts": "9fde1740e300724ef9cab4a3b036c942bdab6e5d2060f262606b27f4b1a40ea6",
+ "frontend-modern/src/features/docker/DockerImagesTable.tsx": "29284b34d9631f118a449da1c274b1028e3cea57aed37a3ac056e48e05b2b613",
+ "frontend-modern/src/features/docker/DockerServicesTable.tsx": "0a84ec6834e2aa8f6603c57a3bad168911b9f06bef80c9c7ccbad07d1f04075c",
+ "frontend-modern/src/features/docker/DockerTasksTable.tsx": "6cc99405c7bdb2b2edfd992f6b17b2636bb9285a53e949e41609dfa8b339cacf",
+ "frontend-modern/src/features/docker/dockerContainerTableModel.ts": "b821b9caf65d5c247e543a6d6dbf0925f735b617c6393066d0f8c15a10e05e72",
+ "frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts": "933723fb9afeeb53afa46946a346daca19d50c0d61a81fc0730498ebd8c09bb2",
+ "frontend-modern/src/features/storageBackups/storageAdapters.ts": "daa85c5d99903d5a5fb10070837106b91f06ad130cae320472b2d980b979fb48",
+ "frontend-modern/src/hooks/useUnifiedResources.ts": "166e45835c56d00f1a3ddbc37f7127348a0e36dc7c42c921f972db7750c603c6"
+ },
+ "routes": [
+ "/proxmox/overview",
+ "/proxmox/storage",
+ "/proxmox/mail",
+ "/docker/overview",
+ "/docker/images",
+ "/docker/storage",
+ "/docker/networks",
+ "/docker/swarm",
+ "/machines"
+ ],
+ "viewports": [
+ {
+ "width": 1440,
+ "height": 900
+ },
+ {
+ "width": 1280,
+ "height": 900
+ },
+ {
+ "width": 390,
+ "height": 900
+ }
+ ],
+ "states": [
+ "PR #2432 rebuilt as one commit on origin/main 1f140a5d38 (after Tailwind 4, #2443/#2444), on a private mock stack whose backend is built from this tree (the mock generator and REST mapper changes are live), Vite serving this tree, light and dark",
+ "Proxmox Storage at 1440 and 390: PBS datastores read Online instead of Unknown, types read NFS, Pool, Filesystem, Datastore and Backup Target, cluster-merged storage reads Shared \u00b7 6 nodes, shared PBS storage is one row; the 390 storage table still clips host and usage values exactly as main does, which open PR #2433 addresses",
+ "Proxmox Overview nodes table at 1440 light and dark in Bars mode: seven multi-disk hosts show their fullest disk percentage beside equal-weight micro-bars with a Highest usage title, none clipped; bar and fill corners compute 2px, matching main's rounded-xs after the Tailwind 4 rename",
+ "Mail Gateway on six fresh loads: both instances fill Version, Nodes, Uptime and mail counters every time, with no dash-only rows",
+ "Docker at 1440, 1280 and 390: Engine reads docker 26.1.3 or podman 4.9.3 whole with the Update control in view; phone containers keep Container, State, CPU, Mem and Update; phone images keep Image, Size and Update; Swarm services read Rollback started and tasks show Started as ages with no raw RFC3339 stamps; storage, networks and swarm phone clipping is limited to identity strings",
+ "The mock estate has no multi-disk Docker host or machine, so Bars mode was seen on Proxmox nodes only; DockerHostsTable and AgentsMachinesTable share the same StackedDiskBar path and its unit tests"
+ ],
+ "interactions": [
+ "scrollWidth-over-clientWidth sweep of every visible table cell (sr-only excluded) on each route and width; read column value distributions for Storage State, Type and Host and Docker Engine",
+ "Opened Mail Gateway from the Proxmox tab strip on six independent page loads and counted dash-only cells per row",
+ "Read computed border radius of the vertical disk bars and the max-label text and title on every multi-disk node row",
+ "go test ./internal/mock ./internal/unifiedresources passed; vitest for src/components/Workloads, src/features/docker, src/features/storageBackups, src/features/platformPage and useUnifiedResources: 174 files, 3384 tests passed"
+ ]
+}
diff --git a/frontend-modern/src/components/Workloads/StackedDiskBar.tsx b/frontend-modern/src/components/Workloads/StackedDiskBar.tsx
index 46c6d86628..868691bdb8 100644
--- a/frontend-modern/src/components/Workloads/StackedDiskBar.tsx
+++ b/frontend-modern/src/components/Workloads/StackedDiskBar.tsx
@@ -189,44 +189,57 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
}
>
- {/* Vertical micro-bars: one per disk, fill height = utilization, color = threshold */}
+ {/* Vertical micro-bars: one per disk, fill height = utilization, color = threshold.
+ The fullest disk's percentage sits beside them so the cell still reads
+ as a number at a glance; every bar keeps equal weight. */}
-
-
- {(bar) => (
-
+
+
+
+ {presentation().verticalBarsLabel}
+
+
+
+
+ {(bar) => (
-
+
+
-
- )}
-
+ )}
+
+
diff --git a/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx b/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx
index e550f069a0..391101ac73 100644
--- a/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx
+++ b/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx
@@ -587,4 +587,36 @@ describe('StackedDiskBar', () => {
expect(bar?.getAttribute('fill')).toContain('239, 68, 68');
});
});
+
+ describe('vertical-bars mode', () => {
+ it('labels the micro-bars with the fullest disk so the cell reads as a number', () => {
+ const { container } = render(() => (
+
+ ));
+
+ const label = container.querySelector('[data-stacked-disk-max-label]');
+ expect(label).toHaveTextContent('52%');
+ expect(label).toHaveAttribute('title', 'Highest usage: /data 52%');
+ // Every disk keeps its own bar with equal weight; the label is derived.
+ expect(container.querySelectorAll('[data-stacked-disk-fill="vertical"]')).toHaveLength(2);
+ });
+
+ it('omits the label when no disk has measured usage', () => {
+ const { container } = render(() => (
+
+ ));
+
+ expect(container.querySelector('[data-stacked-disk-max-label]')).toBeNull();
+ });
+ });
});
diff --git a/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts b/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts
index 054068a04c..b56964dfe4 100644
--- a/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts
+++ b/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts
@@ -372,6 +372,10 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
title: '/x: 40% (40.0 B/100 B)',
});
expect(p.verticalBars[1].color).toBe(CRITICAL); // 120% -> critical
+ // The fullest disk's usage labels the cell so a multi-disk host still
+ // reads as a number without a hover.
+ expect(p.verticalBarsLabel).toBe('120%');
+ expect(p.verticalBarsLabelTitle).toBe('Highest usage: /y 120%');
expect(p.tooltipContent).toHaveLength(3);
expect(p.tooltipContent[2]).toMatchObject({ label: '/z', percent: '—', used: '?' });
});
diff --git a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts
index 7b2adc3f65..ed72f85037 100644
--- a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts
+++ b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts
@@ -87,6 +87,10 @@ export interface StackedDiskBarPresentation {
tooltipTitle: string;
useStackedSegments: boolean;
verticalBars: StackedDiskVerticalBar[];
+ // The fullest disk's usage, shown beside the micro-bars so a multi-disk
+ // host answers "how close is this host to running out?" without a hover.
+ verticalBarsLabel: string;
+ verticalBarsLabelTitle: string;
verticalBarsMode: boolean;
}
@@ -359,6 +363,8 @@ export function buildStackedDiskBarPresentation(
};
})
: [];
+ const verticalBarsLabel = verticalBarsMode && maxInfo ? formatPercent(maxInfo.percent) : '';
+ const verticalBarsLabelTitle = verticalBarsMode && maxInfo ? maxLabelFull : '';
const tooltipContent = buildTooltipContent(allDisks, {
aggregateDisk: props.aggregateDisk,
aggregateMode,
@@ -402,6 +408,8 @@ export function buildStackedDiskBarPresentation(
tooltipTitle: allDisks.length > 1 ? 'Disk Breakdown' : 'Disk Usage',
useStackedSegments,
verticalBars,
+ verticalBarsLabel,
+ verticalBarsLabelTitle,
verticalBarsMode,
};
}
diff --git a/frontend-modern/src/features/docker/DockerImagesTable.tsx b/frontend-modern/src/features/docker/DockerImagesTable.tsx
index 88345be440..744bc18b71 100644
--- a/frontend-modern/src/features/docker/DockerImagesTable.tsx
+++ b/frontend-modern/src/features/docker/DockerImagesTable.tsx
@@ -152,7 +152,7 @@ export const DockerImagesTable: Component<
kind="text"
sort={sort}
sortKey="host"
- class="platform-table-mobile-w-15 md:w-[18%]"
+ class="platform-table-phone-hidden md:w-[18%]"
>
Host
@@ -160,7 +160,7 @@ export const DockerImagesTable: Component<
kind="text"
sort={sort}
sortKey="usedBy"
- class="platform-table-mobile-w-15 md:w-[24%]"
+ class="platform-table-phone-hidden md:w-[24%]"
>
Used by
@@ -168,7 +168,7 @@ export const DockerImagesTable: Component<
kind="numeric-value"
sort={sort}
sortKey="size"
- class="platform-table-mobile-w-15 md:w-[12%]"
+ class="platform-table-mobile-w-20 md:w-[12%]"
>
Size
@@ -176,7 +176,7 @@ export const DockerImagesTable: Component<
kind="badge"
sort={sort}
sortKey="update"
- class="platform-table-mobile-w-15 md:w-[16%]"
+ class="platform-table-mobile-w-25 md:w-[16%]"
>
@@ -211,13 +211,15 @@ export const DockerImagesTable: Component<
/>
}
/>
+ {/* Host and consumers fit the row expansion on a phone; the
+ row keeps the image, its size and the update verdict. */}
{dockerHostName(resource)}
c.toUpperCase())
+ : 'Updating';
const title = [state, message, completedAt].filter(Boolean).join(' | ') || label;
return { label, title };
};
diff --git a/frontend-modern/src/features/docker/DockerTasksTable.tsx b/frontend-modern/src/features/docker/DockerTasksTable.tsx
index e8fabdeaa0..e45f4a182b 100644
--- a/frontend-modern/src/features/docker/DockerTasksTable.tsx
+++ b/frontend-modern/src/features/docker/DockerTasksTable.tsx
@@ -12,6 +12,7 @@ import {
createPlatformTableSortState,
getPlatformTableCellClassForKind,
getPlatformTableDateTimeSortValue,
+ PlatformTableRelativeTimeValue,
PlatformTableShell,
type PlatformTableSortValue,
withPlatformStatusCounts,
@@ -255,11 +256,10 @@ export const DockerTasksTable: Component = (props) => {
-
- {dockerTextValue(resource.docker?.startedAt)}
+ {/* Swarm reports a raw RFC3339 stamp with microseconds; an age
+ reads at a glance and the stamp stays in the title. */}
+
+
diff --git a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx
index 1695ae6c76..4d31066272 100644
--- a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx
+++ b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx
@@ -1512,7 +1512,11 @@ describe('Docker native tables', () => {
expect(screen.getByText('replicated')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
- expect(screen.getByText('rollback_started')).toBeInTheDocument();
+ // The snake_case rollout token reads as words; the raw token stays in the title.
+ expect(screen.getByText('Rollback started')).toBeInTheDocument();
+ expect(
+ screen.getByText('Rollback started').closest('[title]')?.getAttribute('title'),
+ ).toContain('rollback_started');
expect(screen.getByText('18080:8080/tcp')).toBeInTheDocument();
expect(screen.getByText('manager-1')).toBeInTheDocument();
expect(document.querySelector('[data-docker-service-row="service-1"]')).not.toBeNull();
@@ -1651,7 +1655,10 @@ describe('Docker native tables', () => {
expect(screen.getByText('web.2')).toBeInTheDocument();
expect(screen.getByText('web')).toBeInTheDocument();
expect(screen.getByText('running 2 minutes')).toBeInTheDocument();
- expect(screen.getByText('2026-05-24T13:05:00Z')).toBeInTheDocument();
+ // The raw RFC3339 stamp moves to the title; the cell shows an age.
+ const started = screen.getByTitle('2026-05-24T13:05:00Z');
+ expect(started).toBeInTheDocument();
+ expect(started.textContent).toMatch(/ago$/);
});
it('renders Docker Swarm secret API metadata without secret data', () => {
diff --git a/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts b/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts
index f8a8fc36f4..41f10a0869 100644
--- a/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts
+++ b/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts
@@ -30,7 +30,9 @@ describe('dockerContainerTableModel', () => {
const columns = getDockerContainerVisibleColumnsForLayout('phone', true, true, true);
const ids = columns.map((column) => column.id);
- expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'restarts', 'updates']);
+ // Restarts waits for mobile width: a phone row could not fit its header
+ // beside the Update control, and the count stays in the row expansion.
+ expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'updates']);
expect(getDockerContainerColumnWidthStyle('container', 'phone', ids)).toEqual({
width: '30%',
});
diff --git a/frontend-modern/src/features/docker/dockerContainerTableModel.ts b/frontend-modern/src/features/docker/dockerContainerTableModel.ts
index b573f3aba2..2961092c89 100644
--- a/frontend-modern/src/features/docker/dockerContainerTableModel.ts
+++ b/frontend-modern/src/features/docker/dockerContainerTableModel.ts
@@ -70,7 +70,9 @@ const DOCKER_CONTAINER_COLUMN_MIN_LAYOUT: Record<
state: 'narrow',
cpu: 'narrow',
memory: 'narrow',
- restarts: 'phone',
+ // A phone row cannot fit the Restarts header beside the Update control;
+ // the count stays in the row expansion there and returns at mobile width.
+ restarts: 'mobile',
updates: 'narrow',
actions: 'mobile',
host: 'tablet',
@@ -126,11 +128,10 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record<
},
phone: {
container: 32,
- state: 13,
- cpu: 15,
- memory: 17,
- restarts: 10,
- updates: 13,
+ state: 14,
+ cpu: 14,
+ memory: 14,
+ updates: 16,
},
mobile: {
container: 30,
@@ -151,17 +152,20 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record<
updates: 9,
actions: 6,
},
+ // Compact is what a 1280-1536px laptop window gets. Host, Engine and the
+ // Update control are short values that were clipped ("docker 2…") while the
+ // percentage-only CPU and memory bars had room to spare.
compact: {
container: 18,
- host: 10,
- runtime: 8,
- image: 18,
+ host: 12,
+ runtime: 9.5,
+ image: 16.5,
state: 7,
- cpu: 11,
- memory: 12,
- restarts: 9,
+ cpu: 9.5,
+ memory: 10,
+ restarts: 8.5,
ports: 12,
- updates: 9,
+ updates: 10,
actions: 8,
},
};
diff --git a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts
index b811259023..bae62908b8 100644
--- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts
+++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts
@@ -321,8 +321,19 @@ describe('platform overview layout guardrails', () => {
]) {
expect(source).toContain('platform-table-mobile-w-30');
}
+ // The images phone row keeps image, size and the update verdict; host and
+ // consumers move to the row expansion so the verdict is not clipped.
expect(dockerImagesTableSource).toMatch(
- /sortKey="size"[\s\S]{0,120}?class="platform-table-mobile-w-15 md:w-\[12%\]"/,
+ /sortKey="size"[\s\S]{0,120}?class="platform-table-mobile-w-20 md:w-\[12%\]"/,
+ );
+ expect(dockerImagesTableSource).toMatch(
+ /sortKey="host"[\s\S]{0,120}?class="platform-table-phone-hidden md:w-\[18%\]"/,
+ );
+ expect(dockerImagesTableSource).toMatch(
+ /sortKey="usedBy"[\s\S]{0,120}?class="platform-table-phone-hidden md:w-\[24%\]"/,
+ );
+ expect(dockerImagesTableSource).toMatch(
+ /sortKey="update"[\s\S]{0,120}?class="platform-table-mobile-w-25 md:w-\[16%\]"/,
);
expect(dockerServicesTableSource).toMatch(
/sortKey="mode"[\s\S]{0,120}?class="platform-table-phone-hidden md:w-\[8%\]"/,
diff --git a/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts
index 14cd9deebd..5185db68c3 100644
--- a/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts
+++ b/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts
@@ -62,6 +62,14 @@ describe('resourceStoragePresentation', () => {
),
).toBe('Datastore');
expect(getResourceStorageTopologyLabel(makeResource(), 'rbd')).toBe('Cluster Storage');
+ // Proxmox plugin names are acronyms or compound words; title-casing them
+ // produced "Lvmthin" for every default local-lvm storage and "Nfs".
+ expect(getResourceStorageTopologyLabel(makeResource(), 'lvmthin')).toBe('LVM-Thin');
+ expect(getResourceStorageTopologyLabel(makeResource(), 'lvm')).toBe('LVM');
+ expect(getResourceStorageTopologyLabel(makeResource(), 'nfs')).toBe('NFS');
+ expect(getResourceStorageTopologyLabel(makeResource(), 'cifs')).toBe('SMB/CIFS');
+ expect(getResourceStorageTopologyLabel(makeResource(), 'iscsi')).toBe('iSCSI');
+ expect(getResourceStorageTopologyLabel(makeResource(), 'btrfs')).toBe('BTRFS');
expect(getResourceStorageTopologyLabel(makeResource(), 'ignored', 'rebuild target')).toBe(
'Rebuild Target',
);
diff --git a/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts
index 96866fbeda..7e5917d601 100644
--- a/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts
+++ b/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts
@@ -467,6 +467,37 @@ describe('storageAdapters', () => {
expect(records[0].hostLabel).toBe('pve1');
});
+ it('labels a poller-merged shared storage as shared with its node count', () => {
+ const records = buildStorageRecords({
+ state: baseState(),
+ resources: [
+ makeResourceStorage({
+ id: 'cluster-a-cluster-nfs-iso',
+ name: 'nfs-iso',
+ parentName: 'cluster',
+ platformData: { type: 'nfs', node: 'cluster', instance: 'cluster-a', shared: true },
+ storage: {
+ type: 'nfs',
+ shared: true,
+ nodes: ['pve1', 'pve2', 'pve3'],
+ } as Resource['storage'],
+ }),
+ makeResourceStorage({
+ id: 'pve2-local-lvm',
+ name: 'local-lvm',
+ parentName: 'pve2',
+ platformData: { type: 'lvmthin', node: 'pve2', instance: 'cluster-a', shared: false },
+ storage: { type: 'lvmthin', shared: false } as Resource['storage'],
+ }),
+ ],
+ });
+
+ // The poller folds a shared storage into one entry on the synthetic
+ // "cluster" node; a node-local storage keeps its node name.
+ expect(records.map((record) => record.hostLabel)).toEqual(['Shared · 3 nodes', 'pve2']);
+ expect(records.map((record) => record.topologyLabel)).toEqual(['NFS', 'LVM-Thin']);
+ });
+
it('keeps dependency impact separate from healthy storage primary issues', () => {
const records = buildStorageRecords({
state: baseState(),
diff --git a/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts b/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts
index 0c3b5d618a..9302b84d66 100644
--- a/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts
+++ b/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts
@@ -244,6 +244,30 @@ export const getResourceStorageTopologyLabel = (
case 'rbd':
case 'cephfs':
return 'Cluster Storage';
+ // Proxmox storage plugin names are acronyms or compound words that
+ // title-casing mangles ("Lvmthin" for the default local-lvm, "Nfs"); use
+ // the names the Proxmox UI shows for them.
+ case 'nfs':
+ return 'NFS';
+ case 'cifs':
+ case 'smb':
+ return 'SMB/CIFS';
+ case 'lvm':
+ return 'LVM';
+ case 'lvmthin':
+ case 'lvm-thin':
+ return 'LVM-Thin';
+ case 'iscsi':
+ case 'iscsidirect':
+ return 'iSCSI';
+ case 'zfs':
+ return 'ZFS';
+ case 'btrfs':
+ return 'BTRFS';
+ case 'glusterfs':
+ return 'GlusterFS';
+ case 'esxi':
+ return 'ESXi';
default:
return titleize(storageType) || titleize(resource.type) || 'Storage';
}
diff --git a/frontend-modern/src/features/storageBackups/storageAdapters.ts b/frontend-modern/src/features/storageBackups/storageAdapters.ts
index 19b8ac6598..7e22fdd6d7 100644
--- a/frontend-modern/src/features/storageBackups/storageAdapters.ts
+++ b/frontend-modern/src/features/storageBackups/storageAdapters.ts
@@ -129,7 +129,16 @@ const mapResourceStorageRecord = (resource: Resource, adapterId: string): Storag
const usedBytes = asNumberOrNull(resource.disk?.used);
const freeBytes = asNumberOrNull(resource.disk?.free);
const canonicalPlatform = getCanonicalStoragePlatformKey(resource, resource.storage?.platform);
- const hostLabel = locationLabel;
+ // The poller folds a shared storage into one entry whose node is the
+ // synthetic "cluster" marker (the mock uses "shared"); show that as a
+ // shared storage with its reporting node count rather than a node name.
+ const sharedLocationMarkers = new Set(['cluster', 'shared']);
+ const hostLabel =
+ shared && sharedLocationMarkers.has(locationLabel.trim().toLowerCase())
+ ? storageNodes.length > 1
+ ? `Shared · ${storageNodes.length} nodes`
+ : 'Shared'
+ : locationLabel;
const platformLabel = getResourceStoragePlatformLabel(canonicalPlatform);
const issueLabel = getResourceStorageIssueLabel(resource);
const issueSummary = getResourceStorageIssueSummary(resource);
diff --git a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts
index 3408c0079d..079d6012a2 100644
--- a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts
+++ b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts
@@ -1626,6 +1626,64 @@ describe('useUnifiedResources', () => {
dispose();
});
+ it('keeps PMG metadata on the canonical resource for REST-first paints', async () => {
+ // The Mail Gateway table reads `resource.pmg`. The mapper used to keep it
+ // only under platformData, so a REST-sourced row rendered every PMG column
+ // as a dash until a websocket row happened to replace it.
+ apiFetchMock.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ data: [
+ {
+ id: 'pmg-1',
+ type: 'pmg',
+ name: 'mail-gateway-eu',
+ status: 'online',
+ lastSeen: '2026-02-06T12:00:00Z',
+ sources: ['pmg'],
+ uptime: 1_555_222,
+ technology: 'pmg',
+ sourceStatus: { pmg: { status: 'online', lastSeen: '2026-02-06T12:00:00Z' } },
+ actionReadiness: [{ name: 'restart', available: true }],
+ pmg: {
+ instanceId: 'pmg-eu',
+ hostname: 'pmg.example.lan',
+ version: '8.1-2',
+ nodeCount: 1,
+ mailCountTotal: 2_740,
+ spamIn: 321,
+ virusIn: 14,
+ queueTotal: 11,
+ queueDeferred: 5,
+ },
+ },
+ ],
+ }),
+ });
+
+ let dispose = () => {};
+ let result: ReturnType | undefined;
+ createRoot((d) => {
+ dispose = d;
+ result = useUnifiedResources({ query: 'type=pmg&source=pmg', cacheKey: 'pmg-first-paint' });
+ });
+
+ await result!.refetch();
+ const row = result!.resources().find((resource) => resource.id === 'pmg-1');
+ expect(row?.pmg).toEqual(
+ expect.objectContaining({ version: '8.1-2', spamIn: 321, queueDeferred: 5 }),
+ );
+ expect(row?.platformData?.pmg).toEqual(row?.pmg);
+ // The per-source status, action readiness, and technology facets ride the
+ // same race: Docker lifecycle actions and storage freshness read them at
+ // the top level, where only websocket rows used to carry them.
+ expect(row?.sourceStatus?.pmg?.status).toBe('online');
+ expect(row?.actionReadiness?.[0]?.name).toBe('restart');
+ expect(row?.technology).toBe('pmg');
+
+ dispose();
+ });
+
it('falls back to canonical resource.uptime when no platform-specific field is set (vSphere)', async () => {
// vSphere ESXi hosts and VMs populate only the canonical
// Resource.Uptime field — there's no vmware-specific UptimeSeconds
diff --git a/frontend-modern/src/hooks/useUnifiedResources.ts b/frontend-modern/src/hooks/useUnifiedResources.ts
index 9093092194..dbb5d9b3d8 100644
--- a/frontend-modern/src/hooks/useUnifiedResources.ts
+++ b/frontend-modern/src/hooks/useUnifiedResources.ts
@@ -18,8 +18,10 @@ import type {
ResourceDockerMeta,
ResourceHealth,
ResourceMetricsTarget,
+ ResourceActionReadiness,
ResourceAvailabilityMeta,
ResourcePBSMeta,
+ ResourcePMGMeta,
ResourcePolicyPostureSummary,
ResourcePhysicalDiskMeta,
ResourcePoolHealth,
@@ -210,6 +212,8 @@ type APIResource = {
sources?: string[];
platformScopes?: string[];
sourceStatus?: Record;
+ actionReadiness?: ResourceActionReadiness[];
+ technology?: string;
health?: ResourceHealth;
identity?: {
machineId?: string;
@@ -868,6 +872,17 @@ const toResource = (v2: APIResource): Resource => {
truenas: v2.truenas as ResourceTrueNASMeta | undefined,
vmware: v2.vmware as ResourceVMwareMeta | undefined,
pbs: v2.pbs as ResourcePBSMeta | undefined,
+ // Mail Gateway rows read `resource.pmg` directly. Leaving it only under
+ // platformData meant a REST-first paint rendered every PMG column as a
+ // dash until a websocket row happened to replace it, and never did when
+ // the REST snapshot landed last.
+ pmg: v2.pmg as ResourcePMGMeta | undefined,
+ // Same race for the per-source status, action readiness, and technology
+ // facets: Docker lifecycle actions and storage freshness read them from
+ // the top level, and the websocket rows carry them there.
+ sourceStatus: v2.sourceStatus,
+ actionReadiness: v2.actionReadiness,
+ technology: v2.technology,
availability: v2.availability as ResourceAvailabilityMeta | undefined,
availabilityChecks: v2.availabilityChecks as ResourceAvailabilityMeta[] | undefined,
physicalDisk: v2.physicalDisk,
diff --git a/internal/mock/demo_scenarios.go b/internal/mock/demo_scenarios.go
index e4fd41df0f..6211b82793 100644
--- a/internal/mock/demo_scenarios.go
+++ b/internal/mock/demo_scenarios.go
@@ -649,7 +649,7 @@ func applyDemoOfflineDockerContainers(containers []models.DockerContainer, now t
finished = finished.Add(-18 * time.Minute)
for i := range containers {
profile := offlineProfiles[i%len(offlineProfiles)]
- containers[i].Name = profile.Name
+ containers[i].Name = demoContainerProfileName(profile.Name, i/len(offlineProfiles))
containers[i].Image = profile.Image
containers[i].Labels = mergeScenarioLabelSet(containers[i].Labels, profile.Tags)
containers[i].State = "exited"
@@ -662,6 +662,16 @@ func applyDemoOfflineDockerContainers(containers []models.DockerContainer, now t
return containers
}
+// demoContainerProfileName keeps container names unique per host. Docker
+// refuses two containers with one name on a host, so a profile reused for a
+// second round takes a numbered suffix instead of producing duplicate rows.
+func demoContainerProfileName(name string, round int) string {
+ if round <= 0 {
+ return name
+ }
+ return fmt.Sprintf("%s-%d", name, round+1)
+}
+
func applyDemoDockerContainerProfiles(
containers []models.DockerContainer,
profiles []demoDockerContainerProfile,
@@ -672,7 +682,7 @@ func applyDemoDockerContainerProfiles(
})
for i := range containers {
profile := profiles[i%len(profiles)]
- containers[i].Name = profile.Name
+ containers[i].Name = demoContainerProfileName(profile.Name, i/len(profiles))
containers[i].Image = profile.Image
containers[i].Labels = mergeScenarioLabelSet(containers[i].Labels, profile.Tags)
containers[i].State = "running"
diff --git a/internal/mock/demo_scenarios_test.go b/internal/mock/demo_scenarios_test.go
index b5e8c34af9..4b46d58649 100644
--- a/internal/mock/demo_scenarios_test.go
+++ b/internal/mock/demo_scenarios_test.go
@@ -987,3 +987,27 @@ func demoProxmoxNodeIsOffline(graph FixtureGraph, nodeName string) bool {
}
return false
}
+
+func TestDemoDockerContainerNamesAreUniquePerHost(t *testing.T) {
+ cfg := DefaultConfig
+ cfg.DockerHostCount = 3
+ cfg.DockerContainersPerHost = 8
+
+ graph := buildFixtureGraph(cfg, time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC))
+
+ // Docker refuses two containers with one name on a host. The demo used to
+ // reuse a curated profile for a second round and show two "customer-portal"
+ // rows per host, which also doubled every image's "used by" list.
+ for _, host := range graph.State.DockerHosts {
+ seen := map[string]struct{}{}
+ for _, container := range host.Containers {
+ if _, dup := seen[container.Name]; dup {
+ t.Fatalf("host %s names two containers %q", host.Hostname, container.Name)
+ }
+ seen[container.Name] = struct{}{}
+ }
+ }
+ if !dockerContainerNameExists(graph, "customer-portal") {
+ t.Fatal("expected the first round of curated names to stay unchanged")
+ }
+}
diff --git a/internal/mock/generator.go b/internal/mock/generator.go
index c1d914d06d..658d7c48a6 100644
--- a/internal/mock/generator.go
+++ b/internal/mock/generator.go
@@ -5197,50 +5197,62 @@ func generateStorage(nodes []models.Node) []models.Storage {
}
sort.Strings(clusterInstances)
- // Every node reports the cluster's two shared PBS storage definitions. This
- // stays linear in fleet size: the former one-storage-per-target-node shape
- // grew quadratically and made a large demo look busy for the wrong reason.
+ // Each cluster has two shared PBS storage definitions. Every node reports
+ // them to the API, but the poller merges a shared storage into one
+ // cluster-scoped entry (issue 1049), so the fixture carries that merged
+ // shape: one row per store with the reporting nodes listed, never one row
+ // per node with a different usage figure on each.
for _, clusterInstance := range clusterInstances {
clusterNodes := clusterNodesByInstance[clusterInstance]
sort.Slice(clusterNodes, func(i, j int) bool { return clusterNodes[i].Name < clusterNodes[j].Name })
+ nodeNames := make([]string, 0, len(clusterNodes))
+ nodeIDs := make([]string, 0, len(clusterNodes))
+ anyNodeOnline := false
for _, node := range clusterNodes {
- for storeIndex, storeName := range []string{"pbs-primary", "pbs-offsite"} {
- pbsTotal := int64(48+storeIndex*16) * 1024 * 1024 * 1024 * 1024
- pbsID := fmt.Sprintf("%s-%s-%s", node.Instance, node.Name, storeName)
- pbsUsage := SampleMetric("pbsDatastore", pbsID, "usage", now)
- pbsUsed := int64(float64(pbsTotal) * (pbsUsage / 100.0))
- isOffline := node.Status != "online" || node.ConnectionHealth == "offline" || node.Uptime <= 0
- status := "available"
- enabled := true
- active := true
- lastSeen := now
- if isOffline {
- status = "offline"
- enabled = false
- active = false
- pbsUsed = 0
- pbsUsage = 0
- lastSeen = now.Add(-10 * time.Minute)
- }
- storage = append(storage, models.Storage{
- ID: pbsID,
- Name: storeName,
- Node: node.Name,
- Instance: node.Instance,
- Type: "pbs",
- Status: status,
- Total: pbsTotal,
- Used: pbsUsed,
- Free: pbsTotal - pbsUsed,
- Usage: pbsUsage,
- Content: "backup",
- Shared: true,
- Enabled: enabled,
- Active: active,
- LastSeen: lastSeen,
- })
+ nodeNames = append(nodeNames, node.Name)
+ nodeIDs = append(nodeIDs, fmt.Sprintf("%s-%s", node.Instance, node.Name))
+ if node.Status == "online" && node.ConnectionHealth != "offline" && node.Uptime > 0 {
+ anyNodeOnline = true
}
}
+ for storeIndex, storeName := range []string{"pbs-primary", "pbs-offsite"} {
+ pbsTotal := int64(48+storeIndex*16) * 1024 * 1024 * 1024 * 1024
+ pbsID := fmt.Sprintf("%s-cluster-%s", clusterInstance, storeName)
+ pbsUsage := SampleMetric("pbsDatastore", pbsID, "usage", now)
+ pbsUsed := int64(float64(pbsTotal) * (pbsUsage / 100.0))
+ status := "available"
+ enabled := true
+ active := true
+ lastSeen := now
+ if !anyNodeOnline {
+ status = "offline"
+ enabled = false
+ active = false
+ pbsUsed = 0
+ pbsUsage = 0
+ lastSeen = now.Add(-10 * time.Minute)
+ }
+ storage = append(storage, models.Storage{
+ ID: pbsID,
+ Name: storeName,
+ Node: "cluster",
+ Nodes: append([]string(nil), nodeNames...),
+ NodeIDs: append([]string(nil), nodeIDs...),
+ NodeCount: len(nodeNames),
+ Instance: clusterInstance,
+ Type: "pbs",
+ Status: status,
+ Total: pbsTotal,
+ Used: pbsUsed,
+ Free: pbsTotal - pbsUsed,
+ Usage: pbsUsage,
+ Content: "backup",
+ Shared: true,
+ Enabled: enabled,
+ Active: active,
+ LastSeen: lastSeen,
+ })
+ }
sharedTotal := int64(20 * 1024 * 1024 * 1024 * 1024)
sharedID := fmt.Sprintf("%s-shared-storage", clusterInstance)
diff --git a/internal/mock/generator_test.go b/internal/mock/generator_test.go
index 1c254a197c..98772cd2ff 100644
--- a/internal/mock/generator_test.go
+++ b/internal/mock/generator_test.go
@@ -1291,3 +1291,34 @@ func TestRebaseMockReplicationJobsLeavesUnchangedJobsAlone(t *testing.T) {
t.Fatalf("expected at most one job to roll over in one second, %d of %d stayed put", unchanged, len(jobs))
}
}
+
+func TestBuildFixtureStateMergesSharedPBSStorageLikeThePoller(t *testing.T) {
+ cfg := DefaultConfig
+ cfg.NodeCount = 5
+
+ data := buildFixtureState(cfg)
+
+ // Issue 1049: the poller folds a shared storage reported by every node
+ // into one cluster-scoped row. The fixture must not fan it back out into
+ // one row per node, each with its own usage figure.
+ seen := map[string]models.Storage{}
+ for _, storage := range data.Storage {
+ if storage.Type != "pbs" {
+ continue
+ }
+ key := storage.Instance + "/" + storage.Name
+ if _, dup := seen[key]; dup {
+ t.Fatalf("shared PBS storage %s appears more than once", key)
+ }
+ seen[key] = storage
+ if storage.Node != "cluster" || storage.NodeCount < 2 || len(storage.Nodes) != storage.NodeCount {
+ t.Fatalf("shared PBS storage %s is not cluster-scoped: node=%q nodes=%v count=%d", key, storage.Node, storage.Nodes, storage.NodeCount)
+ }
+ if !strings.HasSuffix(storage.ID, "-cluster-"+storage.Name) {
+ t.Fatalf("shared PBS storage %s keeps a node-scoped id %q", key, storage.ID)
+ }
+ }
+ if len(seen) == 0 {
+ t.Fatal("expected shared PBS storage in the fixture")
+ }
+}
diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go
index 9d360e2ece..1a93cb5f03 100644
--- a/internal/unifiedresources/adapters.go
+++ b/internal/unifiedresources/adapters.go
@@ -1562,7 +1562,11 @@ func resourceFromPBSDatastore(instance models.PBSInstance, datastore models.PBSD
name := strings.TrimSpace(datastore.Name)
assessment := storagehealth.AssessPBSDatastore(datastore)
risk := storageRiskFromAssessment(assessment)
- status := storageStatus(statusFromString(datastore.Status), risk)
+ // The PBS poller reports datastores as "available"/"unavailable", the same
+ // storage vocabulary PVE uses; the generic string mapper does not know it
+ // and left every healthy datastore "unknown".
+ datastoreStatus, _ := statusFromStorageState(datastore.Status)
+ status := storageStatus(datastoreStatus, risk)
incidents := incidentsFromAssessment("pulse", string(SourcePBS), "pbs-datastore:"+name, assessment, instance.LastSeen)
status = incidentsStatus(status, incidents)
diff --git a/internal/unifiedresources/adapters_test.go b/internal/unifiedresources/adapters_test.go
index b06013ade9..b6309233d1 100644
--- a/internal/unifiedresources/adapters_test.go
+++ b/internal/unifiedresources/adapters_test.go
@@ -1242,6 +1242,28 @@ func TestResourceFromPBSDatastoreDerivesRiskAndIncidents(t *testing.T) {
}
}
+func TestResourceFromPBSDatastoreReadsStorageStatusVocabulary(t *testing.T) {
+ instance := models.PBSInstance{ID: "pbs-1", Name: "pbs-main", LastSeen: time.Now().UTC()}
+ // The poller stamps "available" when PBS reports no error and
+ // "unavailable" when it does; neither is in the generic status mapper.
+ cases := []struct {
+ status string
+ want ResourceStatus
+ }{
+ {status: "available", want: StatusOnline},
+ {status: "unavailable", want: StatusOffline},
+ {status: "degraded", want: StatusWarning},
+ {status: "", want: StatusUnknown},
+ }
+ for _, tc := range cases {
+ datastore := models.PBSDatastore{Name: "backup-store", Status: tc.status, Total: 100, Used: 10}
+ resource, _ := resourceFromPBSDatastore(instance, datastore)
+ if resource.Status != tc.want {
+ t.Fatalf("status %q: Status = %q, want %q", tc.status, resource.Status, tc.want)
+ }
+ }
+}
+
func TestResourceFromPBSInstanceRollsUpDatastoreRisk(t *testing.T) {
now := time.Now().UTC()
instance := models.PBSInstance{
diff --git a/internal/unifiedresources/pbs_pmg_registry_test.go b/internal/unifiedresources/pbs_pmg_registry_test.go
index 0146f353fa..cb7ac971b3 100644
--- a/internal/unifiedresources/pbs_pmg_registry_test.go
+++ b/internal/unifiedresources/pbs_pmg_registry_test.go
@@ -843,3 +843,26 @@ func TestPBSHostHistoryThreeServerTopology(t *testing.T) {
t.Fatalf("three-server topology: services=%d linked hosts=%d linked disks=%d", services, linkedHosts, linkedDisks)
}
}
+
+func TestStatusFromStorageStateCoversPBSAndPVEVocabulary(t *testing.T) {
+ // PVE storage and PBS datastores both report "available"/"unavailable";
+ // the mapping has to be shared so neither surface falls back to unknown.
+ cases := []struct {
+ state string
+ want ResourceStatus
+ known bool
+ }{
+ {state: "available", want: StatusOnline, known: true},
+ {state: "ACTIVE", want: StatusOnline, known: true},
+ {state: "degraded", want: StatusWarning, known: true},
+ {state: "unavailable", want: StatusOffline, known: true},
+ {state: "", want: StatusUnknown, known: false},
+ {state: "mystery", want: StatusUnknown, known: false},
+ }
+ for _, tc := range cases {
+ got, known := statusFromStorageState(tc.state)
+ if got != tc.want || known != tc.known {
+ t.Fatalf("statusFromStorageState(%q) = (%q, %v), want (%q, %v)", tc.state, got, known, tc.want, tc.known)
+ }
+ }
+}
diff --git a/internal/unifiedresources/status.go b/internal/unifiedresources/status.go
index fa4650e281..fcd1676c74 100644
--- a/internal/unifiedresources/status.go
+++ b/internal/unifiedresources/status.go
@@ -32,14 +32,25 @@ func statusFromGuest(status string) ResourceStatus {
}
}
-func statusFromStorage(storage models.Storage) ResourceStatus {
- switch strings.ToLower(strings.TrimSpace(storage.Status)) {
+// statusFromStorageState maps the storage-native status vocabulary, which
+// includes the "available"/"unavailable" pair PVE storage and PBS datastores
+// report, onto a resource status. The second result is false when the string
+// is not a storage state the caller can act on.
+func statusFromStorageState(status string) (ResourceStatus, bool) {
+ switch strings.ToLower(strings.TrimSpace(status)) {
case "online", "running", "available", "active", "ok":
- return StatusOnline
+ return StatusOnline, true
case "warning", "degraded":
- return StatusWarning
+ return StatusWarning, true
case "offline", "down", "unavailable", "error":
- return StatusOffline
+ return StatusOffline, true
+ }
+ return StatusUnknown, false
+}
+
+func statusFromStorage(storage models.Storage) ResourceStatus {
+ if status, ok := statusFromStorageState(storage.Status); ok {
+ return status
}
if !storage.Active {
return StatusOffline