Fix Workloads attention status filtering

This commit is contained in:
rcourtman
2026-08-02 22:28:36 +01:00
parent bacff3e2eb
commit 09b6de599e
14 changed files with 145 additions and 61 deletions
@@ -3905,7 +3905,9 @@ after applying a view gives someone else the exact filtered state.
Counts or orientation strips presented as part of a filtered resource table
must derive from that table's canonical filtered collection as well. They must
not continue showing page-wide inventory totals after FilterBar state, saved
views, or search has narrowed the rows the operator can see.
views, or search has narrowed the rows the operator can see. Filtered summary
strips should omit zero-value categories and their separators rather than
turning absent states into persistent visual noise.
Implicit "remember last filters" is intentionally not added — defaulting
to yesterday's filter state on a monitoring page hides real problems.
That same shared filter-toolbar boundary also owns controlled select continuity
@@ -272,6 +272,12 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
seed a missing `status` parameter when a platform page is revisited, but the
restore path must write the URL once with `replace` and must not force row
filtering through a separate page-local state channel.
Status buckets must remain mutually exclusive and derive from the already-
loaded workload snapshot: warning or offline unified-resource health takes
precedence for filter/stats posture, while healthy resources continue to use
the provider-authored runtime power state. `useWorkloads.ts` carries both
values; selectors must not collapse warning health into `Stopped` or add a
second resource lookup to recover it.
4. Keep shared auth gating in `internal/api/router.go` cheap and local: pre-auth quick-setup and recovery routing may short-circuit on loopback/session/token checks, but they must not trigger chart, metrics, or broad persistence fan-out on the protected request hot path.
Agent command authorization is likewise a dispatch-time point lookup and
atomic approval consume, not a route-wide scan or request-hot-path fan-out;
@@ -472,7 +472,9 @@ The adjacent Proxmox overview guest-totals strip is likewise presentation-only
and must consume the embedded Workloads surface's filtered statistics. It must
not fall back to page-wide guest summary counts when search, type, status, or
node filters have narrowed the visible workload inventory; backup and recovery
authority remain unchanged by either set of presentation counts.
authority remain unchanged by either set of presentation counts. Filtered
totals omit zero-value status categories and their separators so the strip
does not add false orientation noise beside a narrowed result set.
That overview Backup signal belongs to Proxmox VMs and LXCs. If the embedded
Workloads table demotes Docker-in-LXC `app-container` rows out of peer
membership, the backup surface must still receive the page model's Proxmox
+20 -14
View File
@@ -1,13 +1,19 @@
{
"version": 1,
"base_sha": "c143a7cf322b407e8091ca4e27a16e92a5178cef",
"verified_at": "2026-08-02T20:53:27Z",
"base_sha": "bacff3e2ebbb53dfa54881132d971e11e50d452e",
"verified_at": "2026-08-02T21:27:13Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx"
"frontend-modern/src/components/Workloads/workloadSelectors.ts",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx",
"frontend-modern/src/hooks/useWorkloads.ts",
"frontend-modern/src/types/workloads.ts"
],
"content_sha256": {
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "3dafdf4fec6e6843082f347258c7913f96bc1bf9fc23b10e2461d87ffa90110f"
"frontend-modern/src/components/Workloads/workloadSelectors.ts": "bca56f3abac29d36ed30e361747f4b32138bd0fd75112ff04aed8e2ca15dc01d",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "a26664090d776ac8c99bfd14c52fafa3157cf8aebf275807b2a65588664b1fe7",
"frontend-modern/src/hooks/useWorkloads.ts": "f6064a2dd39d50d2c24c91f7499e21bcb4f115f9e99ae90420536c8e6834b06a",
"frontend-modern/src/types/workloads.ts": "52996a1fa0b08da1167770522302053f72da4f9d40ec3b0cd17b0dd9b1c91749"
},
"routes": [
"/proxmox/overview"
@@ -23,17 +29,17 @@
}
],
"states": [
"Desktop filtered saved view with one stopped VM",
"Phone-width filtered saved view with one stopped VM",
"Desktop zero-result Attention filter",
"Desktop unfiltered workload inventory",
"Clean unfiltered state after saved-view removal and reload"
"Desktop unfiltered Proxmox workload inventory",
"Desktop Attention filter with 11 warning-health guests",
"Desktop Stopped filter with 5 stopped-health guests",
"Phone-width expanded filters with Attention selected",
"Clean unfiltered state after clearing filters and reload"
],
"interactions": [
"Confirmed the one-row VMs, Stopped, and Node pve1 view rendered 0 running and 1 stopped instead of estate-wide totals",
"Inspected the filtered totals strip at 1280x900 and 390x844 for alignment, clipping, and horizontal overflow",
"Cleared the saved filters and confirmed the totals returned to the full visible workload collection",
"Applied the Attention filter and confirmed the totals strip was absent beside the canonical zero-result state",
"Removed the temporary saved view, cleared filters, reloaded the route, and confirmed no saved default or filter state remained"
"Applied Attention and confirmed the 11 node-offline warning guests rendered instead of an empty result",
"Applied Stopped and confirmed only the 5 genuinely stopped guests remained",
"Confirmed filtered totals omitted zero-value categories and stray separators",
"Expanded the phone-width filter controls, selected Attention, and inspected table alignment and viewport clipping",
"Cleared filters, reloaded the route, and confirmed the unfiltered totals restored to 53 running, 11 attention, and 5 stopped"
]
}
@@ -677,14 +677,14 @@ describe('workloadSelectors (branch coverage 2)', () => {
).toEqual(['c1', 'c2']);
});
it('matches status exactly (case-sensitive) in running mode', () => {
it('matches running status case-insensitively', () => {
const guests = [
makeGuest(1, { id: 'lower', status: 'running' }),
makeGuest(2, { id: 'capital', status: 'Running' }),
];
expect(
filterWorkloads({ ...baseFilterParams, guests, statusMode: 'running' }).map((g) => g.id),
).toEqual(['lower']);
).toEqual(['lower', 'capital']);
});
it('counts DEGRADED-set and unknown statuses as degraded, excluding running and OFFLINE-set', () => {
@@ -700,16 +700,15 @@ describe('workloadSelectors (branch coverage 2)', () => {
).toEqual(['warn', 'migrating', 'empty']);
});
it('treats capitalized "Running" as stopped due to the case-sensitive !== running check', () => {
it('normalizes status casing before assigning mutually exclusive buckets', () => {
const guests = [
makeGuest(1, { id: 'capital', status: 'Running' }),
makeGuest(2, { id: 'lower', status: 'running' }),
makeGuest(3, { id: 'stopped', status: 'stopped' }),
];
// stopped mode keeps g.status !== 'running' -> 'Running' survives (case quirk).
expect(
filterWorkloads({ ...baseFilterParams, guests, statusMode: 'stopped' }).map((g) => g.id),
).toEqual(['capital', 'stopped']);
).toEqual(['stopped']);
});
it('applies no status filter for an unrecognized statusMode (else arm)', () => {
@@ -261,7 +261,7 @@ describe('workloadSelectors (branch coverage 2)', () => {
expect(result.map((g) => g.id)).toEqual(['empty']);
});
it('matches status case-sensitively in running mode (capital "Running" is excluded)', () => {
it('normalizes status casing in running mode', () => {
const guests = [
makeGuest(1, { id: 'capital', name: 'capital', status: 'Running' }),
makeGuest(2, { id: 'lower', name: 'lower', status: 'running' }),
@@ -273,8 +273,7 @@ describe('workloadSelectors (branch coverage 2)', () => {
statusMode: 'running',
});
// g.status === 'running' is an exact, case-sensitive comparison.
expect(result.map((g) => g.id)).toEqual(['lower']);
expect(result.map((g) => g.id)).toEqual(['capital', 'lower']);
});
it('matches the numeric vmid and string status/instance text-search candidates', () => {
@@ -409,7 +408,7 @@ describe('workloadSelectors (branch coverage 2)', () => {
});
});
it('classifies a capitalized "Running" status as stopped due to case-sensitive running check', () => {
it('classifies a capitalized "Running" status as running', () => {
const guests = [
makeGuest(1, {
id: 'capital',
@@ -420,13 +419,11 @@ describe('workloadSelectors (branch coverage 2)', () => {
}),
];
// g.status === 'running' is exact; the degraded check lowercases, so
// 'Running' is neither running nor degraded and lands in stopped.
expect(computeWorkloadStats(guests)).toStrictEqual({
total: 1,
running: 0,
running: 1,
degraded: 0,
stopped: 1,
stopped: 0,
vms: 1,
containers: 0,
appContainers: 0,
@@ -124,7 +124,40 @@ describe('workloadSelectors', () => {
selectedHostHint: null,
selectedKubernetesContext: null,
});
expect(stopped.map((g) => g.status)).toEqual(['warning', 'migrating', 'offline']);
expect(stopped.map((g) => g.status)).toEqual(['offline']);
});
it('lets canonical resource health override stale runtime power for status filters', () => {
const guests = [
makeGuest(1, { status: 'stopped', resourceStatus: 'warning' }),
makeGuest(2, { status: 'running', resourceStatus: 'offline' }),
makeGuest(3, { status: 'running', resourceStatus: 'online' }),
];
const filterByStatus = (statusMode: 'running' | 'degraded' | 'stopped') =>
filterWorkloads({
guests,
viewMode: 'all',
statusMode,
searchTerm: '',
selectedNode: null,
selectedHostHint: null,
selectedKubernetesContext: null,
}).map((guest) => guest.id);
expect(filterByStatus('running')).toEqual([guests[2].id]);
expect(filterByStatus('degraded')).toEqual([guests[0].id]);
expect(filterByStatus('stopped')).toEqual([guests[1].id]);
expect(computeWorkloadStats(guests)).toEqual({
total: 3,
running: 1,
degraded: 1,
stopped: 1,
vms: 2,
containers: 0,
appContainers: 1,
pods: 0,
});
});
it('applies text and metric search filters, and supports combined filtering', () => {
@@ -5,7 +5,7 @@ import { computeIOScale } from '@/components/Infrastructure/infrastructureSelect
import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction';
import { parseFilterStack, evaluateFilterStack, splitSearchExclusions } from '@/utils/searchQuery';
import { normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms';
import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES } from '@/utils/status';
import { OFFLINE_HEALTH_STATUSES } from '@/utils/status';
import { getNodeDisplayName } from '@/utils/nodes';
import {
isContainerWorkloadViewMode,
@@ -48,6 +48,29 @@ export interface WorkloadStats {
pods: number;
}
type WorkloadStatusBucket = 'running' | 'degraded' | 'stopped';
const RUNNING_HEALTH_STATUSES = new Set(['online', 'running', 'healthy']);
const classifyWorkloadStatus = (status: string): WorkloadStatusBucket => {
const normalized = status.trim().toLowerCase();
if (RUNNING_HEALTH_STATUSES.has(normalized)) return 'running';
if (OFFLINE_HEALTH_STATUSES.has(normalized)) return 'stopped';
return 'degraded';
};
const resolveWorkloadStatusBucket = (guest: WorkloadGuest): WorkloadStatusBucket => {
const resourceStatus = (guest.resourceStatus || '').trim().toLowerCase();
// Unified-resource health owns warning/offline posture, while a healthy
// resource still defers to the provider-authored runtime power state.
if (resourceStatus && !RUNNING_HEALTH_STATUSES.has(resourceStatus)) {
return classifyWorkloadStatus(resourceStatus);
}
return classifyWorkloadStatus(guest.status || '');
};
type SortDirection = 'asc' | 'desc';
type SortValue = string | number | boolean | null | undefined;
@@ -152,17 +175,11 @@ export const filterWorkloads = ({
}
if (statusMode === 'running') {
guests = guests.filter((g) => g.status === 'running');
guests = guests.filter((g) => resolveWorkloadStatusBucket(g) === 'running');
} else if (statusMode === 'degraded') {
guests = guests.filter((g) => {
const status = (g.status || '').toLowerCase();
return (
DEGRADED_HEALTH_STATUSES.has(status) ||
(status !== 'running' && !OFFLINE_HEALTH_STATUSES.has(status))
);
});
guests = guests.filter((g) => resolveWorkloadStatusBucket(g) === 'degraded');
} else if (statusMode === 'stopped') {
guests = guests.filter((g) => g.status !== 'running');
guests = guests.filter((g) => resolveWorkloadStatusBucket(g) === 'stopped');
}
const trimmedSearch = searchTerm.trim();
@@ -437,15 +454,15 @@ export const buildWorkloadSummaryGroupScopeMap = ({
};
export const computeWorkloadStats = (guests: WorkloadGuest[]): WorkloadStats => {
const running = guests.filter((g) => g.status === 'running').length;
const degraded = guests.filter((g) => {
const status = (g.status || '').toLowerCase();
return (
DEGRADED_HEALTH_STATUSES.has(status) ||
(status !== 'running' && !OFFLINE_HEALTH_STATUSES.has(status))
);
}).length;
const stopped = guests.length - running - degraded;
let running = 0;
let degraded = 0;
let stopped = 0;
guests.forEach((guest) => {
const bucket = resolveWorkloadStatusBucket(guest);
if (bucket === 'running') running += 1;
else if (bucket === 'degraded') degraded += 1;
else stopped += 1;
});
const vms = guests.filter((g) => resolveWorkloadType(g) === 'vm').length;
const containers = guests.filter((g) => resolveWorkloadType(g) === 'system-container').length;
const appContainers = guests.filter((g) => resolveWorkloadType(g) === 'app-container').length;
@@ -381,22 +381,30 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
class="flex items-center gap-2 rounded border border-border bg-surface-alt px-2 py-1 text-xs text-muted"
data-testid="proxmox-guest-totals"
>
<span class="flex items-center gap-1.5">
<StatusDot size="xs" variant="success" ariaHidden />
{visibleGuestStats().running} running
</span>
<Show when={visibleGuestStats().running > 0}>
<span class="flex items-center gap-1.5">
<StatusDot size="xs" variant="success" ariaHidden />
{visibleGuestStats().running} running
</span>
</Show>
<Show when={visibleGuestStats().degraded > 0}>
<span aria-hidden="true">|</span>
<Show when={visibleGuestStats().running > 0}>
<span aria-hidden="true">|</span>
</Show>
<span class="flex items-center gap-1.5">
<StatusDot size="xs" variant="warning" ariaHidden />
{visibleGuestStats().degraded} attention
</span>
</Show>
<span aria-hidden="true">|</span>
<span class="flex items-center gap-1.5">
<StatusDot size="xs" variant="danger" ariaHidden />
{visibleGuestStats().stopped} stopped
</span>
<Show when={visibleGuestStats().stopped > 0}>
<Show when={visibleGuestStats().running > 0 || visibleGuestStats().degraded > 0}>
<span aria-hidden="true">|</span>
</Show>
<span class="flex items-center gap-1.5">
<StatusDot size="xs" variant="danger" ariaHidden />
{visibleGuestStats().stopped} stopped
</span>
</Show>
</div>
</Show>
</div>
@@ -387,6 +387,12 @@ describe('ProxmoxBackupsTable', () => {
expect(proxmoxPageSurfaceSource).not.toContain(
'currentModel().summary.stoppedGuestCount} stopped',
);
expect(proxmoxPageSurfaceSource).toContain(
'<Show when={visibleGuestStats().running > 0}>',
);
expect(proxmoxPageSurfaceSource).toContain(
'<Show when={visibleGuestStats().stopped > 0}>',
);
});
it('keeps the shared storage surface scoped to the whole Proxmox product family', () => {
@@ -238,7 +238,7 @@ describe('ProxmoxPageSurface contract', () => {
render(() => <ProxmoxPageSurface />);
const totals = screen.getByTestId('proxmox-guest-totals');
expect(totals).toHaveTextContent('0 running');
expect(totals).not.toHaveTextContent('running');
expect(totals).not.toHaveTextContent('attention');
expect(totals).toHaveTextContent('1 stopped');
});
@@ -276,7 +276,7 @@ describe('useWorkloads', () => {
dispose();
});
it('keeps Proxmox power state stable while aggregate freshness changes, then removes an authoritative deletion', async () => {
it('keeps Proxmox power state stable while carrying aggregate health, then removes an authoritative deletion', async () => {
const guest = (vmid: number, status: string, runtimeStatus: string) => ({
...sampleResource,
id: `cluster-a-pve1-${vmid}`,
@@ -331,6 +331,11 @@ describe('useWorkloads', () => {
'running',
'running',
]);
expect(result!.workloads().map((workload) => workload.resourceStatus)).toEqual([
'warning',
'online',
'warning',
]);
apiFetchJSONMock.mockResolvedValueOnce({
data: [guest(101, 'online', 'running'), guest(102, 'online', 'running')],
@@ -462,6 +462,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
resource.status ||
(platformType === 'vmware-vsphere' ? resource.vmware?.powerState : null),
),
resourceStatus: (resource.status || '').trim().toLowerCase() || undefined,
type:
workloadType === 'vm'
? 'vm'
+2
View File
@@ -12,6 +12,8 @@ export type ViewMode = 'all' | 'vm' | WorkloadContainerViewMode | 'pod';
export type WorkloadGuest = (VM | Container) & {
workloadType?: WorkloadType;
/** Canonical unified-resource health, kept separate from runtime power state in `status`. */
resourceStatus?: string;
displayId?: string;
image?: string;
// Provider/runtime-native identifier for app-container actions such as Docker image updates.