From e25a5653c3a5a1360f382ff3087d289701c81793 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:16:52 +0100 Subject: [PATCH] Count alerting guests under Attention on the Proxmox overview The guest status chips put a running guest with an open warning or critical alert under Running, because the Attention bucket looked only at resource status and failed availability probes. With seven running guests over a memory threshold the chips still read "Attention 0". The bucket now also takes running guests whose canonical health names an open alert. Backup age stays out: it has its own column and tab. Two smaller fixes on the same page. The nodes table repeated one cluster name in a Cluster column on every row; with a single cluster the column is dropped and the header names the cluster, also while searching. The VM disk "Unavailable" label was amber for every reason, mostly a missing guest agent or a stopped VM, and its explanation was screen-reader only; it is now muted unless the read is genuinely broken, with the reason on hover. Contract-Neutral: Workload status bucket and disk label read existing health and disk-read fields; no performance-path change, so performance-and-scalability.md is unaffected. --- .../subsystems/frontend-primitives.md | 4 +- .../internal/subsystems/unified-resources.md | 8 +++ .../162c35dc6e7baed5.json | 48 ++++++++++++++ .../src/components/Workloads/GuestRow.tsx | 12 +++- .../Workloads/__tests__/GuestRow.test.tsx | 19 ++++++ .../__tests__/workloadSelectors.test.ts | 26 ++++++++ .../components/Workloads/workloadSelectors.ts | 7 +++ .../features/proxmox/ProxmoxNodesTable.tsx | 62 +++++++++++++------ .../__tests__/ProxmoxNodesTable.test.tsx | 18 ++++++ .../__tests__/proxmoxHostTableModel.test.ts | 10 +++ .../features/proxmox/proxmoxHostTableModel.ts | 7 ++- .../src/hooks/__tests__/useWorkloads.test.ts | 40 ++++++++++++ frontend-modern/src/hooks/useWorkloads.ts | 5 ++ frontend-modern/src/types/workloads.ts | 2 + 14 files changed, 247 insertions(+), 21 deletions(-) create mode 100644 frontend-modern/browser-verification/162c35dc6e7baed5.json diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index bd2fbe8a17..ab9d761f47 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -3240,7 +3240,9 @@ default` instead of fusing provider and badge text such as existing table header; Proxmox derives cluster and standalone-node context through `platformEstateOverviewModel.ts` and supplies it to `ProxmoxNodesTable.tsx` - rather than creating a page-level summary. The existing + rather than creating a page-level summary. A single-cluster estate shows that + cluster's name in the same header slot in place of the count, kept + visible while searching, because the repeated Cluster column is dropped. The existing `platformEstateOverviewVisible` preference now governs these inline totals from the shared View menu, preserving the global browser-persisted choice across platform workload surfaces. A page with adjacent totals, such as diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index ae04260b9a..6ecfa0fa4d 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -1293,6 +1293,14 @@ names remain inert identity text, while the separate accessible launch target owns new-tab behavior and event containment. Proxmox host table columns are governed by `proxmoxHostTableModel.ts`; that model must not reintroduce a separate `Web` column, a linked row name, or a page-local launch implementation. +When every node shares one cluster and none is standalone, the model drops +the `cluster` column (it would repeat one value on every row) and +`ProxmoxNodesTable` names that cluster in the table header instead, visible +during search and independent of the inventory-count preference, so the +cluster identity is never lost. Workload status buckets read the canonical +health too: a running workload whose unified health names an open +`warning_alert` or `critical_alert` reason counts under Attention, while +`backup_stale` and other non-alert reasons do not. The same presentation contract applies to Docker/Podman hosts and containers, standalone agents, PBS/PMG instances, Kubernetes clusters/nodes/workloads, and card or mobile variants. An absent URL is honestly absent, while an invalid or diff --git a/frontend-modern/browser-verification/162c35dc6e7baed5.json b/frontend-modern/browser-verification/162c35dc6e7baed5.json new file mode 100644 index 0000000000..509e8e1f59 --- /dev/null +++ b/frontend-modern/browser-verification/162c35dc6e7baed5.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "base_sha": "53e2d27ab420c505f54694b0ccbb7d843ff6eded", + "verified_at": "2026-10-05T00:16:51Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestRow.tsx", + "frontend-modern/src/components/Workloads/workloadSelectors.ts", + "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx", + "frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts", + "frontend-modern/src/hooks/useWorkloads.ts", + "frontend-modern/src/types/workloads.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestRow.tsx": "d1d60dfc1fca8e2cf94c0870ae348ad005e527656e57bac84b304151a87a808b", + "frontend-modern/src/components/Workloads/workloadSelectors.ts": "cc59933a4f75515ce1ab075e737fdd26d2523d526455dbd7765d85ce8d8b3fd0", + "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "9a111deb348d54c2c778595d770d6bf25752067214cec6e4d0cc363cdbd0d321", + "frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts": "0088db54f683bfb8c5603480f240c05a930248442cb08c6a344db9363d62c08d", + "frontend-modern/src/hooks/useWorkloads.ts": "b662ce635aad385ab8e18334874fcc16f324566295d2d2eedfdfb8684c367d5f", + "frontend-modern/src/types/workloads.ts": "924235c100c8d78a9546cd566e2d1cf306bf92415f649aa29e0364832d445ca5" + }, + "routes": [ + "/proxmox/overview", + "/proxmox/overview?status=degraded", + "/docker/overview", + "/vmware/overview", + "/kubernetes/workloads" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Mock estate with the guest memory threshold lowered to 60% on the private stack so seven running guests carried warning alerts. Before (changes reverted in place): chips read Running 60 / Attention 0 / Stopped 5. After: Running 53 / Attention 7; ?status=degraded lists exactly the seven alerting guests.", + "Nodes table: the Cluster column that repeated Production West on every row is gone and the header reads Cluster Production West; it stays visible while searching (Nodes 2 of 5).", + "VM disk Unavailable label renders muted slate with the reason as a title tooltip for vm-stopped; permission-denied and agent-error stay amber (component test).", + "Other platform chips checked after the change: Docker Healthy 27 / Degraded 10 / Offline 2, vSphere Attention 0, Kubernetes workloads Degraded 2, TrueNAS VMs Attention 0, Machines Degraded 0." + ], + "interactions": [ + "Clicked the Attention chip and read the filtered rows; typed a search into the guest search box and confirmed the cluster label; read the disk status title attribute and computed colour." + ] +} diff --git a/frontend-modern/src/components/Workloads/GuestRow.tsx b/frontend-modern/src/components/Workloads/GuestRow.tsx index 244b813c30..e9e573c69b 100644 --- a/frontend-modern/src/components/Workloads/GuestRow.tsx +++ b/frontend-modern/src/components/Workloads/GuestRow.tsx @@ -52,6 +52,10 @@ export { GUEST_COLUMNS, VIEW_MODE_COLUMNS } from './guestRowModel'; export type { GuestRowProps, WorkloadIOEmphasis } from './guestRowModel'; import { getGuestColumnStyle } from './guestRowModel'; +// Disk-read reasons that point at something broken the user can fix, as +// opposed to a guest that simply has no agent or is not running. +const DISK_READ_ACTIONABLE_REASONS = new Set(['permission-denied', 'agent-error']); + export function GuestRow(props: GuestRowProps) { const [rowActionProps] = splitProps(props, ['onClick']); const { @@ -234,6 +238,9 @@ export function GuestRow(props: GuestRowProps) { : 'Unavailable', message: getDiskStatusTooltip(), valueLabelContext: retained ? ('last known' as const) : ('current' as const), + // Most reasons are a setup gap (no guest agent, VM stopped) rather than a + // fault, so they stay muted; only a broken read the user can fix is amber. + needsAction: DISK_READ_ACTIONABLE_REASONS.has(reason.replace(/^prev-/, '')), }; }); const diskValueLabelContext = (): MetricMiniSparklineValueLabelContext => @@ -648,7 +655,10 @@ export function GuestRow(props: GuestRowProps) { {(status) => (

{status().message} diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx index e944724eb3..657ddd04ad 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx @@ -772,9 +772,28 @@ describe('GuestRow', () => { expect(notice).toHaveTextContent('Unavailable'); expect(notice).toHaveTextContent(message); expect(notice).not.toHaveTextContent('Last known'); + // The reason is on hover too, not only for screen readers. + expect(notice).toHaveAttribute('title', message); }, ); + it(`keeps a setup gap muted and a broken ${mode} read amber`, () => { + const render = (diskStatusReason: string) => + renderGuestRow({ + guest: makeGuest({ + diskStatusReason, + disk: makeDisk({ total: 0, used: 0, usage: -1 }), + disks: [], + }), + visibleColumnIds: ['name', 'disk'], + metricDisplayMode: mode, + }).container.querySelector('[data-workload-disk-read-status]'); + expect(render('vm-stopped')?.className).toContain('text-muted'); + expect(render('agent-disabled')?.className).toContain('text-muted'); + expect(render('permission-denied')?.className).toContain('text-amber-700'); + expect(render('prev-agent-error')?.className).toContain('text-amber-700'); + }); + it(`withdraws the ${mode} notice only on a fresh same-VM observation`, () => { const [guest, setGuest] = createSignal(makeGuest({ diskStatusReason: 'prev-vm-locked' })); const { container } = render(() => ( diff --git a/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.test.ts b/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.test.ts index f4924cfe95..bbd80c5077 100644 --- a/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.test.ts @@ -191,6 +191,32 @@ describe('workloadSelectors', () => { expect(computeWorkloadStats(guests).degraded).toBe(1); }); + it('counts a running guest with an open alert as needing attention, not a stopped one', () => { + const guests = [ + makeGuest(1, { status: 'running', hasOpenAlert: true }), + makeGuest(2, { status: 'running' }), + makeGuest(3, { status: 'stopped', hasOpenAlert: true }), + ]; + + const filterByStatus = (statusMode: 'running' | 'degraded' | 'stopped') => + filterWorkloads({ + guests, + viewMode: 'all', + statusMode, + searchTerm: '', + selectedNode: null, + selectedHostHint: null, + selectedKubernetesContext: null, + }).map((guest) => guest.id); + + // A powered-off alert on a stopped guest keeps it under Stopped; the + // Attention chip is for running guests that need a look. + expect(filterByStatus('degraded')).toEqual([guests[0].id]); + expect(filterByStatus('running')).toEqual([guests[1].id]); + expect(filterByStatus('stopped')).toEqual([guests[2].id]); + expect(computeWorkloadStats(guests)).toMatchObject({ running: 1, degraded: 1, stopped: 1 }); + }); + it('ignores availability probes that are not proven to describe the guest', () => { const failing = { enabled: true, diff --git a/frontend-modern/src/components/Workloads/workloadSelectors.ts b/frontend-modern/src/components/Workloads/workloadSelectors.ts index c0cfff3336..dd66a28800 100644 --- a/frontend-modern/src/components/Workloads/workloadSelectors.ts +++ b/frontend-modern/src/components/Workloads/workloadSelectors.ts @@ -79,6 +79,13 @@ const resolveWorkloadStatusBucket = (guest: WorkloadGuest): WorkloadStatusBucket return 'degraded'; } + // A running guest with an open warning or critical alert needs attention + // even while its power state and resource status read healthy. Backup age + // is deliberately not counted here: it has its own column and tab. + if (bucket === 'running' && guest.hasOpenAlert) { + return 'degraded'; + } + return bucket; }; diff --git a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx index a8c4ea1ca7..932f0767e3 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx @@ -223,7 +223,17 @@ export const ProxmoxNodesTable: Component<{ ? getProxmoxHostTableLayoutModeForContainer(measuredWidth) : getWorkloadTableLayoutMode(breakpoint.width()); }); - const visibleColumns = createMemo(() => getProxmoxHostVisibleColumnsForLayout(layoutMode())); + const singleClusterName = createMemo(() => { + const labels = new Set(props.nodes.map((node) => getResourceClusterLabel(node))); + if (labels.size !== 1) return null; + const [label] = [...labels]; + return label && label !== '—' && props.topology?.standalone === 0 ? label : null; + }); + const visibleColumns = createMemo(() => + getProxmoxHostVisibleColumnsForLayout(layoutMode(), { + singleCluster: singleClusterName() !== null, + }), + ); const visibleColumnIds = createMemo(() => visibleColumns().map((column) => column.id)); const displayMode = () => props.metricDisplayMode?.() ?? 'bars'; const isSparklineMode = () => displayMode() === 'sparklines'; @@ -295,25 +305,41 @@ export const ProxmoxNodesTable: Component<{ actions={ 0 || props.topology.standalone > 0) + when={singleClusterName()} + fallback={ + 0 || props.topology.standalone > 0) + } + > + + 0}> + {formatPlatformTableIntegerValue(props.topology!.clusters)}{' '} + {props.topology!.clusters === 1 ? 'cluster' : 'clusters'} + + 0 && props.topology!.standalone > 0}> + {' / '} + + 0}> + {formatPlatformTableIntegerValue(props.topology!.standalone)} standalone + + + } > - - 0}> - {formatPlatformTableIntegerValue(props.topology!.clusters)}{' '} - {props.topology!.clusters === 1 ? 'cluster' : 'clusters'} - - 0 && props.topology!.standalone > 0}> - {' / '} - - 0}> - {formatPlatformTableIntegerValue(props.topology!.standalone)} standalone - - + {/* The hidden Cluster column's value lives here, so it stays + visible while searching and when inventory counts are off. */} + {(name) => ( + + Cluster {name()} + + )} } diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx index 7c780b1316..e985df726d 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx @@ -194,6 +194,24 @@ describe('ProxmoxNodesTable', () => { expect(screen.getByText('2 more below')).toBeVisible(); }); + it('names a single cluster once instead of repeating it in a column, even while searching', () => { + render(() => ( + 'pve-node-2'} + inventoryCountsVisible={() => false} + emptyIcon={} + emptyTitle="No Proxmox VE nodes" + emptyDescription="No nodes" + /> + )); + + expect(screen.getByTestId('proxmox-single-cluster-label')).toHaveTextContent('Cluster homelab'); + expect(screen.queryByRole('columnheader', { name: /cluster/i })).not.toBeInTheDocument(); + }); + it('hides node and topology totals through the page-owned inventory preference', () => { render(() => ( { expect(getProxmoxHostColumnWidthStyle('uptime', 'narrow', ids)).toEqual({ width: '15%' }); }); + it('drops the cluster column when every node shares one cluster', () => { + const wide = getProxmoxHostVisibleColumnsForLayout('wide').map((column) => column.id); + const single = getProxmoxHostVisibleColumnsForLayout('wide', { singleCluster: true }).map( + (column) => column.id, + ); + expect(wide).toContain('cluster'); + expect(single).not.toContain('cluster'); + expect(single).toEqual(wide.filter((id) => id !== 'cluster')); + }); + it('matches the guest table columns and keeps node names whole at phone width', () => { const columns = getProxmoxHostVisibleColumnsForLayout('phone'); const ids = columns.map((column) => column.id); diff --git a/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts b/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts index be6aaafe31..c77c4852b6 100644 --- a/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts +++ b/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts @@ -141,10 +141,15 @@ export const PROXMOX_HOST_TABLE_COLUMNS: ProxmoxHostTableColumn[] = [ export const getProxmoxHostVisibleColumnsForLayout = ( layoutMode: WorkloadTableLayoutMode, + options: { singleCluster?: boolean } = {}, ): ProxmoxHostTableColumn[] => { const layoutRank = HOST_TABLE_LAYOUT_ORDER[layoutMode]; return PROXMOX_HOST_TABLE_COLUMNS.filter( - (column) => HOST_TABLE_LAYOUT_ORDER[HOST_COLUMN_MIN_LAYOUT[column.id]] <= layoutRank, + (column) => + HOST_TABLE_LAYOUT_ORDER[HOST_COLUMN_MIN_LAYOUT[column.id]] <= layoutRank && + // When every node shares one cluster the column repeats one value on + // every row; the table header names that cluster instead. + !(options.singleCluster && column.id === 'cluster'), ); }; diff --git a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts index fe512b6f09..b7f5265e88 100644 --- a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts +++ b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts @@ -1288,6 +1288,46 @@ describe('useWorkloads', () => { dispose(); }); + it('flags a workload whose canonical health names an open alert, not backup age', async () => { + apiFetchJSONMock.mockResolvedValueOnce({ + data: [ + { + ...sampleResource, + id: 'alerting', + name: 'alerting', + health: { verdict: 'attention', reasons: [{ code: 'warning_alert', detail: 'memory' }] }, + }, + { + ...sampleResource, + id: 'stale-backup', + name: 'stale-backup', + vmid: 102, + health: { verdict: 'attention', reasons: [{ code: 'backup_stale' }] }, + }, + { ...sampleResource, id: 'quiet', name: 'quiet', vmid: 103 }, + ], + meta: { totalPages: 1 }, + }); + + let dispose = () => {}; + let result: ReturnType | undefined; + createRoot((d) => { + dispose = d; + const [enabled] = createSignal(true); + result = useWorkloads(enabled); + }); + + await flushAsync(); + await waitForWorkloadCount(() => result!.workloads().length, 3); + + const byName = new Map(result!.workloads().map((workload) => [workload.name, workload])); + expect(byName.get('alerting')?.hasOpenAlert).toBe(true); + expect(byName.get('stale-backup')?.hasOpenAlert).toBe(false); + expect(byName.get('quiet')?.hasOpenAlert).toBe(false); + + dispose(); + }); + it('uses the canonical Kubernetes cluster name for pod context labels', async () => { apiFetchJSONMock.mockResolvedValueOnce({ data: [ diff --git a/frontend-modern/src/hooks/useWorkloads.ts b/frontend-modern/src/hooks/useWorkloads.ts index 26540a4829..5f53347254 100644 --- a/frontend-modern/src/hooks/useWorkloads.ts +++ b/frontend-modern/src/hooks/useWorkloads.ts @@ -73,6 +73,7 @@ type APIResource = { type?: string; name?: string; status?: string; + health?: { verdict?: string; reasons?: Array<{ code?: string; detail?: string }> }; uptime?: number; lastSeen?: string; sources?: string[]; @@ -558,6 +559,9 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => { (platformType === 'vmware-vsphere' ? resource.vmware?.powerState : null), ), resourceStatus: (resource.status || '').trim().toLowerCase() || undefined, + hasOpenAlert: (resource.health?.reasons ?? []).some( + (reason) => reason.code === 'warning_alert' || reason.code === 'critical_alert', + ), type: workloadType === 'vm' ? 'vm' @@ -719,6 +723,7 @@ const mapCanonicalResourceToWorkload = (resource: Resource): WorkloadGuest | nul type: resource.type, name: resource.name, status: resource.status, + health: resource.health, uptime: resource.uptime, lastSeen: resource.lastSeen ? new Date(resource.lastSeen).toISOString() : undefined, sources: resource.sources, diff --git a/frontend-modern/src/types/workloads.ts b/frontend-modern/src/types/workloads.ts index 12005d1459..00425a6612 100644 --- a/frontend-modern/src/types/workloads.ts +++ b/frontend-modern/src/types/workloads.ts @@ -30,6 +30,8 @@ export type WorkloadGuest = (VM | Container) & { metricsTarget?: ResourceMetricsTarget; /** Canonical unified-resource health, kept separate from runtime power state in `status`. */ resourceStatus?: string; + /** The unified-resource health names an open warning or critical alert. */ + hasOpenAlert?: boolean; displayId?: string; image?: string; // Provider/runtime-native identifier for app-container actions such as Docker image updates.