diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 7f010174c5..4cc867961f 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1761,6 +1761,17 @@ payload shape change when the portal presents compact client rows. `frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx`, `frontend-modern/src/components/Settings/availabilitySettingsModel.ts`, and `AvailabilityTargetSlot.tsx` own the browser transport shape. + The settings summary and status pills classify each target through + `getAvailabilityTargetHealth`. When the target's unified + `network-endpoint` resource is loaded, the Machines tab's + `getStandaloneResourceStatusIndicator` decides, so stale evidence and + unresolved identity count the same on both pages. Otherwise the target's + own status is read the way `availability_poller.go` derives the resource + status: aggregate and single-location failures are offline only once + consecutive failures reach the failure threshold (default 2), a missing + count is zero, and a never-checked target is pending. Settings and the + Machines availability tab therefore report the same counts in the same + words. Availability target probe-result and error notices may compose frontend-primitives' `CalloutCard` for shared settings callout chrome; API contracts own the target CRUD/test payload semantics and endpoint diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 6eb8d0dd48..573094ff1b 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -7320,7 +7320,9 @@ successful evidence must render an amber `Stale` state with no green must not appear as a primary row in the Machines `Availability checks` tab; the distinct source-owned `network-endpoint` for that configured check must appear there regardless of whether its correlation state is `attached`, -`standalone`, `ambiguous`, or `unresolved`. +`standalone`, `ambiguous`, or `unresolved`. The tab's summary card owns the one +"Manage checks" link; the checks table toolbar keeps only "Add service/device +check". Operational navigation for those agentless endpoints belongs to the frontend-primitives-owned Machines surface as a focused Availability checks tab rather than a new primary nav item. The page may show availability checks beside diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 0a8439f2df..b367c7365b 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -3195,7 +3195,11 @@ as separate buckets in `standalonePageModel.ts`, include every source-owned `network-endpoint` regardless of correlation state, exclude matched machine or service projections from the check inventory by resource type, and let `AvailabilityChecksTable.tsx` render saved probe method, target, latest result, -check age, failure count, and cadence from the canonical availability payload. +24h uptime, check age, last healthy time and failure count from the canonical +availability payload, with cadence restored only on the widest tables. Uptime +comes from the shared availability history summary the fleet view reads and +stays blank below 90 percent observed. The failure count reads "N in a row", +with the offline threshold on hover, not an "N/M" fraction. Recent check timing and fuller failure context may stay in tooltip or drawer detail, but the table row must not duplicate the same probe protocol and result text across both identity and metric cells. diff --git a/frontend-modern/browser-verification/5c2d883de1ff1863.json b/frontend-modern/browser-verification/5c2d883de1ff1863.json new file mode 100644 index 0000000000..d9bc2311e8 --- /dev/null +++ b/frontend-modern/browser-verification/5c2d883de1ff1863.json @@ -0,0 +1,43 @@ +{ + "version": 1, + "base_sha": "a82a8e04747263f5c49083f4c537e03040ba3c62", + "verified_at": "2026-10-05T04:30:26Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx", + "frontend-modern/src/components/Settings/availabilitySettingsModel.ts", + "frontend-modern/src/features/standalone/AvailabilityChecksTable.tsx", + "frontend-modern/src/features/standalone/AvailabilityFleetView.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx": "77f6f24d33113906bfeba21b64a7f6e2bbc94a38e1710b63e5e1a27ab5519627", + "frontend-modern/src/components/Settings/availabilitySettingsModel.ts": "6caefd3a0129be4c55e28e44ddb6c78852b7687220968f3c55d363fe392e81e0", + "frontend-modern/src/features/standalone/AvailabilityChecksTable.tsx": "e4f00d6188940447fb69a0aa35812874f9bffaa24bb1fcd9e9766ecc599fa792", + "frontend-modern/src/features/standalone/AvailabilityFleetView.tsx": "51d47d8341e489103e5a4330c1c02e2b9c62919bf7da475490eb28eb19aa5a32" + }, + "routes": [ + "/settings/monitoring/availability", + "/standalone/availability" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Settings summary 3 need attention \u00b7 1 offline \u00b7 7 enabled now matches the Machines availability summary (1 check offline, 3 need attention); before Settings said 2 down \u00b7 7 enabled", + "Solar inverter (503, 1 of 2 failures) shows an amber pill with hover text 1 failed check in a row. It counts as offline after 2; Workshop door controller (timed out, 3 in a row) stays red", + "Machines availability table at 1440: Check, Method, Target, Result, Uptime 24h, Checked, Last healthy, Failures; failures read 3 in a row / 1 in a row with the threshold on hover; Interval returns only on tables at least 104rem wide (verified at 1900)", + "Machines availability toolbar keeps Add service/device check; the duplicate Manage link is gone, Manage checks stays in the summary card", + "Phone 390: unchanged five-column projection" + ], + "interactions": [ + "Compared Settings and Machines summaries and pill colours live", + "Measured column visibility at 1440, 1700 and 1900 px" + ] +} diff --git a/frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx b/frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx index 8b07446175..b33ccd8116 100644 --- a/frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx +++ b/frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx @@ -1,4 +1,6 @@ import { Show, createEffect, createMemo, createSignal, onMount, type Component } from 'solid-js'; +import type { Resource } from '@/types/resource'; +import { isAgentlessAvailabilityResource } from '@/features/standalone/standalonePageModel'; import { useLocation, useNavigate } from '@solidjs/router'; import Plus from 'lucide-solid/icons/plus'; import RotateCw from 'lucide-solid/icons/rotate-cw'; @@ -28,6 +30,7 @@ import { getAvailabilityTargetProbeSourceLabel, getAvailabilityTargetStatusClass, getAvailabilityTargetStatusLabel, + getAvailabilityTargetStatusTitle, getAvailabilityTargetsSummary, shouldOpenAvailabilityTargetAddDialog, } from './availabilitySettingsModel'; @@ -45,6 +48,19 @@ export const AvailabilitySettingsPanel: Component = () => { const navigate = useNavigate(); const { resources } = useResources(); const probeAgentOptions = createMemo(() => buildProbeAgentOptions(resources())); + // The Machines availability tab reads these same resources, so the counts and + // colours here come from the same status logic when they are loaded. + const availabilityResourceByTargetId = createMemo(() => { + const byId = new Map(); + for (const resource of resources()) { + if (!isAgentlessAvailabilityResource(resource)) continue; + const targetId = resource.availability?.targetId; + if (targetId) byId.set(targetId, resource); + } + return byId; + }); + const resourceForTarget = (target: AvailabilityTarget) => + availabilityResourceByTargetId().get(target.id); const [targets, setTargets] = createSignal([]); const [loading, setLoading] = createSignal(false); const [error, setError] = createSignal(null); @@ -185,7 +201,7 @@ export const AvailabilitySettingsPanel: Component = () => {
- {getAvailabilityTargetsSummary(targets())} + {getAvailabilityTargetsSummary(targets(), resourceForTarget)}

