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={