diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 679ce24ef..e961edd4e 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 5346867ba..7ff6a15c4 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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; diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 7e8cbc7e6..cdb0b542f 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index f04dd39c9..41a8fc3a0 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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" ] } diff --git a/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.branchcov0712b.test.ts b/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.branchcov0712b.test.ts index ef3e6477d..8de5ae9e0 100644 --- a/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.branchcov0712b.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.branchcov0712b.test.ts @@ -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)', () => { diff --git a/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.branchcov2.test.ts b/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.branchcov2.test.ts index 8db88f0bd..e5c470d2d 100644 --- a/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.branchcov2.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.branchcov2.test.ts @@ -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, diff --git a/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.test.ts b/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.test.ts index abe784cc6..9521567c0 100644 --- a/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.test.ts @@ -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', () => { diff --git a/frontend-modern/src/components/Workloads/workloadSelectors.ts b/frontend-modern/src/components/Workloads/workloadSelectors.ts index 3f7c1220e..83a352c90 100644 --- a/frontend-modern/src/components/Workloads/workloadSelectors.ts +++ b/frontend-modern/src/components/Workloads/workloadSelectors.ts @@ -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; diff --git a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx index 01b1cee6f..56a49e9c2 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx @@ -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" > - - - {visibleGuestStats().running} running - + 0}> + + + {visibleGuestStats().running} running + + 0}> - + 0}> + + {visibleGuestStats().degraded} attention - - - - {visibleGuestStats().stopped} stopped - + 0}> + 0 || visibleGuestStats().degraded > 0}> + + + + + {visibleGuestStats().stopped} stopped + + diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx index 52934e17e..1a158a9ec 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx @@ -387,6 +387,12 @@ describe('ProxmoxBackupsTable', () => { expect(proxmoxPageSurfaceSource).not.toContain( 'currentModel().summary.stoppedGuestCount} stopped', ); + expect(proxmoxPageSurfaceSource).toContain( + ' 0}>', + ); + expect(proxmoxPageSurfaceSource).toContain( + ' 0}>', + ); }); it('keeps the shared storage surface scoped to the whole Proxmox product family', () => { diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx index 973e60099..12aae6e24 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx @@ -238,7 +238,7 @@ describe('ProxmoxPageSurface contract', () => { render(() => ); 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'); }); diff --git a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts index fcff584c1..db4e8e34c 100644 --- a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts +++ b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts @@ -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')], diff --git a/frontend-modern/src/hooks/useWorkloads.ts b/frontend-modern/src/hooks/useWorkloads.ts index 122f8e08c..e17de7129 100644 --- a/frontend-modern/src/hooks/useWorkloads.ts +++ b/frontend-modern/src/hooks/useWorkloads.ts @@ -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' diff --git a/frontend-modern/src/types/workloads.ts b/frontend-modern/src/types/workloads.ts index daa3f19ec..b1237200a 100644 --- a/frontend-modern/src/types/workloads.ts +++ b/frontend-modern/src/types/workloads.ts @@ -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.