Monitor endpoint-only devices and services with ICMP, TCP, and HTTP probes. @@ -272,7 +288,11 @@ export const AvailabilitySettingsPanel: Component = () => { {target.name}

{getAvailabilityTargetStatusLabel(target)} diff --git a/frontend-modern/src/components/Settings/__tests__/AvailabilitySettingsPanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/AvailabilitySettingsPanel.test.tsx index 197d60a8ae..edc8dd4772 100644 --- a/frontend-modern/src/components/Settings/__tests__/AvailabilitySettingsPanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/AvailabilitySettingsPanel.test.tsx @@ -177,6 +177,54 @@ describe('AvailabilitySettingsPanel', () => { expect(screen.getByText('No recent probe report')).toBeInTheDocument(); }); + it('counts a check as offline only after its failure threshold, like Machines', async () => { + vi.mocked(AvailabilityTargetsAPI.list).mockResolvedValue([ + { + ...targets[0], + id: 'solar', + name: 'Solar inverter', + failureThreshold: 2, + status: { + ...targets[0].status!, + targetId: 'solar', + available: false, + lastChecked: '2026-10-05T00:00:00Z', + consecutiveFailures: 1, + failureThreshold: 2, + lastError: 'http probe returned 503 Service Unavailable', + }, + }, + { + ...targets[0], + id: 'door', + name: 'Door controller', + failureThreshold: 2, + status: { + ...targets[0].status!, + targetId: 'door', + available: false, + lastChecked: '2026-10-05T00:00:00Z', + consecutiveFailures: 3, + failureThreshold: 2, + lastError: 'icmp probe timed out', + }, + }, + ]); + + render(() => ); + + await waitFor(() => + expect(screen.getByText('2 need attention · 1 offline · 2 enabled')).toBeInTheDocument(), + ); + const failing = screen.getByText('http probe returned 503 Service Unavailable'); + expect(failing.className).toContain('bg-amber-100'); + expect(failing).toHaveAttribute( + 'title', + '1 failed check in a row. It counts as offline after 2.', + ); + expect(screen.getByText('icmp probe timed out').className).toContain('bg-rose-100'); + }); + it('opens add and edit dialogs from the canonical availability route', async () => { render(() => ); diff --git a/frontend-modern/src/components/Settings/__tests__/availabilitySettingsModel.branchcov0724pm.test.ts b/frontend-modern/src/components/Settings/__tests__/availabilitySettingsModel.branchcov0724pm.test.ts index c2fe29dc66..f08dab710b 100644 --- a/frontend-modern/src/components/Settings/__tests__/availabilitySettingsModel.branchcov0724pm.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/availabilitySettingsModel.branchcov0724pm.test.ts @@ -93,7 +93,11 @@ describe('getAvailabilityTargetStatusClass (indeterminate arm)', () => { expect( getAvailabilityTargetStatusClass( target({ - status: status({ available: false, outcome: 'indeterminate' }), + status: status({ + available: false, + outcome: 'indeterminate', + lastChecked: '2026-10-05T00:00:00Z', + }), }), ), ).toBe('bg-amber-100 text-amber-700 dark:bg-amber-900/25 dark:text-amber-300'); diff --git a/frontend-modern/src/components/Settings/__tests__/availabilitySettingsModel.test.ts b/frontend-modern/src/components/Settings/__tests__/availabilitySettingsModel.test.ts index d99d7785f6..024ef01073 100644 --- a/frontend-modern/src/components/Settings/__tests__/availabilitySettingsModel.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/availabilitySettingsModel.test.ts @@ -7,8 +7,10 @@ import { getAvailabilityTargetKindLabel, getAvailabilityTargetMethodLabel, getAvailabilityTargetProbeSourceLabel, + getAvailabilityTargetHealth, getAvailabilityTargetStatusClass, getAvailabilityTargetStatusLabel, + getAvailabilityTargetStatusTitle, getAvailabilityTargetsSummary, normalizeAvailabilityTargetKind, shouldOpenAvailabilityTargetAddDialog, @@ -90,7 +92,13 @@ describe('availabilitySettingsModel', () => { expect( getAvailabilityTargetStatusLabel( target({ - status: { ...target(), targetId: 'mqtt-broker', available: true, latencyMillis: 12 }, + status: { + ...target(), + targetId: 'mqtt-broker', + available: true, + lastChecked: '2026-10-05T00:00:00Z', + latencyMillis: 12, + }, }), ), ).toBe('Online · 12 ms'); @@ -102,10 +110,15 @@ describe('availabilitySettingsModel', () => { name: 'HTTP health', protocol: 'http', address: 'http://service.local', - status: { ...target(), targetId: 'http-health', available: false }, + status: { + ...target(), + targetId: 'http-health', + available: false, + lastChecked: '2026-10-05T00:00:00Z', + }, }), ]), - ).toBe('1 down · 2 enabled'); + ).toBe('1 needs attention · 2 enabled'); expect( getAvailabilityTargetsSummary([ target({ @@ -116,16 +129,22 @@ describe('availabilitySettingsModel', () => { targetId: 'steam-server', protocol: 'udp', available: false, + lastChecked: '2026-10-05T00:00:00Z', outcome: 'indeterminate', }, }), ]), - ).toBe('1 open or filtered · 1 enabled'); + ).toBe('1 needs attention · 1 enabled'); expect( getAvailabilityTargetsSummary([ target({ id: 'closed-port', - status: { ...target(), targetId: 'closed-port', available: false }, + status: { + ...target(), + targetId: 'closed-port', + available: false, + lastChecked: '2026-10-05T00:00:00Z', + }, }), target({ id: 'silent-port', @@ -136,11 +155,12 @@ describe('availabilitySettingsModel', () => { targetId: 'silent-port', protocol: 'udp', available: false, + lastChecked: '2026-10-05T00:00:00Z', outcome: 'indeterminate', }, }), ]), - ).toBe('1 down · 1 open or filtered · 2 enabled'); + ).toBe('2 need attention · 2 enabled'); expect(getAvailabilityTargetStatusClass(target({ enabled: false }))).toBe( 'bg-surface-alt text-muted', ); @@ -150,13 +170,27 @@ describe('availabilitySettingsModel', () => { expect( getAvailabilityTargetStatusClass( target({ - status: { ...target(), targetId: 'mqtt-broker', available: true, latencyMillis: 12 }, + status: { + ...target(), + targetId: 'mqtt-broker', + available: true, + lastChecked: '2026-10-05T00:00:00Z', + latencyMillis: 12, + }, }), ), ).toBe('bg-emerald-100 text-emerald-700 dark:bg-emerald-900/25 dark:text-emerald-300'); expect( getAvailabilityTargetStatusClass( - target({ status: { ...target(), targetId: 'mqtt-broker', available: false } }), + target({ + status: { + ...target(), + targetId: 'mqtt-broker', + available: false, + lastChecked: '2026-10-05T00:00:00Z', + consecutiveFailures: 2, + }, + }), ), ).toBe('bg-rose-100 text-rose-700 dark:bg-rose-900/25 dark:text-rose-300'); }); @@ -169,6 +203,7 @@ describe('availabilitySettingsModel', () => { ...target(), targetId: 'mqtt-broker', available: true, + lastChecked: '2026-10-05T00:00:00Z', latencyMillis: 12, probeAgentId: 'host-edge-01', }, @@ -190,6 +225,7 @@ describe('availabilitySettingsModel', () => { ...target(), targetId: 'mqtt-broker', available: true, + lastChecked: '2026-10-05T00:00:00Z', probeAgentId: 'host-gone', }, }), @@ -205,6 +241,7 @@ describe('availabilitySettingsModel', () => { ...target(), targetId: 'mqtt-broker', available: false, + lastChecked: '2026-10-05T00:00:00Z', outcome: 'indeterminate', lastError: 'no recent report from probe agent', probeAgentId: 'host-edge-01', @@ -224,10 +261,81 @@ describe('availabilitySettingsModel', () => { ...target(), targetId: 'mqtt-broker', available: true, + lastChecked: '2026-10-05T00:00:00Z', outcome: 'indeterminate', }, }), ), ).toBe('Open or filtered'); }); + + it('counts a failing check as offline only after its failure threshold, like Machines', () => { + const failing = (consecutiveFailures: number) => + target({ + failureThreshold: 2, + status: { + ...target(), + targetId: 'solar', + available: false, + lastChecked: '2026-10-05T00:00:00Z', + consecutiveFailures, + failureThreshold: 2, + lastError: 'http probe returned 503 Service Unavailable', + }, + }); + + expect(getAvailabilityTargetHealth(failing(1))).toBe('attention'); + expect(getAvailabilityTargetHealth(failing(2))).toBe('offline'); + expect(getAvailabilityTargetsSummary([failing(1), failing(3), target()])).toBe( + '2 need attention · 1 offline · 3 enabled', + ); + expect(getAvailabilityTargetStatusClass(failing(1))).toContain('bg-amber-100'); + expect(getAvailabilityTargetStatusTitle(failing(1))).toBe( + '1 failed check in a row. It counts as offline after 2.', + ); + expect(getAvailabilityTargetStatusTitle(failing(2))).toBeUndefined(); + }); + + it('keeps the threshold for a single-location check the backend marks unavailable', () => { + const unavailable = (consecutiveFailures: number) => + target({ + status: { + ...target(), + targetId: 'mqtt-broker', + available: false, + lastChecked: '2026-10-05T00:00:00Z', + aggregateState: 'unavailable', + consecutiveFailures, + }, + }); + + // Default threshold is 2, as in internal/config/availability.go. + expect(getAvailabilityTargetHealth(unavailable(1))).toBe('attention'); + expect(getAvailabilityTargetHealth(unavailable(2))).toBe('offline'); + }); + + it('defers to the Machines status of the loaded resource', () => { + const healthyTarget = target({ + status: { + ...target(), + targetId: 'mqtt-broker', + available: true, + lastChecked: '2026-10-05T00:00:00Z', + }, + }); + const unresolvedResource = { + id: 'availability:mqtt-broker', + type: 'network-endpoint', + name: 'MQTT broker', + status: 'online', + lastSeen: Date.now(), + availability: { targetId: 'mqtt-broker', correlationState: 'unresolved' }, + } as never; + + expect(getAvailabilityTargetHealth(healthyTarget)).toBe('healthy'); + expect(getAvailabilityTargetHealth(healthyTarget, unresolvedResource)).toBe('attention'); + expect(getAvailabilityTargetsSummary([healthyTarget], () => unresolvedResource)).toBe( + '1 needs attention · 1 enabled', + ); + }); }); diff --git a/frontend-modern/src/components/Settings/availabilitySettingsModel.ts b/frontend-modern/src/components/Settings/availabilitySettingsModel.ts index f603c41be4..9d8c09edd5 100644 --- a/frontend-modern/src/components/Settings/availabilitySettingsModel.ts +++ b/frontend-modern/src/components/Settings/availabilitySettingsModel.ts @@ -1,4 +1,6 @@ import type { AvailabilityTarget, AvailabilityTargetKind } from '@/api/availabilityTargets'; +import type { Resource } from '@/types/resource'; +import { getStandaloneResourceStatusIndicator } from '@/features/standalone/standalonePageModel'; import { PROBE_AGENT_STALE_LABEL, getProbeSourceChipLabel, @@ -129,22 +131,83 @@ export function getAvailabilityTargetStatusLabel(target: AvailabilityTarget): st return status.lastError?.trim() || 'Offline'; } -export function getAvailabilityTargetStatusClass(target: AvailabilityTarget): string { - if (!target.enabled) return 'bg-surface-alt text-muted'; - if (!target.status) return 'bg-sky-100 text-sky-700 dark:bg-sky-900/25 dark:text-sky-300'; - if (target.status.aggregateState === 'degraded' || target.status.aggregateState === 'unknown') { - return 'bg-amber-100 text-amber-700 dark:bg-amber-900/25 dark:text-amber-300'; +// One classification for a check, shared with the Machines availability tab. +// When the check's unified resource is loaded, its Machines status indicator +// decides (so stale evidence and unresolved identity count the same way on +// both pages). Without it, the target's own status is read the way +// availability_poller.go derives the resource status: a failing probe is +// offline only once its consecutive failures reach the failure threshold +// (default 2) and needs attention before that. +export type AvailabilityTargetHealth = 'paused' | 'pending' | 'healthy' | 'attention' | 'offline'; + +const DEFAULT_AVAILABILITY_FAILURE_THRESHOLD = 2; + +const availabilityFailureThreshold = (target: AvailabilityTarget): number => { + const configured = target.status?.failureThreshold ?? target.failureThreshold; + return typeof configured === 'number' && configured > 0 + ? configured + : DEFAULT_AVAILABILITY_FAILURE_THRESHOLD; +}; + +export function getAvailabilityTargetHealth( + target: AvailabilityTarget, + resource?: Resource, +): AvailabilityTargetHealth { + if (!target.enabled) return 'paused'; + if (resource) { + const variant = getStandaloneResourceStatusIndicator(resource).variant; + if (variant === 'success') return 'healthy'; + if (variant === 'warning') return 'attention'; + if (variant === 'danger') return 'offline'; + return 'pending'; } - if (target.status.aggregateState === 'unavailable') { - return 'bg-rose-100 text-rose-700 dark:bg-rose-900/25 dark:text-rose-300'; + const status = target.status; + if (!status) return 'pending'; + if (isProbeAgentStaleStatus(status)) return 'attention'; + const failures = status.consecutiveFailures ?? 0; + const thresholdReached = failures >= availabilityFailureThreshold(target); + switch (status.aggregateState) { + case 'healthy': + return 'healthy'; + case 'degraded': + case 'unknown': + return 'attention'; + case 'unavailable': + return thresholdReached ? 'offline' : 'attention'; } - if (target.status.outcome === 'indeterminate') { - return 'bg-amber-100 text-amber-700 dark:bg-amber-900/25 dark:text-amber-300'; + if (!status.lastChecked) return 'pending'; + if (status.available) return 'healthy'; + return thresholdReached ? 'offline' : 'attention'; +} + +/** Hover text for a check that is failing but not yet offline. */ +export function getAvailabilityTargetStatusTitle( + target: AvailabilityTarget, + resource?: Resource, +): string | undefined { + if (getAvailabilityTargetHealth(target, resource) !== 'attention') return undefined; + const status = target.status; + const failures = status?.consecutiveFailures; + if (status?.available !== false || typeof failures !== 'number' || failures <= 0) { + return undefined; } - if (target.status.available) { - return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/25 dark:text-emerald-300'; - } - return 'bg-rose-100 text-rose-700 dark:bg-rose-900/25 dark:text-rose-300'; + const threshold = availabilityFailureThreshold(target); + return `${failures} failed ${failures === 1 ? 'check' : 'checks'} in a row. It counts as offline after ${threshold}.`; +} + +const AVAILABILITY_HEALTH_CLASS: Record = { + paused: 'bg-surface-alt text-muted', + pending: 'bg-sky-100 text-sky-700 dark:bg-sky-900/25 dark:text-sky-300', + healthy: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/25 dark:text-emerald-300', + attention: 'bg-amber-100 text-amber-700 dark:bg-amber-900/25 dark:text-amber-300', + offline: 'bg-rose-100 text-rose-700 dark:bg-rose-900/25 dark:text-rose-300', +}; + +export function getAvailabilityTargetStatusClass( + target: AvailabilityTarget, + resource?: Resource, +): string { + return AVAILABILITY_HEALTH_CLASS[getAvailabilityTargetHealth(target, resource)]; } /** @@ -161,22 +224,20 @@ export function getAvailabilityTargetProbeSourceLabel( return getProbeSourceChipLabel(probeAgentOptions, target.status?.probeAgentId); } -export function getAvailabilityTargetsSummary(targets: readonly AvailabilityTarget[]): string { - const enabled = targets.filter((target) => target.enabled).length; - const indeterminate = targets.filter( - (target) => target.enabled && target.status?.outcome === 'indeterminate', - ).length; - const down = targets.filter( - (target) => - target.enabled && - target.status?.available === false && - target.status.outcome !== 'indeterminate', - ).length; +// The same words and buckets as the Machines availability summary: every +// unhealthy check needs attention, and offline ones are also named, so the two +// pages never report different counts for the same checks. +export function getAvailabilityTargetsSummary( + targets: readonly AvailabilityTarget[], + resourceFor: (target: AvailabilityTarget) => Resource | undefined = () => undefined, +): string { if (targets.length === 0) return 'No availability checks configured'; - if (down > 0 && indeterminate > 0) { - return `${down} down · ${indeterminate} open or filtered · ${enabled} enabled`; - } - if (down > 0) return `${down} down · ${enabled} enabled`; - if (indeterminate > 0) return `${indeterminate} open or filtered · ${enabled} enabled`; - return `${enabled} enabled · ${targets.length} total`; + const enabled = targets.filter((target) => target.enabled).length; + const health = targets.map((target) => getAvailabilityTargetHealth(target, resourceFor(target))); + const offline = health.filter((state) => state === 'offline').length; + const attention = health.filter((state) => state === 'attention' || state === 'offline').length; + if (attention === 0) return `${enabled} enabled · ${targets.length} total`; + const parts = [`${attention} ${attention === 1 ? 'needs' : 'need'} attention`]; + if (offline > 0) parts.push(`${offline} offline`); + return `${parts.join(' · ')} · ${enabled} enabled`; } diff --git a/frontend-modern/src/features/standalone/AvailabilityChecksTable.tsx b/frontend-modern/src/features/standalone/AvailabilityChecksTable.tsx index ec6718b76c..1d7debdaee 100644 --- a/frontend-modern/src/features/standalone/AvailabilityChecksTable.tsx +++ b/frontend-modern/src/features/standalone/AvailabilityChecksTable.tsx @@ -1,7 +1,14 @@ import { A } from '@solidjs/router'; -import { Show, createMemo, createResource, type Component, type JSX } from 'solid-js'; +import { + Show, + createMemo, + createResource, + onCleanup, + onMount, + type Component, + type JSX, +} from 'solid-js'; import PlusIcon from 'lucide-solid/icons/plus'; -import SettingsIcon from 'lucide-solid/icons/settings'; import { MetadataBadge } from '@/components/shared/MetadataBadge'; import { FilterSegmentedControl } from '@/components/shared/FilterToolbar'; import { StatusDot } from '@/components/shared/StatusDot'; @@ -36,15 +43,12 @@ import { getAvailabilityProbePresentation, } from '@/utils/availabilityProbePresentation'; import { getProbeSourceChipLabel, type ProbeAgentOption } from '@/utils/availabilityProbeAgents'; -import { - buildAvailabilitySettingsPath, - buildAvailabilityTargetAddPath, -} from '@/components/Settings/availabilitySettingsModel'; +import { buildAvailabilityTargetAddPath } from '@/components/Settings/availabilitySettingsModel'; import { getStandaloneResourceStatusIndicator, sortStandaloneResourcesByAttention, } from './standalonePageModel'; -import { AvailabilityFleetView } from './AvailabilityFleetView'; +import { AvailabilityFleetView, getAvailabilityUptimeCell } from './AvailabilityFleetView'; export type AvailabilityChecksView = 'table' | 'fleet'; @@ -77,16 +81,32 @@ const formatTarget = (resource: Resource): string => { return getAvailabilityProbeEndpointLabel(availability) || resource.name; }; -const formatFailures = (availability: ResourceAvailabilityMeta | undefined): string => { +// "3/2" read as a fraction. Say what it counts and, on hover, when it turns +// into offline. +const formatFailures = ( + availability: ResourceAvailabilityMeta | undefined, +): { label: string; title?: string } => { const failures = availability?.consecutiveFailures; - if (typeof failures !== 'number' || !Number.isFinite(failures) || failures <= 0) return '—'; - const threshold = availability?.failureThreshold; - if (typeof threshold === 'number' && Number.isFinite(threshold) && threshold > 0) { - return `${failures}/${threshold}`; + if (typeof failures !== 'number' || !Number.isFinite(failures) || failures <= 0) { + return { label: '—' }; } - return String(failures); + const threshold = availability?.failureThreshold; + const title = + typeof threshold === 'number' && Number.isFinite(threshold) && threshold > 0 + ? `${failures} failed ${failures === 1 ? 'check' : 'checks'} in a row. It counts as offline after ${threshold}.` + : undefined; + return { label: `${failures} in a row`, title }; }; +const AVAILABILITY_HISTORY_REFRESH_MS = 5 * 60 * 1000; + +const UPTIME_TONE_CLASS = { + default: 'text-base-content', + warning: 'text-amber-700 dark:text-amber-300', + danger: 'text-red-600 dark:text-red-300', + muted: 'text-muted', +} as const; + export const AvailabilityChecksTable: Component<{ resources: Resource[]; emptyIcon: JSX.Element; @@ -130,10 +150,9 @@ export const AvailabilityChecksTable: Component<{ .map((resource) => availabilityFor(resource)?.targetId) .filter((targetID): targetID is string => Boolean(targetID)), ); + // The table's Uptime 24h column and the fleet view read the same history. const historySource = createMemo(() => - (props.view ?? 'table') === 'fleet' && historyTargetIDs().length > 0 - ? historyTargetIDs().join('\u0000') - : undefined, + historyTargetIDs().length > 0 ? historyTargetIDs().join('\u0000') : undefined, ); const [history, historyActions] = createResource(historySource, async () => { try { @@ -148,6 +167,14 @@ export const AvailabilityChecksTable: Component<{ }; } }); + // Uptime moves as probes run, and a failed read should not stick: refresh + // the shared history on a slow cadence while the table is mounted. + onMount(() => { + const timer = window.setInterval(() => { + if (historySource()) void historyActions.refetch(); + }, AVAILABILITY_HISTORY_REFRESH_MS); + onCleanup(() => window.clearInterval(timer)); + }); const historyByTarget = createMemo( () => new Map((history()?.response?.targets ?? []).map((target) => [target.targetId, target])), ); @@ -237,10 +264,6 @@ export const AvailabilityChecksTable: Component<{ Add service/device check - - - Manage -
} tableClass="min-w-full table-fixed text-xs md:min-w-[900px]" @@ -266,6 +289,12 @@ export const AvailabilityChecksTable: Component<{ > Result + @@ -282,7 +311,7 @@ export const AvailabilityChecksTable: Component<{ Failures @@ -369,6 +398,24 @@ export const AvailabilityChecksTable: Component<{ )} + @@ -388,10 +435,12 @@ export const AvailabilityChecksTable: Component<{