mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Make availability checks agree across Settings and Machines
Settings said "2 down" where Machines said "1 offline, 3 need attention" for the same checks. Settings counted any failed latest probe as down, while Machines and the alert engine count a check offline only once it has failed its threshold number of times in a row. Settings now classifies each check with the Machines status of its resource when that is loaded, and otherwise reads the target the way the availability poller does, so both pages report the same counts in the same words. A check that is failing but not yet offline is amber, with the threshold on hover. The Machines table shows 24h uptime beside the latest result (it existed only in the Fleet view), and only when most of the window had a determinate answer, refreshing every five minutes. Failures read "3 in a row" instead of "3/2", polling cadence moves to the widest tables, and the duplicate Manage link is gone.
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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