From eab62de61c8f76f5eecc9277977f3aadb9a22743 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 3 Jun 2026 11:09:58 +0100 Subject: [PATCH] Improve Machines disk summaries --- .../subsystems/frontend-primitives.md | 6 +- .../subsystems/performance-and-scalability.md | 2 +- .../components/Workloads/StackedDiskBar.tsx | 4 +- .../__tests__/StackedDiskBar.test.tsx | 40 +++++++ .../Workloads/stackedDiskBarModel.ts | 29 ++++- .../standalone/AgentsMachinesTable.tsx | 4 +- .../__tests__/agentMachineTableModel.test.ts | 113 ++++++++++++++++++ .../standalone/agentMachineTableModel.ts | 29 +++++ .../src/utils/__tests__/formatExtra.test.ts | 49 ++++++++ frontend-modern/src/utils/format.ts | 9 ++ pkg/fsfilters/filters.go | 11 +- pkg/fsfilters/filters_test.go | 3 + 12 files changed, 289 insertions(+), 10 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index ff7043ed8..d46e51f11 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -3537,7 +3537,11 @@ the app shell must not add a separate top-level Availability destination. The Machines page must not pretend its machine list is a generic overview: the default tab is `Machines`, the Machines table is only for Pulse Agent-backed resources with host telemetry, and the full availability-check row list belongs -to the `Availability checks` tab. Servers, laptops, desktops, and comparable +to the `Availability checks` tab. Its default disk column is an operator risk +summary: it should surface the highest-usage operational filesystem for the +machine, keep platform plumbing out of the visible disk set, and leave the full +per-filesystem breakdown to hover/detail affordances rather than rendering the +row as a raw mount browser. Servers, laptops, desktops, and comparable computers monitored only by agentless reachability checks may use `targetKind=machine` in the availability form, but they stay in Availability checks until a Pulse Agent registers and supplies CPU, memory, disk, and network 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 416e6d4f3..92bfa0889 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -353,7 +353,7 @@ regression protection. Proxmox, and VMware pages expose filters for the objects visible on that page rather than generic mixed-workload labels. 19. Extend threshold-slider value-position math, title/label derivation, and drag scroll-lock runtime through `frontend-modern/src/components/Workloads/thresholdSliderModel.ts` and `frontend-modern/src/components/Workloads/useThresholdSliderState.ts` rather than rebuilding slider-local state and pointer lifecycle inside `frontend-modern/src/components/Workloads/ThresholdSlider.tsx` -20. Extend stacked disk-bar capacity math, segment/tooltip derivation, and resize-observer runtime through `frontend-modern/src/components/Workloads/stackedDiskBarModel.ts` and `frontend-modern/src/components/Workloads/useStackedDiskBarState.ts` rather than rebuilding disk-bar-local state, mode branching, and tooltip shaping inside `frontend-modern/src/components/Workloads/StackedDiskBar.tsx`. Compact multi-disk rows default to same-height per-disk lanes so each filesystem has its own visible usage bar without implying the whole host disk state is one max or aggregate percentage; explicit `mode="stacked"` remains the capacity-contribution stack for callers that intentionally need that presentation, while tooltips continue to carry the full per-disk breakdown. +20. Extend stacked disk-bar capacity math, segment/tooltip derivation, summary-strategy selection, and resize-observer runtime through `frontend-modern/src/components/Workloads/stackedDiskBarModel.ts` and `frontend-modern/src/components/Workloads/useStackedDiskBarState.ts` rather than rebuilding disk-bar-local state, mode branching, and tooltip shaping inside `frontend-modern/src/components/Workloads/StackedDiskBar.tsx`. Compact multi-disk rows default to same-height per-disk lanes so each filesystem has its own visible usage bar without implying the whole host disk state is one max or aggregate percentage; explicit `mode="stacked"` remains the capacity-contribution stack for callers that intentionally need that presentation, and explicit aggregate callers may choose total-capacity or worst-disk summaries when the owning table's operator job needs one compact risk signal, while tooltips continue to carry the full per-disk breakdown. 21. Extend stacked memory-bar capacity math, balloon/swap derivation, and resize-observer runtime through `frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts` and `frontend-modern/src/components/Workloads/useStackedMemoryBarState.ts` rather than rebuilding memory-bar-local state, tooltip shaping, and label-fit logic inside `frontend-modern/src/components/Workloads/StackedMemoryBar.tsx` 22. Extend metric-bar width, label-fit logic, and resize-observer runtime through `frontend-modern/src/components/Workloads/metricBarModel.ts` and `frontend-modern/src/components/Workloads/useMetricBarState.ts` rather than rebuilding metric-local state and threshold mapping inside `frontend-modern/src/components/Workloads/MetricBar.tsx` 23. Extend enhanced CPU bar usage/anomaly presentation and tooltip runtime through `frontend-modern/src/components/Workloads/enhancedCpuBarModel.ts` and `frontend-modern/src/components/Workloads/useEnhancedCPUBarState.ts` rather than rebuilding tooltip-local state and CPU-threshold formatting inside `frontend-modern/src/components/Workloads/EnhancedCPUBar.tsx` diff --git a/frontend-modern/src/components/Workloads/StackedDiskBar.tsx b/frontend-modern/src/components/Workloads/StackedDiskBar.tsx index d3ed90298..6c1f78d5f 100644 --- a/frontend-modern/src/components/Workloads/StackedDiskBar.tsx +++ b/frontend-modern/src/components/Workloads/StackedDiskBar.tsx @@ -128,10 +128,10 @@ export function StackedDiskBar(props: StackedDiskBarProps) { {' '} - [{props.disks?.length}] + {presentation().diskCountLabel} {/* Anomaly indicator */} diff --git a/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx b/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx index 596730d67..4cb135caa 100644 --- a/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx @@ -250,6 +250,46 @@ describe('StackedDiskBar', () => { const disk2 = makeDisk(); render(() => ); expect(screen.queryByText('[2]')).not.toBeInTheDocument(); + expect(screen.queryByText('(2)')).not.toBeInTheDocument(); + }); + + it('uses total usage as the default aggregate summary', () => { + const disk1 = makeDisk({ + used: 21474836480, // 20 GiB + total: 53687091200, // 50 GiB + }); + const disk2 = makeDisk({ + used: 53687091200, // 50 GiB + total: 107374182400, // 100 GiB + }); + render(() => ); + expect(screen.getByText('47%')).toBeInTheDocument(); + }); + + it('can summarize aggregate mode by the highest individual disk usage', () => { + const disk1 = makeDisk({ + used: Math.round(107374182400 * 0.2), + total: 107374182400, + mountpoint: '/data', + }); + const disk2 = makeDisk({ + used: Math.round(53687091200 * 0.92), + total: 53687091200, + mountpoint: '/var', + }); + const { container } = render(() => ( + + )); + const bar = getSingleBarFill(container); + expect(screen.getByText('92%')).toBeInTheDocument(); + expect(screen.getByText('(2)')).toHaveAttribute('title', '2 disks'); + expect(bar).toHaveAttribute('width', '92'); + expect(bar?.getAttribute('fill')).toContain('239, 68, 68'); }); }); diff --git a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts index fa3a282d8..6bc8e1c9c 100644 --- a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts +++ b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts @@ -14,6 +14,8 @@ export interface StackedDiskBarProps { disks?: Disk[]; aggregateDisk?: Disk; mode?: 'stacked' | 'aggregate' | 'mini' | 'vertical-bars'; + summaryStrategy?: 'total' | 'max'; + showDiskCount?: boolean; anomaly?: AnomalyReport | null; thresholds?: MetricDisplayThresholds | null; } @@ -53,6 +55,8 @@ export interface StackedDiskVerticalBar { export interface StackedDiskMaxInfo { label: string; percent: number; + total: number; + used: number; } export interface StackedDiskBarPresentation { @@ -63,6 +67,8 @@ export interface StackedDiskBarPresentation { barColor: string; barPercent: number; containerClass: string; + diskCountLabel: string; + diskCountTitle: string; displayLabel: string; displayPercentValue: number; displaySublabel: string; @@ -220,6 +226,8 @@ function getMaxDiskInfo(disks: Disk[]): StackedDiskMaxInfo | null { maxInfo = { label: getDiskLabel(disk, index), percent, + total: disk.total, + used: disk.used, }; } } @@ -234,6 +242,7 @@ export function buildStackedDiskBarPresentation( const hasDisks = disks.length > 0; const hasMultipleDisks = disks.length > 1; const aggregateMode = props.mode === 'aggregate'; + const summaryStrategy = props.summaryStrategy ?? 'total'; const miniMode = props.mode === 'mini'; const explicitStackedMode = props.mode === 'stacked'; const verticalBarsMode = props.mode === 'vertical-bars' && hasDisks; @@ -258,21 +267,31 @@ export function buildStackedDiskBarPresentation( : 0; const anomalyRatio = formatAnomalyRatio(props.anomaly) ?? ''; const maxInfo = getMaxDiskInfo(disks); - const displayPercentValue = overallPercent; + const useMaxSummary = + aggregateMode && hasMultipleDisks && summaryStrategy === 'max' && maxInfo !== null; + const displayPercentValue = useMaxSummary && maxInfo ? maxInfo.percent : overallPercent; const barPercent = Math.min(displayPercentValue, 100); const maxLabelShort = maxInfo ? `max ${formatPercent(maxInfo.percent)}` : ''; const maxLabelFull = maxInfo ? `Max ${formatPercent(maxInfo.percent)} (${maxInfo.label})` : ''; const displayLabel = formatPercent(displayPercentValue); - const displaySublabel = `${formatBytes(totalUsed)}/${formatBytes(totalCapacity)}`; + const displaySublabel = useMaxSummary && maxInfo + ? maxInfo.label + : `${formatBytes(totalUsed)}/${formatBytes(totalCapacity)}`; + const diskCountLabel = hasMultipleDisks ? `(${disks.length})` : ''; + const diskCountTitle = hasMultipleDisks ? `${disks.length} disks` : ''; + const showDiskCount = Boolean(props.showDiskCount && hasMultipleDisks); const showMaxLabel = aggregateMode && hasMultipleDisks && + !useMaxSummary && maxLabelShort.length > 0 && containerWidth >= estimateTextWidth(`${displayLabel} ${maxLabelShort}`); const showSublabel = containerWidth >= estimateTextWidth( - `${displayLabel}${showMaxLabel ? ` ${maxLabelShort}` : ''} (${displaySublabel})`, + `${displayLabel}${showMaxLabel ? ` ${maxLabelShort}` : ''} (${displaySublabel})${ + showDiskCount ? ` ${diskCountLabel}` : '' + }`, ); const barColor = aggregateMode && hasMultipleDisks && maxInfo @@ -340,6 +359,8 @@ export function buildStackedDiskBarPresentation( : inlineDiskMode && hasDisks ? 'metric-text w-full h-4 min-w-0' : 'metric-text w-full h-4 flex items-center justify-center', + diskCountLabel, + diskCountTitle, displayLabel, displayPercentValue, displaySublabel, @@ -351,7 +372,7 @@ export function buildStackedDiskBarPresentation( miniDisks, miniMode, segments, - showDiskCount: useStackedSegments, + showDiskCount: useStackedSegments || showDiskCount, showMaxLabel, showSublabel, tooltipContent, diff --git a/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx b/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx index 74062b709..fa3a0cb47 100644 --- a/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx +++ b/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx @@ -1612,7 +1612,9 @@ export const AgentsMachinesTable: Component<{ fallback={metricFallback()} > 1 ? 'vertical-bars' : undefined} + mode={(disks()?.length ?? 0) > 1 ? 'aggregate' : undefined} + summaryStrategy="max" + showDiskCount disks={disks()} aggregateDisk={aggregateDisk()} /> diff --git a/frontend-modern/src/features/standalone/__tests__/agentMachineTableModel.test.ts b/frontend-modern/src/features/standalone/__tests__/agentMachineTableModel.test.ts index 3d8566e5e..33630dc2c 100644 --- a/frontend-modern/src/features/standalone/__tests__/agentMachineTableModel.test.ts +++ b/frontend-modern/src/features/standalone/__tests__/agentMachineTableModel.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { Resource } from '@/types/resource'; import { + getAgentMachineDiskPercent, getAgentMachineDiskIODetails, getAgentMachineNetworkInterfaceDetails, getAgentMachineRaidArrayDetails, @@ -273,6 +274,118 @@ describe('agentMachineTableModel', () => { expect(sorted.map((machine) => machine.id)).toEqual(['warm', 'cool']); }); + it('uses the worst operational filesystem as the machine disk percent', () => { + const machine = resource({ + disk: { + total: 1000, + used: 300, + free: 700, + current: 30, + }, + agent: { + disks: [ + { + device: '/dev/sda2', + mountpoint: '/', + type: 'ext4', + total: 1000, + used: 420, + free: 580, + }, + { + device: '/dev/sdb1', + mountpoint: '/var/lib/postgresql', + type: 'xfs', + total: 1000, + used: 920, + free: 80, + }, + { + device: 'pmxcfs', + mountpoint: '/etc/pve', + type: 'fuse', + total: 1000, + used: 990, + free: 10, + }, + { + device: 'D:', + mountpoint: 'D:\\', + type: 'NTFS', + total: 1000, + used: 510, + free: 490, + }, + { + device: 'system-reserved', + mountpoint: 'System Reserved', + type: 'NTFS', + total: 1000, + used: 980, + free: 20, + }, + ], + }, + }); + + expect(getAgentMachineDiskPercent(machine)).toBe(92); + }); + + it('sorts machines by worst operational disk pressure', () => { + const sorted = sortAgentMachines( + [ + resource({ + id: 'aggregate-heavy', + name: 'Aggregate Heavy', + disk: { total: 10_000, used: 6_000, free: 4_000, current: 60 }, + agent: { + disks: [ + { + device: '/dev/sda1', + mountpoint: '/', + type: 'ext4', + total: 10_000, + used: 6_000, + free: 4_000, + }, + ], + }, + }), + resource({ + id: 'small-hot-volume', + name: 'Small Hot Volume', + disk: { total: 10_000, used: 2_000, free: 8_000, current: 20 }, + agent: { + disks: [ + { + device: '/dev/sda1', + mountpoint: '/', + type: 'ext4', + total: 9_000, + used: 1_000, + free: 8_000, + }, + { + device: '/dev/sdb1', + mountpoint: '/srv/app', + type: 'xfs', + total: 1_000, + used: 910, + free: 90, + }, + ], + }, + }), + ], + 'disk', + 'desc', + () => '', + () => '', + ); + + expect(sorted.map((machine) => machine.id)).toEqual(['small-hot-volume', 'aggregate-heavy']); + }); + it('matches machine-native search fields beyond the generic resource identity', () => { const machine = resource({ id: 'richard-mac-mini', diff --git a/frontend-modern/src/features/standalone/agentMachineTableModel.ts b/frontend-modern/src/features/standalone/agentMachineTableModel.ts index 3b16e1afb..427fef3e6 100644 --- a/frontend-modern/src/features/standalone/agentMachineTableModel.ts +++ b/frontend-modern/src/features/standalone/agentMachineTableModel.ts @@ -1,6 +1,7 @@ import type { ColumnDef } from '@/hooks/useColumnVisibility'; import type { HostDiskIO, HostRAIDArray, HostRAIDDevice } from '@/types/api'; import type { Resource } from '@/types/resource'; +import { normalizeDiskArray } from '@/utils/format'; import { asTrimmedString } from '@/utils/stringUtils'; export type AgentMachineColumnId = @@ -164,6 +165,31 @@ const positiveTemperature = (value: number | undefined): number | undefined => { const maxTemperatureReading = (readings: readonly TemperatureReading[]): number | undefined => readings.length > 0 ? Math.max(...readings.map((reading) => reading.value)) : undefined; +const getDiskUsagePercent = (disk: { + total?: number; + used?: number; + usage?: number; +}): number | undefined => { + const total = finiteMetric(disk.total); + const used = finiteMetric(disk.used); + if (total && total > 0 && typeof used === 'number') { + return (used / total) * 100; + } + + const usage = finiteMetric(disk.usage); + if (usage === undefined) return undefined; + return usage <= 1 ? usage * 100 : usage; +}; + +const getMaxOperationalDiskPercent = (machine: Resource): number | undefined => { + const disks = normalizeDiskArray(machine.agent?.disks) ?? []; + return disks.reduce((maxPercent, disk) => { + const percent = getDiskUsagePercent(disk); + if (percent === undefined) return maxPercent; + return maxPercent === undefined ? percent : Math.max(maxPercent, percent); + }, undefined); +}; + const getPositiveRecordReadings = ( values: Record | undefined, ): TemperatureReading[] => @@ -241,6 +267,9 @@ export const getAgentMachineMemoryPercent = (machine: Resource): number | undefi }; export const getAgentMachineDiskPercent = (machine: Resource): number | undefined => { + const maxDiskPercent = getMaxOperationalDiskPercent(machine); + if (maxDiskPercent !== undefined) return maxDiskPercent; + const total = finiteMetric(machine.disk?.total); const used = finiteMetric(machine.disk?.used); if (total && total > 0 && typeof used === 'number') { diff --git a/frontend-modern/src/utils/__tests__/formatExtra.test.ts b/frontend-modern/src/utils/__tests__/formatExtra.test.ts index b51461b11..7f4a0e13f 100644 --- a/frontend-modern/src/utils/__tests__/formatExtra.test.ts +++ b/frontend-modern/src/utils/__tests__/formatExtra.test.ts @@ -201,6 +201,55 @@ describe('normalizeDiskArray', () => { expect(result?.map((disk) => disk.mountpoint)).toEqual(['/', '/Volumes/Development']); }); + it('filters platform plumbing while preserving operational Windows and Linux disks', () => { + const result = normalizeDiskArray([ + { + device: '/dev/sda1', + mountpoint: '/boot/firmware', + filesystem: 'vfat', + total: 536870912, + used: 67108864, + }, + { + device: '/dev/sda2', + mountpoint: '/boot', + filesystem: 'ext4', + total: 2147483648, + used: 268435456, + }, + { + device: 'C:', + mountpoint: 'C:\\', + filesystem: 'NTFS', + total: 536870912000, + used: 268435456000, + }, + { + device: 'D:', + mountpoint: 'D:\\', + filesystem: 'NTFS', + total: 1099511627776, + used: 549755813888, + }, + { + device: '\\\\?\\Volume{system-reserved}\\', + mountpoint: 'System Reserved', + filesystem: 'NTFS', + total: 524288000, + used: 104857600, + }, + { + device: 'pmxcfs', + mountpoint: '/etc/pve', + filesystem: 'fuse', + total: 134217728, + used: 262144, + }, + ]); + + expect(result?.map((disk) => disk.mountpoint)).toEqual(['/boot', 'C:\\', 'D:\\']); + }); + it('returns undefined when all disk rows are non-operational macOS mounts', () => { expect( normalizeDiskArray([ diff --git a/frontend-modern/src/utils/format.ts b/frontend-modern/src/utils/format.ts index 642e65f25..230f95033 100644 --- a/frontend-modern/src/utils/format.ts +++ b/frontend-modern/src/utils/format.ts @@ -16,9 +16,18 @@ const NON_OPERATIONAL_DISK_MOUNT_PREFIXES = [ '/Library/Developer/CoreSimulator/Volumes/', ]; +const NON_OPERATIONAL_DISK_MOUNTPOINTS = new Set([ + '/boot/efi', + '/boot/firmware', + '/etc/pve', + 'System Reserved', +]); + function shouldDisplayDisk(disk: DiskInput): boolean { const mountpoint = disk.mountpoint?.trim() ?? ''; if (!mountpoint) return true; + if (NON_OPERATIONAL_DISK_MOUNTPOINTS.has(mountpoint)) return false; + if (mountpoint.includes('System Reserved')) return false; return !NON_OPERATIONAL_DISK_MOUNT_PREFIXES.some((prefix) => mountpoint.startsWith(prefix)); } diff --git a/pkg/fsfilters/filters.go b/pkg/fsfilters/filters.go index 06c7cd0a6..b619e183d 100644 --- a/pkg/fsfilters/filters.go +++ b/pkg/fsfilters/filters.go @@ -103,6 +103,15 @@ var specialMountPrefixes = []string{ "/Library/Developer/CoreSimulator/Volumes/", // Xcode simulator runtime disk images } +// specialMountpoints are exact mountpoints that represent platform plumbing +// rather than operator-managed capacity. +var specialMountpoints = map[string]bool{ + "/boot/efi": true, + "/boot/firmware": true, + "/etc/pve": true, + "/var/run": true, +} + // containerOverlayPatterns detect container overlay filesystem paths from various // container runtimes (Docker, Podman, LXC, EnhanceCP, etc.) that may not be in // standard locations. These paths should be excluded from disk usage as they @@ -155,7 +164,7 @@ func ShouldSkipFilesystem(fsType, mountpoint string, totalBytes, usedBytes uint6 } // Check specific special mountpoints - if mountpoint == "/boot/efi" || mountpoint == "/var/run" { + if specialMountpoints[mountpoint] { reasons = append(reasons, "special-mountpoint") } diff --git a/pkg/fsfilters/filters_test.go b/pkg/fsfilters/filters_test.go index d63278d80..ab497548e 100644 --- a/pkg/fsfilters/filters_test.go +++ b/pkg/fsfilters/filters_test.go @@ -175,6 +175,9 @@ func TestShouldSkipFilesystem(t *testing.T) { {"/var/lib/docker", "ext4", "/var/lib/docker/overlay2", 1000000, 500000, true}, {"/snap prefix", "ext4", "/snap/core/12345", 1000000, 500000, true}, {"/boot/efi exact", "vfat", "/boot/efi", 512 * 1024 * 1024, 50 * 1024 * 1024, true}, + {"/boot/firmware exact", "vfat", "/boot/firmware", 512 * 1024 * 1024, 50 * 1024 * 1024, true}, + {"/boot regular filesystem - should NOT skip", "ext4", "/boot", 2 * 1024 * 1024 * 1024, 256 * 1024 * 1024, false}, + {"Proxmox cluster config filesystem", "fuse", "/etc/pve", 128 * 1024 * 1024, 256 * 1024, true}, {"/var/lib/containers podman", "ext4", "/var/lib/containers/storage/overlay/abc123/merged", 1000000, 500000, true}, // Container overlay paths in non-standard locations (issue #790)