mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Incorporate supplied upstream frontier for diagnostic-test review
Retain the batch-start main frontier before integrating the independently reviewed documentation-test candidate. No publication or deployment. Change-source: pulse-maintainer
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -7338,7 +7338,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
|
||||
|
||||
@@ -3246,7 +3246,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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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<string, Resource>();
|
||||
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<AvailabilityTarget[]>([]);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
@@ -185,7 +201,7 @@ export const AvailabilitySettingsPanel: Component = () => {
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold text-base-content">
|
||||
{getAvailabilityTargetsSummary(targets())}
|
||||
{getAvailabilityTargetsSummary(targets(), resourceForTarget)}
|
||||
</div>
|
||||
<p class="mt-1 text-xs leading-5 text-muted">
|
||||
Monitor endpoint-only devices and services with ICMP, TCP, and HTTP probes.
|
||||
@@ -272,7 +288,11 @@ export const AvailabilitySettingsPanel: Component = () => {
|
||||
{target.name}
|
||||
</div>
|
||||
<span
|
||||
class={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${getAvailabilityTargetStatusClass(target)}`}
|
||||
class={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${getAvailabilityTargetStatusClass(target, resourceForTarget(target))}`}
|
||||
title={getAvailabilityTargetStatusTitle(
|
||||
target,
|
||||
resourceForTarget(target),
|
||||
)}
|
||||
>
|
||||
{getAvailabilityTargetStatusLabel(target)}
|
||||
</span>
|
||||
|
||||
@@ -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(() => <AvailabilitySettingsPanel />);
|
||||
|
||||
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(() => <AvailabilitySettingsPanel />);
|
||||
|
||||
|
||||
+5
-1
@@ -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');
|
||||
|
||||
+116
-8
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<AvailabilityTargetHealth, string> = {
|
||||
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`;
|
||||
}
|
||||
|
||||
@@ -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<{
|
||||
<PlusIcon class="h-3.5 w-3.5" />
|
||||
Add service/device check
|
||||
</A>
|
||||
<A href={buildAvailabilitySettingsPath()} class={settingsLinkClass}>
|
||||
<SettingsIcon class="h-3.5 w-3.5" />
|
||||
Manage
|
||||
</A>
|
||||
</div>
|
||||
}
|
||||
tableClass="min-w-full table-fixed text-xs md:min-w-[900px]"
|
||||
@@ -266,6 +289,12 @@ export const AvailabilityChecksTable: Component<{
|
||||
>
|
||||
Result
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden lg:table-cell lg:w-[9%]`}
|
||||
title="Share of the last 24 hours the check was reachable"
|
||||
>
|
||||
Uptime 24h
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[10%]`}
|
||||
>
|
||||
@@ -282,7 +311,7 @@ export const AvailabilityChecksTable: Component<{
|
||||
Failures
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden lg:table-cell lg:w-[8%]`}
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden 2xl:table-cell 2xl:w-[7%]`}
|
||||
>
|
||||
Interval
|
||||
</TableHead>
|
||||
@@ -369,6 +398,24 @@ export const AvailabilityChecksTable: Component<{
|
||||
)}
|
||||
</Show>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden tabular-nums lg:table-cell`}
|
||||
>
|
||||
{(() => {
|
||||
const uptime = getAvailabilityUptimeCell(
|
||||
historyByTarget().get(availability()?.targetId ?? ''),
|
||||
);
|
||||
return (
|
||||
<span
|
||||
class={UPTIME_TONE_CLASS[uptime.tone]}
|
||||
title={uptime.title}
|
||||
data-availability-uptime
|
||||
>
|
||||
{uptime.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
@@ -388,10 +435,12 @@ export const AvailabilityChecksTable: Component<{
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content lg:table-cell`}
|
||||
>
|
||||
{formatFailures(availability())}
|
||||
<span title={formatFailures(availability()).title}>
|
||||
{formatFailures(availability()).label}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content lg:table-cell`}
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content 2xl:table-cell`}
|
||||
>
|
||||
<PlatformTableDurationValue
|
||||
seconds={availability()?.pollIntervalSeconds}
|
||||
@@ -402,7 +451,7 @@ export const AvailabilityChecksTable: Component<{
|
||||
resource={check}
|
||||
open={isExpanded()}
|
||||
detailRowId={detailRowId()}
|
||||
colSpan={8}
|
||||
colSpan={9}
|
||||
resolveResourceLabel={resolveResourceLabel}
|
||||
onClose={() => drawer.close(check)}
|
||||
/>
|
||||
|
||||
@@ -47,16 +47,75 @@ const historyState = (bucket: AvailabilityHistoryBucket): AvailabilityFleetHisto
|
||||
const formatPercent = (value: number): string =>
|
||||
`${value.toLocaleString(undefined, { maximumFractionDigits: 2 })}%`;
|
||||
|
||||
// An uptime percentage is only honest when most of the window had a
|
||||
// determinate answer. availabilityPercent leaves out indeterminate time, so a
|
||||
// window of mostly inconclusive UDP results could otherwise read 100 percent.
|
||||
const MIN_UPTIME_COVERAGE_PERCENT = 90;
|
||||
const MIN_DETERMINATE_SHARE = 0.9;
|
||||
|
||||
type UptimeVerdict =
|
||||
| { kind: 'ok'; percent: number }
|
||||
| { kind: 'thin'; observedMinutes: number }
|
||||
| { kind: 'inconclusive' };
|
||||
|
||||
const getUptimeVerdict = (summary: AvailabilityHistoryTarget['summary']): UptimeVerdict => {
|
||||
const observed =
|
||||
(summary?.reachableSeconds ?? 0) +
|
||||
(summary?.unreachableSeconds ?? 0) +
|
||||
(summary?.indeterminateSeconds ?? 0);
|
||||
if (
|
||||
!summary ||
|
||||
summary.coveragePercent < MIN_UPTIME_COVERAGE_PERCENT ||
|
||||
summary.availabilityPercent === undefined
|
||||
) {
|
||||
return { kind: 'thin', observedMinutes: Math.round(observed / 60) };
|
||||
}
|
||||
const determinate = summary.reachableSeconds + summary.unreachableSeconds;
|
||||
if (observed <= 0 || determinate / observed < MIN_DETERMINATE_SHARE) {
|
||||
return { kind: 'inconclusive' };
|
||||
}
|
||||
return { kind: 'ok', percent: summary.availabilityPercent };
|
||||
};
|
||||
|
||||
// Compact 24h uptime for the checks table, on the same coverage rule as the
|
||||
// fleet view: below 90 percent observed there is no honest percentage yet.
|
||||
export const getAvailabilityUptimeCell = (
|
||||
history: AvailabilityHistoryTarget | undefined,
|
||||
): { label: string; title: string; tone: 'default' | 'warning' | 'danger' | 'muted' } => {
|
||||
if (!history?.summary) {
|
||||
return { label: '—', title: 'History unavailable', tone: 'muted' };
|
||||
}
|
||||
const verdict = getUptimeVerdict(history.summary);
|
||||
if (verdict.kind === 'thin') {
|
||||
return {
|
||||
label: '—',
|
||||
title: `Not enough history yet: ${verdict.observedMinutes.toLocaleString()}m observed in the last 24h`,
|
||||
tone: 'muted',
|
||||
};
|
||||
}
|
||||
if (verdict.kind === 'inconclusive') {
|
||||
return {
|
||||
label: '—',
|
||||
title: 'Most results in the last 24h were inconclusive, so there is no uptime figure',
|
||||
tone: 'muted',
|
||||
};
|
||||
}
|
||||
const percent = verdict.percent;
|
||||
return {
|
||||
label: formatPercent(percent),
|
||||
title: `Available ${formatPercent(percent)} of the last 24h (${formatPercent(history.summary.coveragePercent)} observed)`,
|
||||
tone: percent < 95 ? 'danger' : percent < 99 ? 'warning' : 'default',
|
||||
};
|
||||
};
|
||||
|
||||
const availabilityText = (history: AvailabilityHistoryTarget | undefined): string => {
|
||||
if (!history?.summary) return 'History unavailable';
|
||||
const { summary } = history;
|
||||
if (summary.coveragePercent < 90 || summary.availabilityPercent === undefined) {
|
||||
const observed =
|
||||
summary.reachableSeconds + summary.unreachableSeconds + summary.indeterminateSeconds;
|
||||
const observedMinutes = Math.round(observed / 60);
|
||||
return `Insufficient coverage · ${observedMinutes.toLocaleString()}m observed`;
|
||||
const verdict = getUptimeVerdict(history.summary);
|
||||
if (verdict.kind === 'thin') {
|
||||
return `Insufficient coverage · ${verdict.observedMinutes.toLocaleString()}m observed`;
|
||||
}
|
||||
return `${formatPercent(summary.availabilityPercent)} available · ${formatPercent(summary.coveragePercent)} observed`;
|
||||
if (verdict.kind === 'inconclusive') return 'Mostly inconclusive results';
|
||||
return `${formatPercent(verdict.percent)} available · ${formatPercent(history.summary.coveragePercent)} observed`;
|
||||
};
|
||||
|
||||
const latencyPaths = (
|
||||
|
||||
@@ -3,8 +3,13 @@ import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import type { ProbeAgentOption } from '@/utils/availabilityProbeAgents';
|
||||
import { AvailabilityHistoryAPI } from '@/api/availabilityHistory';
|
||||
import { AvailabilityChecksTable, resolveAvailabilityChecksView } from '../AvailabilityChecksTable';
|
||||
|
||||
vi.mock('@/api/availabilityHistory', () => ({
|
||||
AvailabilityHistoryAPI: { batch: vi.fn(async () => ({ targets: [] })) },
|
||||
}));
|
||||
|
||||
const availabilityResource = (overrides: Partial<Resource> = {}): Resource =>
|
||||
({
|
||||
id: 'availability:mock-availability-mqtt-meter',
|
||||
@@ -84,15 +89,21 @@ describe('AvailabilityChecksTable', () => {
|
||||
const { container } = renderTable([availabilityResource()]);
|
||||
const headers = [...container.querySelectorAll('thead th')];
|
||||
|
||||
expect(headers).toHaveLength(8);
|
||||
expect(headers).toHaveLength(9);
|
||||
expect(headers[0]).toHaveClass('platform-table-name-column', 'platform-table-mobile-w-30');
|
||||
expect(headers[1]).toHaveClass('platform-table-mobile-w-15');
|
||||
expect(headers[1]).not.toHaveClass('hidden');
|
||||
expect(headers[2]).toHaveClass('platform-table-mobile-w-25');
|
||||
expect(headers[2]).not.toHaveClass('hidden');
|
||||
expect(headers[3]).toHaveClass('platform-table-mobile-w-15');
|
||||
expect(headers[4]).toHaveClass('platform-table-mobile-w-15');
|
||||
expect(headers[4]).not.toHaveClass('hidden');
|
||||
// Uptime 24h joins from lg up, beside the latest result.
|
||||
expect(headers[4]).toHaveTextContent('Uptime 24h');
|
||||
expect(headers[4]).toHaveClass('hidden', 'lg:table-cell');
|
||||
expect(headers[5]).toHaveClass('platform-table-mobile-w-15');
|
||||
expect(headers[5]).not.toHaveClass('hidden');
|
||||
// Interval is configuration; only the widest tables carry it.
|
||||
expect(headers[8]).toHaveTextContent('Interval');
|
||||
expect(headers[8]).toHaveClass('hidden', '2xl:table-cell');
|
||||
});
|
||||
|
||||
it('exposes complete availability details from the compact summary row', () => {
|
||||
@@ -204,4 +215,71 @@ describe('AvailabilityChecksTable', () => {
|
||||
'/settings/monitoring/availability?add=target&targetKind=service',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows 24h uptime from history and says what consecutive failures mean', async () => {
|
||||
vi.mocked(AvailabilityHistoryAPI.batch).mockResolvedValueOnce({
|
||||
targets: [
|
||||
{
|
||||
targetId: 'mock-availability-mqtt-meter',
|
||||
summary: {
|
||||
reachableSeconds: 85_000,
|
||||
unreachableSeconds: 1_400,
|
||||
indeterminateSeconds: 0,
|
||||
unknownSeconds: 0,
|
||||
coveragePercent: 100,
|
||||
availabilityPercent: 98.4,
|
||||
},
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
|
||||
const { container } = renderTable([
|
||||
availabilityResource({
|
||||
availability: {
|
||||
...availabilityResource().availability!,
|
||||
available: false,
|
||||
consecutiveFailures: 3,
|
||||
failureThreshold: 2,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const uptime = await screen.findByText('98.4%');
|
||||
expect(uptime).toHaveAttribute('data-availability-uptime');
|
||||
expect(uptime.className).toContain('text-amber-700');
|
||||
const failures = screen.getByText('3 in a row');
|
||||
expect(failures).toHaveAttribute(
|
||||
'title',
|
||||
'3 failed checks in a row. It counts as offline after 2.',
|
||||
);
|
||||
expect(container.textContent).not.toContain('3/2');
|
||||
});
|
||||
|
||||
it('shows no uptime figure when most of the window was inconclusive', async () => {
|
||||
vi.mocked(AvailabilityHistoryAPI.batch).mockResolvedValueOnce({
|
||||
targets: [
|
||||
{
|
||||
targetId: 'mock-availability-mqtt-meter',
|
||||
summary: {
|
||||
reachableSeconds: 60,
|
||||
unreachableSeconds: 0,
|
||||
indeterminateSeconds: 86_340,
|
||||
unknownSeconds: 0,
|
||||
coveragePercent: 100,
|
||||
availabilityPercent: 100,
|
||||
},
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
|
||||
const { container } = renderTable([availabilityResource()]);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(container.querySelector('[data-availability-uptime]')).toHaveAttribute(
|
||||
'title',
|
||||
'Most results in the last 24h were inconclusive, so there is no uptime figure',
|
||||
),
|
||||
);
|
||||
expect(container.querySelector('[data-availability-uptime]')).toHaveTextContent('—');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user