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.
This commit is contained in:
rcourtman
2026-10-05 01:16:52 +01:00
parent 53e2d27ab4
commit e25a5653c3
14 changed files with 247 additions and 21 deletions
@@ -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
@@ -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
@@ -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."
]
}
@@ -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) => (
<p
data-workload-disk-read-status
class="mt-0.5 text-center text-[10px] leading-none text-amber-700 dark:text-amber-300"
class={`mt-0.5 text-center text-[10px] leading-none ${
status().needsAction ? 'text-amber-700 dark:text-amber-300' : 'text-muted'
}`}
title={status().message}
>
<span aria-hidden="true">{status().label}</span>
<span class="sr-only">{status().message}</span>
@@ -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(() => (
@@ -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,
@@ -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;
};
@@ -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={
<span class="inline-flex items-center gap-2">
<Show
when={
inventoryCountsVisible() &&
props.topology &&
!props.search?.()?.trim() &&
(props.topology.clusters > 0 || props.topology.standalone > 0)
when={singleClusterName()}
fallback={
<Show
when={
inventoryCountsVisible() &&
props.topology &&
!props.search?.()?.trim() &&
(props.topology.clusters > 0 || props.topology.standalone > 0)
}
>
<span class="text-[10px] font-medium text-muted">
<Show when={props.topology!.clusters > 0}>
{formatPlatformTableIntegerValue(props.topology!.clusters)}{' '}
{props.topology!.clusters === 1 ? 'cluster' : 'clusters'}
</Show>
<Show when={props.topology!.clusters > 0 && props.topology!.standalone > 0}>
{' / '}
</Show>
<Show when={props.topology!.standalone > 0}>
{formatPlatformTableIntegerValue(props.topology!.standalone)} standalone
</Show>
</span>
</Show>
}
>
<span class="text-[10px] font-medium text-muted">
<Show when={props.topology!.clusters > 0}>
{formatPlatformTableIntegerValue(props.topology!.clusters)}{' '}
{props.topology!.clusters === 1 ? 'cluster' : 'clusters'}
</Show>
<Show when={props.topology!.clusters > 0 && props.topology!.standalone > 0}>
{' / '}
</Show>
<Show when={props.topology!.standalone > 0}>
{formatPlatformTableIntegerValue(props.topology!.standalone)} standalone
</Show>
</span>
{/* The hidden Cluster column's value lives here, so it stays
visible while searching and when inventory counts are off. */}
{(name) => (
<span
class="text-[10px] font-medium text-muted"
data-testid="proxmox-single-cluster-label"
>
Cluster {name()}
</span>
)}
</Show>
</span>
}
@@ -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(() => (
<ProxmoxNodesTable
nodes={makeNodeResources(3)}
guests={[]}
topology={{ nodes: 3, clusters: 1, standalone: 0 }}
search={() => 'pve-node-2'}
inventoryCountsVisible={() => false}
emptyIcon={<span />}
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(() => (
<ProxmoxNodesTable
@@ -19,6 +19,16 @@ describe('proxmoxHostTableModel', () => {
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);
@@ -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'),
);
};
@@ -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<UseWorkloadsModule['useWorkloads']> | 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: [
@@ -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,
+2
View File
@@ -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.