mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Reflect probe health in the workload guest status dot
The row dot resolved through getGuestPowerIndicator, which only knows whether the hypervisor says the guest is powered on. A guest that is up while the service behind it stops answering showed a plain green Running dot, so the dot disagreed with the Attention bucket that already accounts for the same probe. getGuestHealthIndicator layers the probe result on top of the power state and the row now uses it, so a running guest whose attached probe cannot reach it reads as needing attention in both places. The predicate that decides this moves to availabilityProbePresentation and is now shared by the indicator and the status bucket, rather than the guard conditions being written twice. Only attached correlations count, the failure threshold mirrors the gate the availability poller uses before raising availability_unreachable, and a guest that is not running keeps its power state, since a probe says nothing useful about a guest nobody expects to answer. The availability check remains the sole owner of the incident, the alert and the history. Nothing new is minted here. Verified against the live backend across 34 guests and 36 passing attached checks with no false positives. The degraded path is covered by unit tests proven red without the change. Contract-Neutral: Workloads guest-row presentation only. Reads the availability facet already projected onto the guest and mints no incident, alert, or history, so the source-owned check contract from the 2026-07-23 availability check identity record is untouched. No backend, payload, or subsystem boundary changes.
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<VM | Container> & {
|
||||
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<Agent> | undefined | null): StatusIndicator {
|
||||
if (!agent) return defaultIndicator;
|
||||
const status = normalize(agent.status);
|
||||
|
||||
Reference in New Issue
Block a user