mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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`
|
||||
|
||||
@@ -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(
|
||||
|
||||
+48
-3
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user