From b8a0f87e6ba53d9d93fa8634b9788f544e049dc1 Mon Sep 17 00:00:00 2001
From: rcourtman <8825017+rcourtman@users.noreply.github.com>
Date: Sat, 3 Oct 2026 21:35:27 +0100
Subject: [PATCH] Proxmox and Docker passes: storage words, first paint, table
fits
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rebuilds #2432 as one commit on current main. Its branch conflicted
with the Tailwind 4 move (#2443) in StackedDiskBar.tsx and
DockerTasksTable.tsx; the resolution keeps this change and main's class
renames (rounded-sm to rounded-xs). The eight original commits' reasons
follow.
Read PBS datastore status in the storage vocabulary:
The PBS poller reports a datastore as "available", or "unavailable"
when PBS returns an error for it. The datastore adapter mapped that
string through the generic status mapper, which only knows online,
offline and warning, so every healthy PBS datastore showed as
"Unknown" in the Storage tab and an unavailable one was not flagged.
PVE storage already maps the same vocabulary through its own storage
mapper. Share that mapping so a PBS datastore reads online or offline
like any other storage.
Carry shared PBS storage as one cluster row in the mock estate:
The mock emitted each cluster's two shared PBS storages once per node,
each with its own usage figure. The real poller folds a shared storage
into one cluster-scoped entry, which users asked for in issue 1049, so
the demo's Storage tab contradicted the shipped behaviour: ten rows of
"west-backup-primary" with different usage and growth on a five-node
estate, and a hundred on the public demo.
The fixture now carries the merged shape: one row per store with the
reporting nodes listed and a cluster-scoped id, offline only when no
cluster node is online.
Name Proxmox storage types and shared hosts as operators know them:
The Storage tab title-cased any storage type it did not map, so the
default local-lvm on every Proxmox install read "Lvmthin" and an NFS
share read "Nfs". Those types now carry the names the Proxmox UI uses:
LVM-Thin, LVM, NFS, SMB/CIFS, iSCSI, ZFS, BTRFS, GlusterFS and ESXi.
A storage the poller merged across the cluster showed the synthetic
node marker "cluster" as its host. It now reads "Shared", with the
reporting node count when the storage lists its nodes.
Carry PMG metadata on REST-mapped resources:
The Mail Gateway table reads resource.pmg. The REST-to-Resource mapper
set every other provider facet at the top level but kept pmg only under
platformData, so a row sourced from the REST snapshot rendered every
PMG column (version, nodes, mail in, spam, virus, quarantine, queue,
deferred) as a dash. The websocket snapshot carries pmg at the top
level, so the table filled in only when a websocket row happened to
replace the REST row; on a fresh load the two transports race, and
about half the loads stayed blank for good.
The mapper now carries pmg like the other facets, and a test pins it.
Label multi-disk hosts with their fullest disk in Bars mode:
A host with more than one agent-reported disk draws its Disk cell as
one micro-bar per disk and no figure, while a single-disk host shows a
percentage and Trends mode shows one for every host. Bars is the
default view, and most Proxmox nodes have at least local-zfs beside a
data pool, so the question "which node is running out of space?" had
no at-a-glance answer for the rows that matter most; the column's own
sort key was a number the cell never showed.
The cell now carries the fullest disk's usage beside the micro-bars.
Every bar keeps equal weight and no disk is named in the row; the
mount and the per-disk breakdown stay in the title and the tooltip.
Carry source status, action readiness and technology on REST rows:
The same race that blanked the Mail Gateway table applies to three
more fields the API returns: sourceStatus, actionReadiness and
technology. The REST mapper set sourceStatus only under platformData
and dropped the other two, while websocket rows carry all three at the
top level. Docker container lifecycle actions read sourceStatus and
actionReadiness to decide refusals, storage rows read sourceStatus for
freshness, and search suggestions and the assistant context read
technology, so whichever transport won a fresh load decided whether
those consumers saw the data.
The mapper now carries all three, the REST type declares the two it
omitted, and the PMG mapping test pins them.
Fit the Docker containers table and read Swarm state as words:
On a 1280-1536px laptop window the containers table clipped its short
values: the engine read "docker 2…", host names lost their tail, and
the Update control itself was cut off, while the percentage-only CPU
and memory bars had room to spare. The compact widths now give those
columns their share. On a phone the row dropped to "runni…" with an
unreadable Restarts header beside a wrapped Update button; the phone
row now keeps container, state, CPU, memory and Update, and the
restart count stays in the row expansion until mobile width. The
images table on a phone showed "Not c…" for the update verdict and a
clipped host; it keeps image, size and update check there and defers
host and consumers to the expansion.
Swarm services showed the rollout state as the raw token
"rollback_started" and Swarm tasks showed a raw RFC3339 stamp with
microseconds as their start time. The state reads as words and the
start as an age, with the raw values in the title.
Keep demo Docker container names unique per host:
The demo scenario reused its curated container profiles for a second
round on hosts with more containers than profiles, so a host showed
two running "customer-portal" rows and every image's "used by" list
read "customer-portal, customer-portal". Docker refuses two containers
with one name on a host, so the duplicates read as a bug to anyone
evaluating the demo. A reused profile now takes a numbered suffix.
Contract-Neutral: Storage type labels, shared-host label, REST mapper carrying fields websocket rows already carry, Bars-mode disk label and Docker column fits are presentation and mapping only; owning unified-resources and storage-recovery clauses updated; no frontend-primitives primitive, API or runtime boundary change
---
.../v6/internal/subsystems/monitoring.md | 7 ++
.../subsystems/performance-and-scalability.md | 13 +++
.../internal/subsystems/storage-recovery.md | 8 ++
.../internal/subsystems/unified-resources.md | 23 +++++
.../dceabcf6f551939d.json | 67 ++++++++++++++
.../components/Workloads/StackedDiskBar.tsx | 69 +++++++++------
.../__tests__/StackedDiskBar.test.tsx | 32 +++++++
...stackedDiskBarModel.branchcov0712b.test.ts | 4 +
.../Workloads/stackedDiskBarModel.ts | 8 ++
.../src/features/docker/DockerImagesTable.tsx | 14 +--
.../features/docker/DockerServicesTable.tsx | 7 +-
.../src/features/docker/DockerTasksTable.tsx | 10 +--
.../__tests__/DockerNativeTables.test.tsx | 11 ++-
.../dockerContainerTableModel.test.ts | 4 +-
.../docker/dockerContainerTableModel.ts | 30 ++++---
.../platformOverviewLayout.guardrails.test.ts | 13 ++-
.../resourceStoragePresentation.test.ts | 8 ++
.../__tests__/storageAdapters.test.ts | 31 +++++++
.../resourceStoragePresentation.ts | 24 +++++
.../storageBackups/storageAdapters.ts | 11 ++-
.../__tests__/useUnifiedResources.test.ts | 58 ++++++++++++
.../src/hooks/useUnifiedResources.ts | 15 ++++
internal/mock/demo_scenarios.go | 14 ++-
internal/mock/demo_scenarios_test.go | 24 +++++
internal/mock/generator.go | 88 +++++++++++--------
internal/mock/generator_test.go | 31 +++++++
internal/unifiedresources/adapters.go | 6 +-
internal/unifiedresources/adapters_test.go | 22 +++++
.../unifiedresources/pbs_pmg_registry_test.go | 23 +++++
internal/unifiedresources/status.go | 21 +++--
30 files changed, 592 insertions(+), 104 deletions(-)
create mode 100644 frontend-modern/browser-verification/dceabcf6f551939d.json
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