mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user