diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index f79f26320..0764ddc98 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 55b3e2e74..c7e18c4bf 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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::` 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` diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 2aa407d78..fed067ea8 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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" ] } diff --git a/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx b/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx index a363ad538..99807094f 100644 --- a/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx @@ -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(() => ); + 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(() => ); + 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(() => ); + + 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(() => ); + expect(screen.getByText('Usage unavailable')).toBeInTheDocument(); + }); + }); + describe('disk type display', () => { it('builds disk presentation through the canonical disk-list model', () => { const presentation = buildWorkloadsDiskPresentation( diff --git a/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts b/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts index 528fdac43..522d54246 100644 --- a/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts @@ -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: '—' }); + }); }); }); diff --git a/frontend-modern/src/components/Workloads/diskListModel.ts b/frontend-modern/src/components/Workloads/diskListModel.ts index 1d3cf9e4c..771dc4478 100644 --- a/frontend-modern/src/components/Workloads/diskListModel.ts +++ b/frontend-modern/src/components/Workloads/diskListModel.ts @@ -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() ?? ''; diff --git a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts index f7d1f735c..7b2adc3f6 100644 --- a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts +++ b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts @@ -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, diff --git a/frontend-modern/src/features/standalone/agentMachineTableModel.ts b/frontend-modern/src/features/standalone/agentMachineTableModel.ts index f51f12353..905ff3560 100644 --- a/frontend-modern/src/features/standalone/agentMachineTableModel.ts +++ b/frontend-modern/src/features/standalone/agentMachineTableModel.ts @@ -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 => { diff --git a/frontend-modern/src/utils/__tests__/formatExtra.test.ts b/frontend-modern/src/utils/__tests__/formatExtra.test.ts index b56e23285..319ad23a3 100644 --- a/frontend-modern/src/utils/__tests__/formatExtra.test.ts +++ b/frontend-modern/src/utils/__tests__/formatExtra.test.ts @@ -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'); diff --git a/frontend-modern/src/utils/format.ts b/frontend-modern/src/utils/format.ts index df9675671..223157944 100644 --- a/frontend-modern/src/utils/format.ts +++ b/frontend-modern/src/utils/format.ts @@ -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; diff --git a/internal/mock/generator.go b/internal/mock/generator.go index a84f44a68..c1a717b98 100644 --- a/internal/mock/generator.go +++ b/internal/mock/generator.go @@ -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() diff --git a/internal/mock/generator_test.go b/internal/mock/generator_test.go index e60f3f401..10be0d4d0 100644 --- a/internal/mock/generator_test.go +++ b/internal/mock/generator_test.go @@ -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) + } +} diff --git a/internal/monitoring/container_parsing.go b/internal/monitoring/container_parsing.go index 708af50f9..4c0159115 100644 --- a/internal/monitoring/container_parsing.go +++ b/internal/monitoring/container_parsing.go @@ -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 { diff --git a/internal/monitoring/container_parsing_test.go b/internal/monitoring/container_parsing_test.go index 40afe2ea7..5e9fa5ca9 100644 --- a/internal/monitoring/container_parsing_test.go +++ b/internal/monitoring/container_parsing_test.go @@ -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 } } diff --git a/internal/monitoring/monitor_pve.go b/internal/monitoring/monitor_pve.go index 4c0d90a8b..7e552149e 100644 --- a/internal/monitoring/monitor_pve.go +++ b/internal/monitoring/monitor_pve.go @@ -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) diff --git a/internal/monitoring/monitor_pve_guest_lxc_test.go b/internal/monitoring/monitor_pve_guest_lxc_test.go index cdf95f7cf..88c55a2f6 100644 --- a/internal/monitoring/monitor_pve_guest_lxc_test.go +++ b/internal/monitoring/monitor_pve_guest_lxc_test.go @@ -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) + } +}