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) => { 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