Proxmox and Docker passes: storage words, first paint, table fits

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
This commit is contained in:
rcourtman
2026-10-03 21:35:27 +01:00
parent bb4a137258
commit b8a0f87e6b
30 changed files with 592 additions and 104 deletions
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
]
}
@@ -189,44 +189,57 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
</Show>
}
>
{/* 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. */}
<div
data-stacked-disk-trigger
class="h-full w-full"
onMouseEnter={state.handleMouseEnter}
onMouseLeave={state.handleMouseLeave}
>
<div class="flex h-full items-end justify-center gap-[3px]">
<For each={presentation().verticalBars}>
{(bar) => (
<div
class="group/disk relative h-full w-[5px] shrink-0 cursor-help overflow-hidden rounded-xs bg-surface-hover ring-1 ring-transparent transition-[transform,box-shadow,ring-color] duration-100 hover:scale-y-[1.08] hover:ring-base-content/30"
title={bar.title}
>
<div class="flex h-full items-center justify-center gap-1.5">
<Show when={presentation().verticalBarsLabel}>
<span
data-stacked-disk-max-label
class="shrink-0 text-[10px] font-semibold leading-none tabular-nums text-base-content"
title={presentation().verticalBarsLabelTitle}
>
{presentation().verticalBarsLabel}
</span>
</Show>
<div class="flex h-full items-end justify-center gap-[3px]">
<For each={presentation().verticalBars}>
{(bar) => (
<div
data-stacked-disk-fill="vertical"
class="absolute inset-0 rounded-xs brightness-100 transition-[filter] duration-100 group-hover/disk:brightness-125"
class="group/disk relative h-full w-[5px] shrink-0 cursor-help overflow-hidden rounded-xs bg-surface-hover ring-1 ring-transparent transition-[transform,box-shadow,ring-color] duration-100 hover:scale-y-[1.08] hover:ring-base-content/30"
title={bar.title}
>
<svg
aria-hidden="true"
class="absolute inset-0 h-full w-full"
viewBox="0 0 100 100"
preserveAspectRatio="none"
<div
data-stacked-disk-fill="vertical"
class="absolute inset-0 rounded-xs brightness-100 transition-[filter] duration-100 group-hover/disk:brightness-125"
>
<rect
class="metric-fill-geometry"
x="0"
y={invertPercent(bar.fillPercent)}
width="100"
height={clampPercent(bar.fillPercent)}
rx="2"
fill={bar.color}
/>
</svg>
<svg
aria-hidden="true"
class="absolute inset-0 h-full w-full"
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
<rect
class="metric-fill-geometry"
x="0"
y={invertPercent(bar.fillPercent)}
width="100"
height={clampPercent(bar.fillPercent)}
rx="2"
fill={bar.color}
/>
</svg>
</div>
</div>
</div>
)}
</For>
)}
</For>
</div>
</div>
</div>
</Show>
@@ -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(() => (
<StackedDiskBar
mode="vertical-bars"
disks={[
makeDisk({ mountpoint: '/', usage: 24, used: 24, total: 100 }),
makeDisk({ mountpoint: '/data', usage: 52, used: 52, total: 100 }),
]}
/>
));
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(() => (
<StackedDiskBar
mode="vertical-bars"
disks={[makeDisk({ mountpoint: '/', usage: -1 })]}
aggregateDisk={makeDisk({ usage: 30 })}
/>
));
expect(container.querySelector('[data-stacked-disk-max-label]')).toBeNull();
});
});
});
@@ -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: '?' });
});
@@ -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,
};
}
@@ -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
</PlatformSortableTableHead>
@@ -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
</PlatformSortableTableHead>
@@ -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
</PlatformSortableTableHead>
@@ -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%]"
>
<PlatformResponsiveTableLabel compact="Update" full="Update check" />
</PlatformSortableTableHead>
@@ -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. */}
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
class={`${getPlatformTableCellClassForKind('text')} text-base-content platform-table-phone-hidden`}
>
{dockerHostName(resource)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
class={`${getPlatformTableCellClassForKind('text')} text-base-content platform-table-phone-hidden`}
>
<span
class="inline-block max-w-[20rem] truncate"
@@ -74,7 +74,12 @@ const formatServiceUpdate = (
return { label: 'Stable', title: 'No active service update reported' };
}
const label = state || 'Updating';
// Docker reports the rollout state as a snake_case token
// ("rollback_started"); read it as words in the cell and keep the raw
// token, message and completion time in the title.
const label = state
? state.replace(/[_-]+/g, ' ').replace(/^./, (c) => c.toUpperCase())
: 'Updating';
const title = [state, message, completedAt].filter(Boolean).join(' | ') || label;
return { label, title };
};
@@ -12,6 +12,7 @@ import {
createPlatformTableSortState,
getPlatformTableCellClassForKind,
getPlatformTableDateTimeSortValue,
PlatformTableRelativeTimeValue,
PlatformTableShell,
type PlatformTableSortValue,
withPlatformStatusCounts,
@@ -255,11 +256,10 @@ export const DockerTasksTable: Component<DockerNativeTableProps> = (props) => {
<TableCell
class={`${getPlatformTableCellClassForKind('text')} platform-table-narrow-hidden hidden text-base-content md:table-cell`}
>
<span
class="inline-block max-w-48 truncate"
title={dockerTextValue(resource.docker?.startedAt)}
>
{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. */}
<span title={dockerTextValue(resource.docker?.startedAt)}>
<PlatformTableRelativeTimeValue value={resource.docker?.startedAt} />
</span>
</TableCell>
</TableRow>
@@ -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', () => {
@@ -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%',
});
@@ -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,
},
};
@@ -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%\]"/,
@@ -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',
);
@@ -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(),
@@ -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';
}
@@ -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);
@@ -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<UseUnifiedResourcesModule['useUnifiedResources']> | 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
@@ -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<string, { status: string; lastSeen: string; error?: string }>;
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,
+12 -2
View File
@@ -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"
+24
View File
@@ -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")
}
}
+50 -38
View File
@@ -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)
+31
View File
@@ -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")
}
}
+5 -1
View File
@@ -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)
@@ -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{
@@ -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)
}
}
}
+16 -5
View File
@@ -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