mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+4
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
+12
-1
@@ -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%\]"/,
|
||||
|
||||
+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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user