diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 4205ccf7d..bcdeb95b6 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,36 +1,36 @@ { "version": 1, - "base_sha": "1807ab294cc53d397fb8b9e7ab1b8a3d3bca8137", - "verified_at": "2026-08-04T09:47:19Z", + "base_sha": "e86ca7da057a613a59d5cea24b8230d9a3c914df", + "verified_at": "2026-08-04T10:06:40Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx" + "frontend-modern/src/components/Workloads/useGuestRowState.ts", + "frontend-modern/src/components/Workloads/workloadSelectors.ts", + "frontend-modern/src/utils/availabilityProbePresentation.ts", + "frontend-modern/src/utils/status.ts" ], "content_sha256": { - "frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx": "05d38e5a9b7235804dd388b893f58b79818fbae50c90244edc077c6cbcb6c8b4" + "frontend-modern/src/components/Workloads/useGuestRowState.ts": "44d3b0d6ba5fa8a940dac638040aa193b8d471dd9e2d4714a6591aa9ad2ddbc7", + "frontend-modern/src/components/Workloads/workloadSelectors.ts": "a31b6f298a8fe56c9be698ba6361cf9f8151fda4ddc5171155bc5ca53aaf1bc6", + "frontend-modern/src/utils/availabilityProbePresentation.ts": "e51d19778743b991c81fea2d1e595b17c73e1cee35b40396e0583ce96b91371a", + "frontend-modern/src/utils/status.ts": "45b965582f025894b47bfa9d9fae43f6ef2308f71f481474138b84c548f97f50" }, - "routes": [ - "/settings/security-overview" - ], + "routes": ["/proxmox/overview"], "viewports": [ - { - "width": 1280, - "height": 720 - }, - { - "width": 390, - "height": 844 - } + { "width": 1440, "height": 900 }, + { "width": 390, "height": 844 } ], "states": [ - "Security Overview against the live dev backend as the local admin, a privileged /api/security/status caller, showing the real posture score of 60% Moderate with all seven posture cards populated from actual payload values", - "Recommended hardening steps callout rendered below the posture summary for the privileged session", - "Narrow 390x844 layout with the posture header and stacked posture cards rendering without horizontal document overflow" + "Proxmox Overview against the live backend with 34 guest rows, 24 running and 10 stopped, and 36 attached availability checks none of which are currently failing", + "Every guest row status dot resolved through the new health indicator, reporting only Running or Stopped with no probe-degraded rows", + "Narrow 390px layout with the same 34 rows and the same dot resolution" ], "interactions": [ - "Fetched /api/security/status from the page session and confirmed detailLevel is privileged with hasHTTPS false, exportProtected true, hasAuditLogging false, apiTokenConfigured true, each matching the rendered card states, so privileged rendering is unchanged by the new gate", - "Confirmed the new admin-session callout is absent for the privileged session and the posture summary still renders", - "Read console messages filtered to errors on both viewports and found none", - "The truncated authenticated-level branch, which now renders the admin-session explanation instead of false negatives, cannot be reached on this instance because a non-admin RBAC session requires Pro SSO role mappings; that branch is proven by the new unit tests in SecurityOverviewPanel.test.tsx which assert the callout appears and the posture score and hardening sections stay hidden" + "Reloaded /proxmox after the change and read every guest row status dot by aria-label, counting 24 Running and 10 Stopped against a live payload of exactly 24 running and 10 stopped guests", + "Confirmed zero rows resolved to the new not-responding state, so consolidating the dot onto the health indicator introduces no false positives on a fleet whose probes all pass", + "Resized to 390x844 and confirmed the same 34 dots and labels with no horizontal document overflow", + "Read console messages filtered to errors at both viewports and found none", + "The degraded path is proven by unit test rather than in the browser because no attached check is currently failing on this instance; the new getGuestHealthIndicator cases were confirmed red with the degradation branch disabled and green with it restored", + "Verification ran in the shared checkout against this exact staged file content; the commit was assembled in an isolated worktree because another agent held the shared index and had receipt edits in flight" ] } diff --git a/frontend-modern/src/components/Workloads/useGuestRowState.ts b/frontend-modern/src/components/Workloads/useGuestRowState.ts index db708f1f5..288b86a8f 100644 --- a/frontend-modern/src/components/Workloads/useGuestRowState.ts +++ b/frontend-modern/src/components/Workloads/useGuestRowState.ts @@ -6,7 +6,7 @@ import { useAlertsActivation } from '@/stores/alertsActivation'; import type { Container } from '@/types/api'; import type { DisplayMetricType } from '@/utils/metricThresholds'; import { buildMetricKey } from '@/utils/metricsKeys'; -import { getGuestPowerIndicator, isGuestRunning } from '@/utils/status'; +import { getGuestHealthIndicator, isGuestRunning } from '@/utils/status'; import { getShortImageName, formatBytes } from '@/utils/format'; import { getContainerRuntimeBadgeForRuntime } from '@/utils/resourceBadgePresentation'; import { @@ -234,7 +234,7 @@ export function useGuestRowState(props: GuestRowProps) { const parentOnline = createMemo(() => props.parentNodeOnline !== false); const isRunning = createMemo(() => isGuestRunning(props.guest, parentOnline())); - const guestStatus = createMemo(() => getGuestPowerIndicator(props.guest, parentOnline())); + const guestStatus = createMemo(() => getGuestHealthIndicator(props.guest, parentOnline())); const lockLabel = createMemo(() => (props.guest.lock || '').trim()); const hasUnacknowledgedAlert = createMemo(() => !!props.alertStyles?.hasUnacknowledgedAlert); diff --git a/frontend-modern/src/components/Workloads/workloadSelectors.ts b/frontend-modern/src/components/Workloads/workloadSelectors.ts index 7702beded..deb5b69da 100644 --- a/frontend-modern/src/components/Workloads/workloadSelectors.ts +++ b/frontend-modern/src/components/Workloads/workloadSelectors.ts @@ -6,6 +6,7 @@ import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInt import { parseFilterStack, evaluateFilterStack, splitSearchExclusions } from '@/utils/searchQuery'; import { normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms'; import { OFFLINE_HEALTH_STATUSES } from '@/utils/status'; +import { hasFailedAttachedAvailabilityCheck } from '@/utils/availabilityProbePresentation'; import { getNodeDisplayName } from '@/utils/nodes'; import { isContainerWorkloadViewMode, @@ -59,37 +60,6 @@ const classifyWorkloadStatus = (status: string): WorkloadStatusBucket => { return 'degraded'; }; -/** - * True when an availability probe that resolved to this guest has failed past - * its configured threshold. - * - * Only `attached` facets count: a standalone, ambiguous, or unresolved probe - * has not been proven to describe this guest. The threshold mirrors the gate - * the availability poller uses before raising `availability_unreachable`, so a - * guest lands in this bucket exactly when its check has alerted. - * - * The check resource remains the sole owner of that incident, its alert, and - * its history (see the availability check identity record of 2026-07-23). This - * mints nothing new; it only stops a guest whose service is unreachable from - * being bucketed as healthy, which today hides it from the Attention filter. - */ -const hasFailedAttachedAvailabilityCheck = (guest: WorkloadGuest): boolean => { - const checks = guest.availabilityChecks?.length - ? guest.availabilityChecks - : guest.availability - ? [guest.availability] - : []; - - return checks.some((check) => { - if (!check || check.enabled === false) return false; - if (check.available !== false) return false; - if (check.correlationState !== 'attached') return false; - const failures = check.consecutiveFailures ?? 0; - const threshold = Math.max(check.failureThreshold ?? 1, 1); - return failures >= threshold; - }); -}; - const resolveWorkloadStatusBucket = (guest: WorkloadGuest): WorkloadStatusBucket => { const resourceStatus = (guest.resourceStatus || '').trim().toLowerCase(); diff --git a/frontend-modern/src/utils/__tests__/status.test.ts b/frontend-modern/src/utils/__tests__/status.test.ts index 709b185c8..66cfe411a 100644 --- a/frontend-modern/src/utils/__tests__/status.test.ts +++ b/frontend-modern/src/utils/__tests__/status.test.ts @@ -10,6 +10,7 @@ import { isNodeOnline, isGuestRunning, getNodeStatusIndicator, + getGuestHealthIndicator, getGuestPowerIndicator, getAgentStatusIndicator, getDockerHostStatusIndicator, @@ -173,6 +174,91 @@ describe('getGuestPowerIndicator', () => { }); }); +describe('getGuestHealthIndicator', () => { + const failedAttachedProbe = { + enabled: true, + available: false, + correlationState: 'attached' as const, + consecutiveFailures: 3, + failureThreshold: 2, + }; + + it('degrades a running guest whose attached probe cannot reach it', () => { + const result = getGuestHealthIndicator( + { status: 'running', availability: failedAttachedProbe }, + true, + ); + expect(result.variant).toBe('warning'); + expect(result.label).toBe('Running, not responding to probe'); + }); + + it('leaves a running guest healthy when its probe is responding', () => { + const result = getGuestHealthIndicator( + { + status: 'running', + availability: { enabled: true, available: true, correlationState: 'attached' }, + }, + true, + ); + expect(result.variant).toBe('success'); + expect(result.label).toBe('Running'); + }); + + it('leaves a running guest healthy when no probe is configured', () => { + expect(getGuestHealthIndicator({ status: 'running' }, true).variant).toBe('success'); + }); + + // A probe that has not been proven to describe this guest, or that has not + // yet failed enough times for the poller to raise an incident, must not move + // the indicator ahead of the alert. + it('ignores probes that are not attached or are still inside the threshold', () => { + const standalone = getGuestHealthIndicator( + { + status: 'running', + availability: { ...failedAttachedProbe, correlationState: 'standalone' }, + }, + true, + ); + const belowThreshold = getGuestHealthIndicator( + { status: 'running', availability: { ...failedAttachedProbe, consecutiveFailures: 1 } }, + true, + ); + const disabled = getGuestHealthIndicator( + { status: 'running', availability: { ...failedAttachedProbe, enabled: false } }, + true, + ); + + expect(standalone.variant).toBe('success'); + expect(belowThreshold.variant).toBe('success'); + expect(disabled.variant).toBe('success'); + }); + + it('keeps power state for a stopped guest and an offline node', () => { + expect( + getGuestHealthIndicator({ status: 'stopped', availability: failedAttachedProbe }, true).label, + ).toBe('Stopped'); + expect( + getGuestHealthIndicator({ status: 'running', availability: failedAttachedProbe }, false) + .label, + ).toBe('Node offline'); + }); + + it('degrades when any one of several projected checks has failed', () => { + const result = getGuestHealthIndicator( + { + status: 'running', + availability: { enabled: true, available: true, correlationState: 'attached' }, + availabilityChecks: [ + { enabled: true, available: true, correlationState: 'attached' }, + failedAttachedProbe, + ], + }, + true, + ); + expect(result.variant).toBe('warning'); + }); +}); + describe('getAgentStatusIndicator', () => { it('returns muted for null/undefined', () => { expect(getAgentStatusIndicator(null)).toEqual({ variant: 'muted', label: 'Unknown' }); diff --git a/frontend-modern/src/utils/availabilityProbePresentation.ts b/frontend-modern/src/utils/availabilityProbePresentation.ts index 21cf57b05..74bc43856 100644 --- a/frontend-modern/src/utils/availabilityProbePresentation.ts +++ b/frontend-modern/src/utils/availabilityProbePresentation.ts @@ -80,6 +80,40 @@ export const getAvailabilityProbeEndpointLabel = ( return addressWithPort; }; +/** + * True when an availability probe that resolved to this resource has failed + * past its configured threshold. + * + * Only `attached` facets count: a standalone, ambiguous, or unresolved probe + * has not been proven to describe this resource. The threshold mirrors the gate + * the availability poller uses before raising `availability_unreachable`, so + * every surface built on this agrees with the alert rather than running ahead + * of it. + * + * This reads the facet already projected onto the resource. The check resource + * remains the sole owner of the incident, its alert and its history, per the + * availability check identity record of 2026-07-23. + */ +export const hasFailedAttachedAvailabilityCheck = (source: { + availability?: ResourceAvailabilityMeta | null; + availabilityChecks?: ResourceAvailabilityMeta[] | null; +}): boolean => { + const checks = source.availabilityChecks?.length + ? source.availabilityChecks + : source.availability + ? [source.availability] + : []; + + return checks.some((check) => { + if (!check || check.enabled === false) return false; + if (check.available !== false) return false; + if (check.correlationState !== 'attached') return false; + const failures = check.consecutiveFailures ?? 0; + const threshold = Math.max(check.failureThreshold ?? 1, 1); + return failures >= threshold; + }); +}; + const getAvailabilityProbeFailureLabel = (availability: ResourceAvailabilityMeta): string => { const lastError = (availability.lastError ?? '').trim(); const normalizedError = lastError.toLowerCase(); diff --git a/frontend-modern/src/utils/status.ts b/frontend-modern/src/utils/status.ts index 0b03e0061..4724dfad0 100644 --- a/frontend-modern/src/utils/status.ts +++ b/frontend-modern/src/utils/status.ts @@ -9,6 +9,8 @@ import type { DockerService, ReplicationJob, } from '@/types/api'; +import type { ResourceAvailabilityMeta } from '@/types/resource'; +import { hasFailedAttachedAvailabilityCheck } from '@/utils/availabilityProbePresentation'; const ONLINE_STATUS = 'online'; const RUNNING_STATUS = 'running'; @@ -216,6 +218,36 @@ export function getGuestPowerIndicator( : { variant: 'danger', label: 'Stopped' }; } +/** + * Row indicator for a workload guest: power state, degraded when an + * availability probe attached to this guest cannot reach it. + * + * The hypervisor only knows whether a guest is powered on. A guest that is up + * while the service behind it stops answering is not healthy, and plain + * "Running" hides that. This keeps the indicator agreeing with the Attention + * bucket, which resolves the same probe facet through the same predicate. + * + * A guest that is not running keeps its power state unchanged: a probe says + * nothing useful about a guest nobody expects to answer. + */ +export function getGuestHealthIndicator( + guest: + | (Partial & { + availability?: ResourceAvailabilityMeta; + availabilityChecks?: ResourceAvailabilityMeta[]; + }) + | undefined + | null, + parentNodeOnline = true, +): StatusIndicator { + const power = getGuestPowerIndicator(guest, parentNodeOnline); + if (!guest || power.variant !== 'success') return power; + if (hasFailedAttachedAvailabilityCheck(guest)) { + return { variant: 'warning', label: 'Running, not responding to probe' }; + } + return power; +} + export function getAgentStatusIndicator(agent: Partial | undefined | null): StatusIndicator { if (!agent) return defaultIndicator; const status = normalize(agent.status);