Show LXC mount points from container config on the API path again

Stock Proxmox reports no per-mount LXC usage through the status API, so
v6's API-polled containers listed only rootfs. The v5.1.32 fallback that
synthesized mount rows from the container config never crossed to the v6
line, and the v6.2.0 pct-df agent path only covers nodes running the
unified agent. Restore the fallback and improve it: parse size= so
config-only rows carry capacity, mark live usage unknown with the -1
sentinel, and merge without displacing the aggregate-seeded rootfs row.

Frontend consumers stop fabricating percents for sentinel rows: the
workloads row bar and summary math exclude them (tooltip lists them with
capacity), the drawer Filesystems block renders ?/<size> with no percent,
disk normalization preserves the sentinel, and per-machine max-disk
derivations skip them. Mock mode seeds one running container in this
exact shape so the surfaces stay exercised.

Related to #1477
This commit is contained in:
rcourtman
2026-08-11 20:45:26 +01:00
parent cea33b8ee2
commit 63f1a14f31
16 changed files with 684 additions and 89 deletions
@@ -66,6 +66,19 @@ the cadence-derived cache lease. Missing, expired, renamed, stopped, or
migrated observations fall back to the Proxmox API disk view; a fresh accepted
rootfs reading also replaces the row's primary disk summary before alerts and
history are evaluated.
The Proxmox API disk view must itself enumerate every configured mount point.
Stock PVE reports no per-mount LXC usage through the status API, so mounts
known only from the container config (`rootfs`/`mpX` keys with
`mp=`/`mountpoint=` targets and `size=` capacity) surface as disk rows carrying
the configured capacity and the negative unknown-usage sentinel instead of
being dropped (#1477, restoring the v5.1.32 behavior on the v6 line).
Config-only rows merge by mountpoint identity and must never displace an
existing live-usage entry such as the aggregate-seeded rootfs row; admitted
node-local `pct df` agent rows replace the config-derived view wholesale.
Consumers must treat negative usage as unknown, never as a measured zero — the
alert engine already skips such rows in both aggregate and per-disk
evaluation, and mock mode must keep at least one running container fixture in
this exact shape so frontend surfaces keep exercising it.
Host-agent report liveness is server-observed, not agent-clock-observed:
`ApplyHostReport` must stamp `Host.LastSeen`, agent-sourced Ceph cluster
freshness, and host-agent cluster sensor freshness from Pulse receipt time, so
@@ -573,6 +573,15 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
30-day, or other fixed color cutoffs are forbidden because they can
contradict both user policy and the table beside the drawer.
12. Extend workload disk-list derivations and fallback runtime wiring through `frontend-modern/src/components/Workloads/diskListModel.ts` and `frontend-modern/src/components/Workloads/useDiskListState.ts` rather than rebuilding usage math, progress-state mapping, or tooltip fallback logic inside `frontend-modern/src/components/Workloads/DiskList.tsx`
Disk rows carrying the poller's negative unknown-usage sentinel
(config-only LXC mounts, #1477) must be excluded from usage math, summary
percents, and bar/mini-slot rendering in `stackedDiskBarModel.ts`, and
rendered without a fabricated percent in `diskListModel.ts`; they stay
listed in tooltips and the drawer with capacity only. Disk normalization
in `frontend-modern/src/utils/format.ts` must preserve the sentinel
instead of recomputing a percent from a zero used value, and per-machine
max-disk derivations must skip sentinel rows rather than surface them as
0% or negative percents.
13. Extend workload guest metadata cache persistence, metadata refresh, org-scope switching, and optimistic custom-URL updates through `frontend-modern/src/components/Workloads/useWorkloadGuestMetadataState.ts` rather than rebuilding workload-local storage caches, event listeners, or guest metadata API wiring inside `frontend-modern/src/components/Workloads/useWorkloadsState.ts`
14. Extend workload deep-link selection and hovered-row continuity semantics through `frontend-modern/src/components/Workloads/workloadSelectionModel.ts`, and extend table scroll preservation plus reactive selection state through `frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts`, rather than rebuilding resource-query parsing, selected-row scroll pinning, or hovered-row invalidation inside `frontend-modern/src/components/Workloads/useWorkloadsState.ts`; canonical typed workload IDs such as `app-container:<host>:<provider-id>` must remain exact route/selection keys and must not be reinterpreted into synthetic node scopes
15. Extend workload route ownership, route-driven option catalogs, and toolbar filter config through `frontend-modern/src/components/Workloads/useWorkloadRouteState.ts`, `frontend-modern/src/components/Workloads/useWorkloadFilterOptions.ts`, `frontend-modern/src/components/Workloads/workloadRouteModel.ts`, `frontend-modern/src/components/Workloads/workloadFilterConfigModel.ts`, and `frontend-modern/src/components/Workloads/workloadRouteStateModel.ts`, and extend query-param synchronization plus managed workload URL semantics through `frontend-modern/src/components/Workloads/useWorkloadUrlSync.ts` and `frontend-modern/src/components/Workloads/workloadUrlSyncModel.ts`, rather than rebuilding route sync, alias parsing, option derivation, toolbar callback/config wiring, reset policy, node-selection compatibility rules, param precedence, or managed workload URLs inside `frontend-modern/src/components/Workloads/useWorkloadsState.ts`
+25 -19
View File
@@ -1,37 +1,43 @@
{
"version": 1,
"base_sha": "501575524519294805e98b47dd3f0b746df8d175",
"verified_at": "2026-08-11T18:56:00Z",
"base_sha": "cea33b8ee29255035d7d248704b80428761085c6",
"verified_at": "2026-08-11T19:40:09Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/api/settings.ts",
"frontend-modern/src/components/Settings/useSystemSettingsState.ts"
"frontend-modern/src/components/Workloads/diskListModel.ts",
"frontend-modern/src/components/Workloads/stackedDiskBarModel.ts",
"frontend-modern/src/features/standalone/agentMachineTableModel.ts",
"frontend-modern/src/utils/format.ts"
],
"content_sha256": {
"frontend-modern/src/api/settings.ts": "160fdba69870f19682cb234eb582f0f6ea6fb83ef67e0ca3662d2ac0da4b4325",
"frontend-modern/src/components/Settings/useSystemSettingsState.ts": "6e3fc2e3ea80458b30178463fea8f74ffff3498e992c6aa38578daa684653a99"
"frontend-modern/src/components/Workloads/diskListModel.ts": "82dd17c7d9ba7c5ee103db375d11748df245ecf843ab56505f9e4fa6d21f0b81",
"frontend-modern/src/components/Workloads/stackedDiskBarModel.ts": "a72196fdc5c75bdd40ad5c45f1934e06d05ebdd4a89f68f6e932a12f97383b78",
"frontend-modern/src/features/standalone/agentMachineTableModel.ts": "fe5a0b14cc9324ac9d6197295a6f201fe59c6d22fd66437b22aeb4b567b316cf",
"frontend-modern/src/utils/format.ts": "8c09d25938796573232752d5e326055712c784dc906731f110188804a09b2114"
},
"routes": ["/settings/system-general", "/docker/containers"],
"routes": [
"/proxmox"
],
"viewports": [
{
"width": 1440,
"height": 1000
"width": 1280,
"height": 800
},
{
"width": 390,
"height": 844
"width": 768,
"height": 900
}
],
"states": [
"The running mock backend had Docker update actions disabled and outbound usage telemetry disabled, while the complete admin settings request was deliberately answered with 403 to exercise the viewer fallback.",
"System General reflected Hide update buttons as enabled and Outbound usage telemetry as disabled from the authenticated runtime projection at desktop and narrow widths.",
"Docker Containers rendered no Update or Update all action buttons while the global hide policy was active.",
"System General had no document or body horizontal overflow at either viewport."
"mock-mode LXC edge-proxy-01 with a measured rootfs plus a config-only mount (/srv/archive, usage -1, total 20 GiB) in the shared workloads table",
"workloads row disk cell: single measured bar (37% target), no fabricated 0% mini-slot for the config-only mount, at both viewports",
"row disk tooltip: Disk Breakdown lists / at 37% (21.9 GB/59.0 GB) and /srv/archive as — with ?/20.0 GB",
"guest drawer Filesystems block: rootfs card 21.9 GB/59.0 GB 37% ROOTFS and /srv/archive card ?/20.0 GB — MP0 at both viewports"
],
"interactions": [
"Started the current managed mock runtime and persisted disableDockerUpdateActions=true plus telemetryEnabled=false through the live settings API.",
"Intercepted only GET /api/system/settings with the expected 403 refusal, then opened System General through the current browser build and confirmed the fallback values from GET /api/runtime/display.",
"Inspected full-page desktop and narrow screenshots and measured document and body overflow.",
"Navigated to Docker Containers at both viewports and confirmed the shared global policy left zero Update or Update all buttons."
"searched the Proxmox workloads table for edge-proxy-01 at 1280x800",
"hovered the row disk cell to open the Disk Breakdown tooltip and read both entries",
"clicked the row to expand the guest drawer and read the Filesystems block",
"resized to 768x900, reloaded /proxmox, re-filtered to edge-proxy-01, re-opened the drawer and re-read the disk cell and Filesystems block"
]
}
@@ -281,6 +281,42 @@ describe('DiskList', () => {
});
});
describe('config-only mounts (usage unknown sentinel)', () => {
it('shows capacity with "?" used marker when usage is -1 and total is known', () => {
const disk = makeDisk({
mountpoint: '/data',
type: 'mp0',
used: 0,
free: 0,
usage: -1,
});
render(() => <DiskList disks={[disk]} />);
expect(screen.getByText('?/100 GB')).toBeInTheDocument();
});
it('shows dash instead of a percent when usage is -1 despite known capacity', () => {
const disk = makeDisk({ used: 0, free: 0, usage: -1 });
render(() => <DiskList disks={[disk]} />);
expect(screen.getByText('—')).toBeInTheDocument();
expect(screen.queryByText('0%')).not.toBeInTheDocument();
});
it('keeps the progress bar empty when usage is -1', () => {
const disk = makeDisk({ used: 53687091200, usage: -1 });
const { container } = render(() => <DiskList disks={[disk]} />);
const bar = getBarFill(container);
expect(bar).toBeInTheDocument();
expect(bar).toHaveStyle({ width: '0%' });
});
it('shows "Usage unavailable" when usage is -1 and total is unknown', () => {
const disk = makeDisk({ total: 0, used: 0, free: 0, usage: -1 });
render(() => <DiskList disks={[disk]} />);
expect(screen.getByText('Usage unavailable')).toBeInTheDocument();
});
});
describe('disk type display', () => {
it('builds disk presentation through the canonical disk-list model', () => {
const presentation = buildWorkloadsDiskPresentation(
@@ -349,27 +349,31 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
it('builds vertical bars clamped to 0..100 and the vertical-bars container class', () => {
// verticalBarsMode = mode === 'vertical-bars' && hasDisks; fillPercent uses
// Math.max(0, Math.min(percent, 100)) -> exercises both clamps.
// Math.max(0, Math.min(percent, 100)) -> exercises the upper clamp. A
// negative usage is the unknown-usage sentinel: excluded from the bars,
// still listed in the tooltip.
const p = buildStackedDiskBarPresentation(
{
mode: 'vertical-bars',
disks: [
makeDisk({ mountpoint: '/x', total: 100, used: 40, free: 60, usage: 40 }),
makeDisk({ mountpoint: '/y', total: 100, used: 120, free: 0, usage: 120 }),
makeDisk({ mountpoint: '/z', total: 100, used: -5, free: 105, usage: -5 }),
makeDisk({ mountpoint: '/z', total: 100, used: 0, free: 0, usage: -5 }),
],
},
400,
);
expect(p.verticalBarsMode).toBe(true);
expect(p.containerClass).toBe('metric-text w-full h-4 min-w-0');
expect(p.verticalBars.map((b) => b.fillPercent)).toEqual([40, 100, 0]);
expect(p.verticalBars.map((b) => b.fillPercent)).toEqual([40, 100]);
expect(p.verticalBars[0]).toStrictEqual({
color: NORMAL,
fillPercent: 40,
title: '/x: 40% (40.0 B/100 B)',
});
expect(p.verticalBars[1].color).toBe(CRITICAL); // 120% -> critical
expect(p.tooltipContent).toHaveLength(3);
expect(p.tooltipContent[2]).toMatchObject({ label: '/z', percent: '—', used: '?' });
});
it('enables inline disk mode and its container class in mini mode', () => {
@@ -469,5 +473,46 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
expect(p.showSublabel).toBe(false);
expect(p.displaySublabel).toBe('25.0 B/100 B');
});
it('excludes config-only mounts (usage -1) from usage visuals but lists them in the tooltip', () => {
// A container with a measured rootfs plus a mount known only from the
// guest config (usage -1, capacity from size=). The unknown mount must
// not get a bar slot or skew the summary math, but stays discoverable
// in the tooltip with capacity and no fabricated 0%.
const p = buildStackedDiskBarPresentation(
{
disks: [
makeDisk({ mountpoint: '/', total: 100, used: 50, free: 50, usage: 50 }),
makeDisk({ mountpoint: '/srv/archive', total: 200, used: 0, free: 0, usage: -1 }),
],
},
400,
);
expect(p.hasMultipleDisks).toBe(false); // only the measured disk counts
expect(p.inlineDiskMode).toBe(false); // no "archive 0%" mini slot
expect(p.miniDisks.map((d) => d.label)).toEqual(['/']);
expect(p.displayPercentValue).toBe(50); // unknown capacity not in the denominator
expect(p.tooltipContent).toHaveLength(2);
expect(p.tooltipContent[1]).toMatchObject({
label: '/srv/archive',
percent: '—',
used: '?',
total: '200 B',
});
});
it('falls back to the aggregate disk when every listed disk has unknown usage', () => {
const p = buildStackedDiskBarPresentation(
{
disks: [makeDisk({ mountpoint: '/srv/archive', total: 200, used: 0, free: 0, usage: -1 })],
aggregateDisk: makeDisk({ total: 100, used: 25, free: 75, usage: 25 }),
},
400,
);
expect(p.hasDisks).toBe(false);
expect(p.displayPercentValue).toBe(25);
expect(p.tooltipContent).toHaveLength(1);
expect(p.tooltipContent[0]).toMatchObject({ label: '/srv/archive', percent: '—' });
});
});
});
@@ -24,6 +24,11 @@ export interface WorkloadsDiskPresentation {
export const hasWorkloadsDiskCapacity = (disk: Disk): boolean =>
typeof disk.total === 'number' && disk.total > 0;
// The poller reports usage -1 for mounts it can only see in the container
// config (capacity may be known, live usage is not).
export const isWorkloadsDiskUsageUnknown = (disk: Disk): boolean =>
typeof disk.usage === 'number' && disk.usage < 0;
export const getWorkloadsDiskUsagePercent = (disk: Disk): number => {
const total = disk.total ?? 0;
if (total <= 0) {
@@ -39,13 +44,21 @@ export const getWorkloadsDiskLabel = (disk: Disk): string =>
export const getWorkloadsDiskLabelTitle = (label: string): string | undefined =>
label !== 'Unknown' ? label : undefined;
export const getWorkloadsDiskUsageText = (disk: Disk): string =>
hasWorkloadsDiskCapacity(disk)
export const getWorkloadsDiskUsageText = (disk: Disk): string => {
if (isWorkloadsDiskUsageUnknown(disk)) {
return hasWorkloadsDiskCapacity(disk)
? `?/${formatBytes(disk.total ?? 0)}`
: 'Usage unavailable';
}
return hasWorkloadsDiskCapacity(disk)
? `${formatBytes(disk.used ?? 0)}/${formatBytes(disk.total ?? 0)}`
: 'Usage unavailable';
};
export const getWorkloadsDiskUsagePercentLabel = (disk: Disk): string =>
hasWorkloadsDiskCapacity(disk) ? `${getWorkloadsDiskUsagePercent(disk).toFixed(0)}%` : '—';
hasWorkloadsDiskCapacity(disk) && !isWorkloadsDiskUsageUnknown(disk)
? `${getWorkloadsDiskUsagePercent(disk).toFixed(0)}%`
: '—';
export const getWorkloadsDiskProgressClass = (
disk: Disk,
@@ -53,7 +66,9 @@ export const getWorkloadsDiskProgressClass = (
): string => getMetricColorClass(getWorkloadsDiskUsagePercent(disk), 'disk', thresholds);
export const getWorkloadsDiskProgressWidth = (disk: Disk): string =>
`${Math.min(getWorkloadsDiskUsagePercent(disk), 100)}%`;
isWorkloadsDiskUsageUnknown(disk)
? '0%'
: `${Math.min(getWorkloadsDiskUsagePercent(disk), 100)}%`;
export const getWorkloadsDiskTypeLabel = (disk: Disk): string => disk.type?.toUpperCase() ?? '';
@@ -111,6 +111,15 @@ function getDiskUsagePercent(disk: Disk): number {
return 0;
}
// The poller reports usage -1 for mounts it only knows from the guest config
// (capacity may be known, live usage is not). Those cannot be drawn as usage
// bars; they surface in the tooltip instead.
export function isStackedDiskUsageUnknown(disk: Disk): boolean {
return typeof disk.usage === 'number' && disk.usage < 0;
}
const UNKNOWN_DISK_COLOR = 'rgba(148, 163, 184, 0.5)';
function getDiskLabel(disk: Disk, index: number): string {
return disk.mountpoint || disk.device || `Disk ${index + 1}`;
}
@@ -184,6 +193,15 @@ function buildTooltipContent(
options.aggregateMode || options.inlineDiskMode || options.miniMode || options.verticalBarsMode;
if (disks.length > 0) {
return disks.map((disk, index) => {
if (isStackedDiskUsageUnknown(disk)) {
return {
color: UNKNOWN_DISK_COLOR,
label: getDiskLabel(disk, index),
percent: '—',
total: (disk.total ?? 0) > 0 ? formatBytes(disk.total ?? 0) : '—',
used: '?',
};
}
const percentValue = getDiskUsagePercent(disk);
return {
color: useUsageColors
@@ -237,7 +255,10 @@ export function buildStackedDiskBarPresentation(
props: StackedDiskBarProps,
containerWidth: number,
): StackedDiskBarPresentation {
const disks = props.disks ?? [];
const allDisks = props.disks ?? [];
// Usage visualisations only draw disks with measured usage; config-only
// mounts (usage -1) stay listed in the tooltip.
const disks = allDisks.filter((disk) => !isStackedDiskUsageUnknown(disk));
const hasDisks = disks.length > 0;
const hasMultipleDisks = disks.length > 1;
const aggregateMode = props.mode === 'aggregate';
@@ -338,7 +359,7 @@ export function buildStackedDiskBarPresentation(
};
})
: [];
const tooltipContent = buildTooltipContent(disks, {
const tooltipContent = buildTooltipContent(allDisks, {
aggregateDisk: props.aggregateDisk,
aggregateMode,
inlineDiskMode,
@@ -378,7 +399,7 @@ export function buildStackedDiskBarPresentation(
showMaxLabel,
showSublabel,
tooltipContent,
tooltipTitle: hasMultipleDisks ? 'Disk Breakdown' : 'Disk Usage',
tooltipTitle: allDisks.length > 1 ? 'Disk Breakdown' : 'Disk Usage',
useStackedSegments,
verticalBars,
verticalBarsMode,
@@ -202,15 +202,18 @@ const getDiskUsagePercent = (disk: {
used?: number;
usage?: number;
}): number | undefined => {
const reportedUsage = getPlatformTableFiniteMetric(disk.usage);
// usage < 0 is the poller's "usage unknown" sentinel — no percent to report.
if (reportedUsage !== undefined && reportedUsage < 0) return undefined;
const total = getPlatformTableFiniteMetric(disk.total);
const used = getPlatformTableFiniteMetric(disk.used);
if (total && total > 0 && typeof used === 'number') {
return (used / total) * 100;
}
const usage = getPlatformTableFiniteMetric(disk.usage);
if (usage === undefined) return undefined;
return usage <= 1 ? usage * 100 : usage;
if (reportedUsage === undefined) return undefined;
return reportedUsage <= 1 ? reportedUsage * 100 : reportedUsage;
};
const getMaxOperationalDiskPercent = (machine: Resource): number | undefined => {
@@ -152,6 +152,16 @@ describe('normalizeDiskArray', () => {
expect(result?.[0].usage).toBe(0);
});
it('preserves the unknown-usage sentinel instead of computing a percent', () => {
// Config-only LXC mounts (#1477) arrive with capacity but usage -1.
const result = normalizeDiskArray([
{ mountpoint: '/srv/archive', filesystem: 'mp0', total: 1000, usage: -1 },
]);
expect(result?.[0].usage).toBe(-1);
expect(result?.[0].total).toBe(1000);
expect(result?.[0].used).toBe(0);
});
it('uses filesystem type when available', () => {
const result = normalizeDiskArray([{ device: '/dev/sda', filesystem: 'ext4' }]);
expect(result?.[0].type).toBe('ext4');
+9 -3
View File
@@ -10,6 +10,7 @@ type DiskInput = {
total?: number;
used?: number;
free?: number;
usage?: number;
};
const NON_OPERATIONAL_DISK_MOUNT_PREFIXES = [
@@ -340,7 +341,11 @@ export function normalizeDiskArray(disks?: DiskInput[]): NormalizedDisk[] | unde
const total = d.total ?? 0;
const used = d.used ?? 0;
const free = d.free ?? (total > 0 ? Math.max(0, total - used) : 0);
const usage = total > 0 ? (used / total) * 100 : 0;
// usage < 0 is the poller's "usage unknown" sentinel (e.g. LXC mounts
// known only from the guest config, #1477) — preserve it rather than
// fabricating a computed percent.
const usage =
typeof d.usage === 'number' && d.usage < 0 ? -1 : total > 0 ? (used / total) * 100 : 0;
return {
total,
used,
@@ -368,8 +373,9 @@ export function getResourceDiskSummary(
};
}
const disks = normalizeDiskArray(resource.agent?.disks);
if (!disks) return undefined;
// Disks with unknown usage (sentinel -1) have no measured used/free to sum.
const disks = normalizeDiskArray(resource.agent?.disks)?.filter((disk) => disk.usage >= 0);
if (!disks || disks.length === 0) return undefined;
const total = disks.reduce((sum, disk) => sum + disk.total, 0);
if (total <= 0) return undefined;
+39
View File
@@ -436,6 +436,44 @@ func ensureFreshFilesystemFixture(data *models.StateSnapshot) {
}
}
// ensureConfigOnlyMountFixture guarantees at least one running container
// carries a mount known only from its config: capacity from the size
// parameter, live usage unavailable (Usage -1). Stock Proxmox reports no
// per-mount usage through the status API, so API-polled containers surface
// their mpX mounts in exactly this shape (#1477). The rootfs entry is seeded
// from the aggregate the same way the poller's ensureContainerRootDiskEntry
// does when only aggregate stats exist.
func ensureConfigOnlyMountFixture(data *models.StateSnapshot) {
const archiveBytes = int64(20) * 1024 * 1024 * 1024
for i := range data.Containers {
ct := &data.Containers[i]
if ct.Status != "running" {
continue
}
if len(ct.Disks) == 0 {
if ct.Disk.Total <= 0 {
continue
}
ct.Disks = []models.Disk{{
Total: ct.Disk.Total,
Used: ct.Disk.Used,
Free: ct.Disk.Free,
Usage: ct.Disk.Usage,
Mountpoint: "/",
Type: "rootfs",
}}
}
ct.Disks = append(ct.Disks, models.Disk{
Total: archiveBytes,
Usage: -1,
Mountpoint: "/srv/archive",
Type: "mp0",
Device: fmt.Sprintf("tank:subvol-%d-disk-1", ct.VMID),
})
return
}
}
// buildFixtureState synthesizes the snapshot-backed portion of the canonical
// fixture graph for demo and test environments.
func buildFixtureState(config MockConfig) models.StateSnapshot {
@@ -602,6 +640,7 @@ func buildFixtureState(config MockConfig) models.StateSnapshot {
data.ReplicationJobs = generateReplicationJobs(data.Nodes, data.VMs)
ensureFreshFilesystemFixture(&data)
ensureConfigOnlyMountFixture(&data)
// Calculate stats
data.Stats.StartTime = time.Now()
+45
View File
@@ -848,3 +848,48 @@ func TestMemoryTotalForResourceTracksFixtureCapacity(t *testing.T) {
t.Fatalf("blank guest capacity = %f, want 0", got)
}
}
func TestEnsureConfigOnlyMountFixtureSeedsUnknownUsageMount(t *testing.T) {
data := buildFixtureState(DefaultConfig)
var fixtureContainer *models.Container
for i := range data.Containers {
for _, disk := range data.Containers[i].Disks {
if disk.Mountpoint == "/srv/archive" {
fixtureContainer = &data.Containers[i]
}
}
}
if fixtureContainer == nil {
t.Fatal("no container carries the config-only mount fixture (#1477)")
}
if fixtureContainer.Status != "running" {
t.Fatalf("fixture container %s is %s, want running", fixtureContainer.Name, fixtureContainer.Status)
}
var rootfs, archive *models.Disk
for i := range fixtureContainer.Disks {
disk := &fixtureContainer.Disks[i]
switch disk.Mountpoint {
case "/":
rootfs = disk
case "/srv/archive":
archive = disk
}
}
if rootfs == nil || rootfs.Total <= 0 || rootfs.Usage < 0 {
t.Fatalf("fixture container must keep a measured rootfs row, got %+v", rootfs)
}
if archive == nil {
t.Fatal("config-only mount row missing")
}
if archive.Usage != -1 {
t.Fatalf("config-only mount usage = %f, want the -1 unknown sentinel", archive.Usage)
}
if archive.Total != int64(20)*1024*1024*1024 {
t.Fatalf("config-only mount total = %d, want the configured 20 GiB", archive.Total)
}
if archive.Type != "mp0" || archive.Device == "" {
t.Fatalf("config-only mount must keep its mp key and device, got %+v", archive)
}
}
+199 -52
View File
@@ -3,7 +3,9 @@ package monitoring
import (
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"strings"
"unicode"
@@ -23,6 +25,7 @@ type containerMountMetadata struct {
Key string
Mountpoint string
Source string
Size int64
}
// ensureContainerRootDiskEntry adds a root disk entry to a container if none exists.
@@ -60,66 +63,93 @@ func ensureContainerRootDiskEntry(container *models.Container) {
}
// convertContainerDiskInfo converts Proxmox container disk info to the models format.
// Mount points that only exist in the container config (the normal case — stock
// Proxmox reports no per-mount usage through the status API) are surfaced with
// the configured capacity and a -1 usage sentinel so they still appear in the UI.
func convertContainerDiskInfo(status *proxmox.Container, metadata map[string]containerMountMetadata) []models.Disk {
if status == nil || len(status.DiskInfo) == 0 {
return nil
disks := make([]models.Disk, 0)
seen := make(map[string]struct{})
if status != nil && len(status.DiskInfo) > 0 {
for name, info := range status.DiskInfo {
total := clampToInt64(info.Total)
used := clampToInt64(info.Used)
if total > 0 && used > total {
used = total
}
free := total - used
if free < 0 {
free = 0
}
disk := models.Disk{
Total: total,
Used: used,
Free: free,
}
if total > 0 {
disk.Usage = safePercentage(float64(used), float64(total))
}
label := strings.TrimSpace(name)
lowerLabel := strings.ToLower(label)
metadataKey := lowerLabel
if strings.EqualFold(label, "rootfs") || label == "" {
metadataKey = "rootfs"
}
seen[metadataKey] = struct{}{}
mountpoint := ""
device := ""
if metadata != nil {
if meta, ok := metadata[metadataKey]; ok {
mountpoint = strings.TrimSpace(meta.Mountpoint)
device = strings.TrimSpace(meta.Source)
}
}
if strings.EqualFold(label, "rootfs") || label == "" {
if mountpoint == "" {
mountpoint = "/"
}
disk.Type = "rootfs"
if device == "" {
device = sanitizeRootFSDevice(status.RootFS)
}
} else {
if mountpoint == "" {
mountpoint = label
}
disk.Type = lowerLabel
}
disk.Mountpoint = mountpoint
if disk.Device == "" && device != "" {
disk.Device = device
}
disks = append(disks, disk)
}
}
disks := make([]models.Disk, 0, len(status.DiskInfo))
for name, info := range status.DiskInfo {
total := clampToInt64(info.Total)
used := clampToInt64(info.Used)
if total > 0 && used > total {
used = total
}
free := total - used
if free < 0 {
free = 0
if len(metadata) > 0 {
keys := make([]string, 0, len(metadata))
for key := range metadata {
keys = append(keys, key)
}
sort.Strings(keys)
disk := models.Disk{
Total: total,
Used: used,
Free: free,
}
if total > 0 {
disk.Usage = safePercentage(float64(used), float64(total))
}
label := strings.TrimSpace(name)
lowerLabel := strings.ToLower(label)
mountpoint := ""
device := ""
if metadata != nil {
if meta, ok := metadata[lowerLabel]; ok {
mountpoint = strings.TrimSpace(meta.Mountpoint)
device = strings.TrimSpace(meta.Source)
for _, key := range keys {
if _, ok := seen[key]; ok {
continue
}
}
if strings.EqualFold(label, "rootfs") || label == "" {
if mountpoint == "" {
mountpoint = "/"
meta := metadata[key]
if strings.TrimSpace(meta.Key) == "" {
meta.Key = key
}
disk.Type = "rootfs"
if device == "" {
device = sanitizeRootFSDevice(status.RootFS)
}
} else {
if mountpoint == "" {
mountpoint = label
}
disk.Type = lowerLabel
disks = append(disks, diskFromContainerMountMetadata(meta))
}
disk.Mountpoint = mountpoint
if disk.Device == "" && device != "" {
disk.Device = device
}
disks = append(disks, disk)
}
if len(disks) > 1 {
@@ -128,9 +158,87 @@ func convertContainerDiskInfo(status *proxmox.Container, metadata map[string]con
})
}
if len(disks) == 0 {
return nil
}
return disks
}
// diskFromContainerMountMetadata builds a disk entry for a mount point known
// only from the container config. Capacity comes from the config's size
// parameter when present; live usage is unavailable, signalled by Usage -1.
func diskFromContainerMountMetadata(meta containerMountMetadata) models.Disk {
diskType := strings.ToLower(strings.TrimSpace(meta.Key))
if diskType == "" {
diskType = "rootfs"
}
mountpoint := strings.TrimSpace(meta.Mountpoint)
if mountpoint == "" {
if diskType == "rootfs" {
mountpoint = "/"
} else {
mountpoint = diskType
}
}
disk := models.Disk{
Mountpoint: mountpoint,
Device: strings.TrimSpace(meta.Source),
Type: diskType,
Usage: -1,
}
if meta.Size > 0 {
disk.Total = meta.Size
}
return disk
}
// mergeContainerDisksPreservingExisting appends discovered disks to the
// existing list, keeping the existing entry when both describe the same mount.
func mergeContainerDisksPreservingExisting(existing, discovered []models.Disk) []models.Disk {
if len(existing) == 0 {
return discovered
}
if len(discovered) == 0 {
return existing
}
merged := append([]models.Disk{}, existing...)
seen := make(map[string]struct{}, len(existing))
for _, disk := range existing {
seen[containerDiskIdentity(disk)] = struct{}{}
}
for _, disk := range discovered {
key := containerDiskIdentity(disk)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
merged = append(merged, disk)
}
if len(merged) > 1 {
sort.SliceStable(merged, func(i, j int) bool {
return merged[i].Mountpoint < merged[j].Mountpoint
})
}
return merged
}
func containerDiskIdentity(disk models.Disk) string {
if mountpoint := strings.ToLower(strings.TrimSpace(disk.Mountpoint)); mountpoint != "" {
return "mount:" + mountpoint
}
if diskType := strings.ToLower(strings.TrimSpace(disk.Type)); diskType != "" {
return "type:" + diskType
}
return "device:" + strings.ToLower(strings.TrimSpace(disk.Device))
}
// sanitizeRootFSDevice extracts the device path from a rootfs config string.
func sanitizeRootFSDevice(root string) string {
root = strings.TrimSpace(root)
@@ -364,6 +472,8 @@ func parseContainerMountMetadata(config map[string]interface{}) map[string]conta
switch k {
case "mp", "mountpoint":
meta.Mountpoint = v
case "size":
meta.Size = parseProxmoxVolumeSize(v)
}
}
@@ -381,6 +491,43 @@ func parseContainerMountMetadata(config map[string]interface{}) map[string]conta
return results
}
// parseProxmoxVolumeSize parses a Proxmox volume size parameter (e.g. "8G",
// "512M") into bytes. Proxmox writes sizes as a number with an optional binary
// K/M/G/T suffix; a bare number is bytes. Returns 0 when the value cannot be
// parsed.
func parseProxmoxVolumeSize(value string) int64 {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
multiplier := float64(1)
switch value[len(value)-1] {
case 'K', 'k':
multiplier = 1 << 10
value = value[:len(value)-1]
case 'M', 'm':
multiplier = 1 << 20
value = value[:len(value)-1]
case 'G', 'g':
multiplier = 1 << 30
value = value[:len(value)-1]
case 'T', 't':
multiplier = 1 << 40
value = value[:len(value)-1]
}
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed <= 0 {
return 0
}
bytes := parsed * multiplier
if bytes > math.MaxInt64 {
return 0
}
return int64(math.Round(bytes))
}
// mergeContainerNetworkInterface merges network interface details into the target slice.
func mergeContainerNetworkInterface(target *[]models.GuestNetworkInterface, detail containerNetworkDetails) {
if target == nil {
+139 -1
View File
@@ -583,6 +583,7 @@ func TestParseContainerMountMetadataEdgeCases(t *testing.T) {
Key: "rootfs",
Mountpoint: "/",
Source: "local:100/vm-100-disk-0.raw",
Size: 8589934592,
},
},
},
@@ -642,6 +643,7 @@ func TestParseContainerMountMetadataEdgeCases(t *testing.T) {
Key: "rootfs",
Mountpoint: "/",
Source: "local:100/vm-100-disk-0.raw",
Size: 8589934592,
},
},
},
@@ -655,6 +657,7 @@ func TestParseContainerMountMetadataEdgeCases(t *testing.T) {
Key: "mp1",
Mountpoint: "",
Source: "local:volume",
Size: 10737418240,
},
},
},
@@ -669,6 +672,7 @@ func TestParseContainerMountMetadataEdgeCases(t *testing.T) {
Key: "rootfs",
Mountpoint: "/",
Source: "local:disk",
Size: 8589934592,
},
"mp0": {
Key: "mp0",
@@ -1073,6 +1077,90 @@ func TestConvertContainerDiskInfo(t *testing.T) {
},
},
},
{
name: "metadata mount missing from status is retained as unavailable",
status: &proxmox.Container{
DiskInfo: map[string]proxmox.ContainerDiskUsage{
"rootfs": {
Total: 8589934592,
Used: 4294967296,
},
},
},
metadata: map[string]containerMountMetadata{
"mp0": {
Key: "mp0",
Mountpoint: "/data",
Source: "local-lvm:vm-100-disk-1",
Size: 10737418240,
},
"rootfs": {
Key: "rootfs",
Mountpoint: "/",
Source: "local-lvm:vm-100-disk-0",
},
},
want: []models.Disk{
{
Total: 8589934592,
Used: 4294967296,
Free: 4294967296,
Usage: 50.0,
Mountpoint: "/",
Type: "rootfs",
Device: "local-lvm:vm-100-disk-0",
},
{
Total: 10737418240,
Usage: -1,
Mountpoint: "/data",
Type: "mp0",
Device: "local-lvm:vm-100-disk-1",
},
},
},
{
name: "metadata only disks are surfaced with unavailable usage",
status: &proxmox.Container{
DiskInfo: map[string]proxmox.ContainerDiskUsage{},
},
metadata: map[string]containerMountMetadata{
"mp0": {
Key: "mp0",
Mountpoint: "/mnt/media",
Source: "/mnt/pve/media/subvol-100-disk-1",
},
},
want: []models.Disk{
{
Usage: -1,
Mountpoint: "/mnt/media",
Type: "mp0",
Device: "/mnt/pve/media/subvol-100-disk-1",
},
},
},
{
name: "nil status with metadata still lists mounts",
status: nil,
metadata: map[string]containerMountMetadata{
"mp0": {
Key: "mp0",
Mountpoint: "/srv/backup",
Source: "tank:subvol-101-disk-1",
Size: 21474836480,
},
},
want: []models.Disk{
{
Total: 21474836480,
Usage: -1,
Mountpoint: "/srv/backup",
Type: "mp0",
Device: "tank:subvol-101-disk-1",
},
},
},
{
name: "multiple disks sorted by mountpoint",
status: &proxmox.Container{
@@ -1386,6 +1474,56 @@ func TestConvertContainerDiskInfo(t *testing.T) {
}
}
func TestMergeContainerDisksPreservingExisting(t *testing.T) {
t.Parallel()
existing := []models.Disk{
{Mountpoint: "/", Type: "rootfs", Total: 1000, Used: 500, Free: 500, Usage: 50},
}
discovered := []models.Disk{
{Mountpoint: "/", Type: "rootfs", Usage: -1},
{Mountpoint: "/data", Type: "mp0", Device: "local:vm-100-disk-1", Total: 2000, Usage: -1},
}
got := mergeContainerDisksPreservingExisting(existing, discovered)
want := []models.Disk{
{Mountpoint: "/", Type: "rootfs", Total: 1000, Used: 500, Free: 500, Usage: 50},
{Mountpoint: "/data", Type: "mp0", Device: "local:vm-100-disk-1", Total: 2000, Usage: -1},
}
if !diskSlicesEqual(got, want) {
t.Fatalf("mergeContainerDisksPreservingExisting() = %+v, want %+v", got, want)
}
}
func TestParseProxmoxVolumeSize(t *testing.T) {
t.Parallel()
tests := []struct {
input string
want int64
}{
{"8G", 8589934592},
{"512M", 536870912},
{"10k", 10240},
{"1T", 1099511627776},
{"19.5G", 20937965568},
{"100", 100},
{" 8G ", 8589934592},
{"", 0},
{"0", 0},
{"-5G", 0},
{"abc", 0},
{"G", 0},
}
for _, tt := range tests {
if got := parseProxmoxVolumeSize(tt.input); got != tt.want {
t.Errorf("parseProxmoxVolumeSize(%q) = %d, want %d", tt.input, got, tt.want)
}
}
}
func TestEnsureContainerRootDiskEntry(t *testing.T) {
t.Parallel()
@@ -1665,7 +1803,7 @@ func mountMetadataMapsEqual(a, b map[string]containerMountMetadata) bool {
if !ok {
return false
}
if valA.Key != valB.Key || valA.Mountpoint != valB.Mountpoint || valA.Source != valB.Source {
if valA.Key != valB.Key || valA.Mountpoint != valB.Mountpoint || valA.Source != valB.Source || valA.Size != valB.Size {
return false
}
}
+7 -1
View File
@@ -353,7 +353,13 @@ func (m *Monitor) enrichContainerMetadata(ctx context.Context, client PVEClientI
}
if disks := convertContainerDiskInfo(status, mountMetadata); len(disks) > 0 {
container.Disks = disks
if (status == nil || len(status.DiskInfo) == 0) && len(container.Disks) > 0 {
// Only config-derived entries were discovered; keep the seeded
// rootfs entry, which carries live aggregate usage.
container.Disks = mergeContainerDisksPreservingExisting(container.Disks, disks)
} else {
container.Disks = disks
}
}
ensureContainerRootDiskEntry(container)
@@ -337,3 +337,59 @@ func TestIssue1634LXCMemoryStaysUnavailableWithoutListingValue(t *testing.T) {
t.Fatal("expected memory to stay marked unavailable without any usage evidence")
}
}
func TestIssue1477ConfigOnlyMountsSurviveIntoContainerDisks(t *testing.T) {
t.Parallel()
// Stock PVE reports no per-mount usage through the LXC status API, so the
// only record of an mpX mount is the container config. The API path must
// still surface it: capacity from size=, usage unknown (-1), and the
// aggregate-seeded rootfs row (live usage) must survive the merge.
metadata := parseContainerMountMetadata(map[string]interface{}{
"rootfs": "local-lvm:vm-106-disk-0,size=59G",
"mp0": "tank:subvol-106-disk-1,mp=/srv/archive,size=20G",
})
discovered := convertContainerDiskInfo(nil, metadata)
if len(discovered) != 2 {
t.Fatalf("discovered disks = %+v, want rootfs + mp0", discovered)
}
seededRootfs := models.Disk{
Total: 63350767616,
Used: 23530764893,
Free: 39820002723,
Usage: 37.14,
Mountpoint: "/",
Type: "rootfs",
}
merged := mergeContainerDisksPreservingExisting([]models.Disk{seededRootfs}, discovered)
if len(merged) != 2 {
t.Fatalf("merged disks = %+v, want rootfs + mp0", merged)
}
var rootfs, archive *models.Disk
for i := range merged {
switch merged[i].Mountpoint {
case "/":
rootfs = &merged[i]
case "/srv/archive":
archive = &merged[i]
}
}
if rootfs == nil || rootfs.Used != seededRootfs.Used || rootfs.Usage != seededRootfs.Usage {
t.Fatalf("live rootfs row must win the merge, got %+v", rootfs)
}
if archive == nil {
t.Fatal("config-only mount dropped by the merge")
}
if archive.Usage != -1 {
t.Fatalf("config-only mount usage = %f, want the -1 unknown sentinel", archive.Usage)
}
if archive.Total != int64(20)*1024*1024*1024 {
t.Fatalf("config-only mount total = %d, want 20 GiB from size=", archive.Total)
}
if archive.Device != "tank:subvol-106-disk-1" || archive.Type != "mp0" {
t.Fatalf("config-only mount must keep device and mp key, got %+v", archive)
}
}