Add probe assignment and source attribution to the availability UI

The availability check editor gains a "Run from" control listing
connected agent hosts, locked behind the external_probe entitlement
with the standard feature-gate affordance - visible for
discoverability, disabled without Pro, and never in the way of
saving local checks. Statuses show a "via agent" chip when the
latest observation came from a probe, stale probe reports render as
indeterminate, and clearing an assignment serializes an explicit
empty probeAgentId so the backend merge-decode clears it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
courtmanr@gmail.com
2026-07-27 11:09:29 +01:00
parent 3efca89525
commit c2c51019d7
17 changed files with 904 additions and 1 deletions
@@ -502,6 +502,22 @@ Example API payload for simple ping monitoring:
Pulse stores and returns that target as `protocol: "icmp"` so dashboards,
alerts, and resource projections keep one canonical protocol value.
### External probes (Pro)
By default every availability check runs from the Pulse server itself, which
means a check cannot report the one failure that matters most: the site
running Pulse losing its connectivity. With the Pro `external_probe`
entitlement, a check can instead be assigned to any connected Pulse agent
(`probeAgentId` in the API, "Run from" in the UI) — for example an agent on a
cloud VM or at another site. The assigned agent receives the check through
its signed agent configuration, runs it on the configured interval, and
reports results back with its reports; results are only accepted from the
currently assigned agent. An assigned check is not also run locally. If no
report arrives for several intervals the check shows as indeterminate
("no recent report from probe agent"), and if the entitlement lapses the
check automatically resumes running from the Pulse server. Checks without an
assignment are unaffected and remain available in every edition.
### ICMP probe privileges
ICMP probes run the system `ping` binary, which needs the `CAP_NET_RAW`
@@ -64,6 +64,51 @@ describe('AvailabilityTargetsAPI', () => {
);
});
it('round-trips a probe agent assignment through list and create', async () => {
const target: AvailabilityTarget = {
id: 'sensor-1',
name: 'Remote MQTT',
address: 'mqtt.remote.local',
protocol: 'tcp',
port: 1883,
enabled: true,
probeAgentId: 'host-agent-7',
status: {
targetId: 'sensor-1',
name: 'Remote MQTT',
address: 'mqtt.remote.local',
protocol: 'tcp',
enabled: true,
available: true,
outcome: 'reachable',
probeAgentId: 'host-agent-7',
},
};
mockedApiFetchJSON.mockResolvedValueOnce([target]);
const listed = await AvailabilityTargetsAPI.list();
expect(listed[0].probeAgentId).toBe('host-agent-7');
expect(listed[0].status?.probeAgentId).toBe('host-agent-7');
mockedApiFetchJSON.mockResolvedValueOnce(target);
await AvailabilityTargetsAPI.create(target);
expect(mockedApiFetchJSON).toHaveBeenLastCalledWith('/api/availability-targets', {
method: 'POST',
body: JSON.stringify(target),
});
});
it('sends an explicit empty probe agent id when clearing an assignment', async () => {
mockedApiFetchJSON.mockResolvedValueOnce({});
await AvailabilityTargetsAPI.update('sensor-1', { probeAgentId: '' });
const [, init] = mockedApiFetchJSON.mock.calls.at(-1) as [string, { body: string }];
// The server decodes onto the existing record, so an omitted key would keep
// the stored assignment. The empty string has to survive serialization.
expect(JSON.parse(init.body)).toEqual({ probeAgentId: '' });
expect(init.body).toContain('"probeAgentId":""');
});
it('accepts https protocol for secure web services', async () => {
const target: AvailabilityTarget = {
id: '',
@@ -21,6 +21,11 @@ export interface AvailabilityProbeStatus {
consecutiveFailures?: number;
lastError?: string;
failureThreshold?: number;
/**
* Set when the latest observation was reported by a remote probe agent.
* Absent (omitempty) when the local Pulse server ran the check.
*/
probeAgentId?: string;
}
export interface AvailabilityTarget {
@@ -39,6 +44,13 @@ export interface AvailabilityTarget {
pollIntervalSeconds?: number;
timeoutMillis?: number;
failureThreshold?: number;
/**
* Host agent that runs this check. Empty string means the local Pulse
* server. Assigning a non-empty value requires the `external_probe`
* entitlement. Clearing an assignment requires sending an explicit empty
* string because the server decodes updates onto the existing record.
*/
probeAgentId?: string;
status?: AvailabilityProbeStatus;
}
@@ -61,6 +73,11 @@ export class AvailabilityTargetsAPI {
});
}
/**
* Partial update. The server decodes the body onto the existing record, so
* omitted keys keep their stored value. Send `probeAgentId: ''` explicitly to
* clear a probe assignment and move the check back to the local Pulse server.
*/
static async update(
id: string,
target: Partial<AvailabilityTarget>,
@@ -16,6 +16,9 @@ import { Button } from '@/components/shared/Button';
import { EmptyState } from '@/components/shared/EmptyState';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { Dialog } from '@/components/shared/Dialog';
import { MetadataBadge } from '@/components/shared/MetadataBadge';
import { useResources } from '@/hooks/useResources';
import { buildProbeAgentOptions } from '@/utils/availabilityProbeAgents';
import {
AvailabilityTargetsAPI,
type AvailabilityTarget,
@@ -29,6 +32,7 @@ import {
getAvailabilityTargetAddressLabel,
getAvailabilityTargetKindLabel,
getAvailabilityTargetMethodLabel,
getAvailabilityTargetProbeSourceLabel,
getAvailabilityTargetStatusClass,
getAvailabilityTargetStatusLabel,
getAvailabilityTargetsSummary,
@@ -46,6 +50,8 @@ const sortTargets = (targets: readonly AvailabilityTarget[]): AvailabilityTarget
export const AvailabilitySettingsPanel: Component = () => {
const location = useLocation();
const navigate = useNavigate();
const { resources } = useResources();
const probeAgentOptions = createMemo(() => buildProbeAgentOptions(resources()));
const [targets, setTargets] = createSignal<AvailabilityTarget[]>([]);
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
@@ -272,6 +278,20 @@ export const AvailabilitySettingsPanel: Component = () => {
>
{getAvailabilityTargetStatusLabel(target)}
</span>
<Show
when={getAvailabilityTargetProbeSourceLabel(target, probeAgentOptions())}
>
{(sourceLabel) => (
<MetadataBadge
tone="muted"
size="xs"
appearance="outline"
data-availability-probe-source={target.status?.probeAgentId}
>
{sourceLabel()}
</MetadataBadge>
)}
</Show>
</div>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
<span>{getAvailabilityTargetKindLabel(target)}</span>
@@ -1,6 +1,7 @@
import { Component, For, Show, createMemo, createSignal, onMount } from 'solid-js';
import { Button } from '@/components/shared/Button';
import { CalloutCard } from '@/components/shared/CalloutCard';
import { FeatureGateSection } from '@/components/shared/FeatureGateSection';
import {
formCheckbox,
formControl,
@@ -28,6 +29,19 @@ import { useResources } from '@/hooks/useResources';
import { getSourcePlatformLabel } from '@/utils/sourcePlatforms';
import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity';
import { getResourceTypeLabel } from '@/utils/resourceTypePresentation';
import {
EXTERNAL_PROBE_FEATURE,
LOCAL_PROBE_AGENT_LABEL,
buildProbeAgentOptions,
getExternalProbeGateBody,
getExternalProbeGateTitle,
getExternalProbeLockedHelpText,
isExternalProbeLicenseError,
isProbeAgentMissing,
} from '@/utils/availabilityProbeAgents';
import { hasFeature, loadRuntimeCapabilities, runtimeCapabilitiesLoaded } from '@/stores/license';
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import type { Resource } from '@/types/resource';
interface AvailabilityForm {
@@ -42,6 +56,7 @@ interface AvailabilityForm {
udpRequest: string;
udpExpectedResponse: string;
linkedResourceId: string;
probeAgentId: string;
enabled: boolean;
pollIntervalSeconds: string;
timeoutMillis: string;
@@ -76,6 +91,7 @@ const newAvailabilityForm = (
udpRequest: '',
udpExpectedResponse: '',
linkedResourceId: '',
probeAgentId: '',
enabled: true,
pollIntervalSeconds: '60',
timeoutMillis: '2000',
@@ -94,6 +110,7 @@ const formFromTarget = (target: AvailabilityTarget): AvailabilityForm => ({
udpRequest: target.udpRequest ?? '',
udpExpectedResponse: target.udpExpectedResponse ?? '',
linkedResourceId: target.linkedResourceId ?? '',
probeAgentId: target.probeAgentId ?? '',
enabled: target.enabled ?? true,
pollIntervalSeconds: String(target.pollIntervalSeconds ?? 60),
timeoutMillis: String(target.timeoutMillis ?? 2000),
@@ -119,6 +136,10 @@ const payloadFromForm = (form: AvailabilityForm): AvailabilityTarget => {
udpRequest: form.protocol === 'udp' ? form.udpRequest : undefined,
udpExpectedResponse: form.protocol === 'udp' ? form.udpExpectedResponse : undefined,
linkedResourceId: form.linkedResourceId.trim() || undefined,
// Always serialized, never `undefined`: the server decodes updates onto the
// existing record, so an explicit empty string is what clears a probe
// assignment and moves the check back to the local Pulse server.
probeAgentId: form.probeAgentId.trim(),
enabled: form.enabled,
pollIntervalSeconds: parsePositiveInt(form.pollIntervalSeconds),
timeoutMillis: parsePositiveInt(form.timeoutMillis),
@@ -182,6 +203,21 @@ export const AvailabilityTargetSlot: Component<AvailabilityTargetSlotProps> = (p
const [testing, setTesting] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [testResult, setTestResult] = createSignal<AvailabilityTestResponse | null>(null);
// Set when the server rejects a probe assignment with the canonical 402, so a
// stale cached capability set still lands on the upgrade gate.
const [probeLicenseRejected, setProbeLicenseRejected] = createSignal(false);
const probeAgentOptions = createMemo(() => buildProbeAgentOptions(resources()));
const probeAgentMissing = createMemo(() =>
isProbeAgentMissing(probeAgentOptions(), form().probeAgentId),
);
const externalProbeLicensed = createMemo(
() =>
!probeLicenseRejected() && runtimeCapabilitiesLoaded() && hasFeature(EXTERNAL_PROBE_FEATURE),
);
const externalProbeLocked = createMemo(
() => probeLicenseRejected() || (runtimeCapabilitiesLoaded() && !externalProbeLicensed()),
);
const linkedResourceMissing = createMemo(() => {
const id = form().linkedResourceId.trim();
@@ -240,6 +276,7 @@ export const AvailabilityTargetSlot: Component<AvailabilityTargetSlotProps> = (p
};
onMount(async () => {
void loadRuntimeCapabilities();
const targetId = props.editingTargetId?.trim();
if (!targetId) return;
setLoading(true);
@@ -287,6 +324,11 @@ export const AvailabilityTargetSlot: Component<AvailabilityTargetSlotProps> = (p
}
props.onSaved();
} catch (err) {
if (payload.probeAgentId && isExternalProbeLicenseError(err)) {
setProbeLicenseRejected(true);
setError(getExternalProbeGateBody());
return;
}
setError(err instanceof Error ? err.message : 'Failed to save availability target.');
} finally {
setSaving(false);
@@ -402,6 +444,39 @@ export const AvailabilityTargetSlot: Component<AvailabilityTargetSlotProps> = (p
)}
</For>
</FormSelect>
<div class="space-y-3 sm:col-span-2">
<FormSelect
label="Run from"
value={form().probeAgentId}
disabled={externalProbeLocked()}
onChange={(event) => updateForm({ probeAgentId: event.currentTarget.value })}
help={
externalProbeLocked()
? getExternalProbeLockedHelpText()
: 'Run this check from the Pulse server, or hand it to a connected Pulse Agent host so it is probed from that network.'
}
>
<option value="">{LOCAL_PROBE_AGENT_LABEL}</option>
<Show when={probeAgentMissing()}>
<option value={form().probeAgentId}>
{form().probeAgentId} (not currently connected)
</option>
</Show>
<For each={probeAgentOptions()}>
{(option) => <option value={option.id}>{option.label}</option>}
</For>
</FormSelect>
<Show when={externalProbeLocked()}>
<div class="rounded-md border border-border bg-surface-alt p-4">
<FeatureGateSection
title={getExternalProbeGateTitle()}
body={getExternalProbeGateBody()}
upgradeDestination={getUpgradeActionDestination(EXTERNAL_PROBE_FEATURE)}
showUpgradePrompts={!presentationPolicyHidesUpgradePrompts()}
/>
</div>
</Show>
</div>
<Show when={form().protocol !== 'icmp'}>
<label class={formField}>
<span class={formLabel}>Port</span>
@@ -24,12 +24,52 @@ vi.mock('@/hooks/useResources', () => ({
}),
}));
const licenseMocks = vi.hoisted(() => ({
features: new Set<string>(),
loaded: true,
}));
vi.mock('@/stores/license', () => ({
hasFeature: (feature: string) => licenseMocks.features.has(feature),
loadRuntimeCapabilities: vi.fn().mockResolvedValue(undefined),
runtimeCapabilitiesLoaded: () => licenseMocks.loaded,
}));
vi.mock('@/stores/licenseCommercial', () => ({
getUpgradeActionDestination: (feature: string) => ({
href: `https://example.test/upgrade?feature=${feature}`,
external: true,
}),
}));
vi.mock('@/stores/sessionPresentationPolicy', () => ({
presentationPolicyHidesUpgradePrompts: () => false,
}));
const agentHostResource = (agentId: string, displayName: string) => ({
id: `agent:${agentId}`,
type: 'agent',
name: displayName,
displayName,
platformId: agentId,
platformType: 'agent',
sourceType: 'agent',
sources: ['agent'],
status: 'online',
lastSeen: 1_700_000_000_000,
agent: { agentId },
});
const mockedCreate = vi.mocked(AvailabilityTargetsAPI.create);
const mockedList = vi.mocked(AvailabilityTargetsAPI.list);
const mockedUpdate = vi.mocked(AvailabilityTargetsAPI.update);
describe('AvailabilityTargetSlot', () => {
beforeEach(() => {
vi.clearAllMocks();
resourceMocks.resources = [];
licenseMocks.features = new Set(['external_probe']);
licenseMocks.loaded = true;
mockedCreate.mockResolvedValue({
id: 'target-1',
name: 'Rack sensor',
@@ -176,4 +216,147 @@ describe('AvailabilityTargetSlot', () => {
);
expect(onSaved).toHaveBeenCalledTimes(1);
});
describe('external probe assignment', () => {
it('offers connected agent hosts and saves the assignment when licensed', async () => {
resourceMocks.resources = [agentHostResource('host-edge-01', 'Edge 01')];
render(() => <AvailabilityTargetSlot onCancel={vi.fn()} onSaved={vi.fn()} />);
const runFrom = screen.getByLabelText('Run from') as HTMLSelectElement;
expect(runFrom).not.toBeDisabled();
expect(screen.getByRole('option', { name: 'This Pulse server' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Edge 01' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'View plans' })).not.toBeInTheDocument();
fireEvent.input(screen.getByLabelText('Name'), { target: { value: 'Remote MQTT' } });
fireEvent.input(screen.getByPlaceholderText('service.local'), {
target: { value: 'mqtt.remote.local' },
});
fireEvent.change(runFrom, { target: { value: 'host-edge-01' } });
fireEvent.click(screen.getByRole('button', { name: 'Add service/device check' }));
await waitFor(() =>
expect(mockedCreate).toHaveBeenCalledWith(
expect.objectContaining({ probeAgentId: 'host-edge-01' }),
),
);
});
it('locks the control behind the canonical upgrade gate when unlicensed', () => {
licenseMocks.features = new Set();
resourceMocks.resources = [agentHostResource('host-edge-01', 'Edge 01')];
render(() => <AvailabilityTargetSlot onCancel={vi.fn()} onSaved={vi.fn()} />);
// Discoverability is the point of the Pro gate: the control stays visible.
const runFrom = screen.getByLabelText('Run from') as HTMLSelectElement;
expect(runFrom).toBeDisabled();
expect(screen.getByRole('option', { name: 'Edge 01' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'External Probes' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'View plans' })).toHaveAttribute(
'href',
'https://example.test/upgrade?feature=external_probe',
);
});
it('saves a local target unlicensed without any license friction', async () => {
licenseMocks.features = new Set();
render(() => <AvailabilityTargetSlot onCancel={vi.fn()} onSaved={vi.fn()} />);
fireEvent.input(screen.getByLabelText('Name'), { target: { value: 'Local ping' } });
fireEvent.input(screen.getByPlaceholderText('service.local'), {
target: { value: 'printer.local' },
});
fireEvent.click(screen.getByRole('button', { name: 'Add service/device check' }));
await waitFor(() =>
expect(mockedCreate).toHaveBeenCalledWith(expect.objectContaining({ probeAgentId: '' })),
);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('clears an assignment with an explicit empty string', async () => {
resourceMocks.resources = [agentHostResource('host-edge-01', 'Edge 01')];
mockedList.mockResolvedValue([
{
id: 'target-1',
name: 'Remote MQTT',
address: 'mqtt.remote.local',
protocol: 'tcp',
port: 1883,
enabled: true,
probeAgentId: 'host-edge-01',
},
]);
mockedUpdate.mockResolvedValue({
id: 'target-1',
name: 'Remote MQTT',
address: 'mqtt.remote.local',
protocol: 'tcp',
enabled: true,
});
render(() => (
<AvailabilityTargetSlot editingTargetId="target-1" onCancel={vi.fn()} onSaved={vi.fn()} />
));
await waitFor(() => expect(screen.getByLabelText('Run from')).toHaveValue('host-edge-01'));
fireEvent.change(screen.getByLabelText('Run from'), { target: { value: '' } });
fireEvent.click(screen.getByRole('button', { name: 'Save target' }));
await waitFor(() => expect(mockedUpdate).toHaveBeenCalled());
const [, payload] = mockedUpdate.mock.calls.at(-1)!;
expect(payload.probeAgentId).toBe('');
expect(Object.prototype.hasOwnProperty.call(payload, 'probeAgentId')).toBe(true);
});
it('keeps a saved assignment selectable when its host is not currently connected', async () => {
mockedList.mockResolvedValue([
{
id: 'target-1',
name: 'Remote MQTT',
address: 'mqtt.remote.local',
protocol: 'tcp',
enabled: true,
probeAgentId: 'host-gone',
},
]);
render(() => (
<AvailabilityTargetSlot editingTargetId="target-1" onCancel={vi.fn()} onSaved={vi.fn()} />
));
await waitFor(() =>
expect(
screen.getByRole('option', { name: 'host-gone (not currently connected)' }),
).toBeInTheDocument(),
);
expect(screen.getByLabelText('Run from')).toHaveValue('host-gone');
});
it('falls back to the upgrade gate when the server answers 402 license_required', async () => {
resourceMocks.resources = [agentHostResource('host-edge-01', 'Edge 01')];
mockedCreate.mockRejectedValueOnce(
Object.assign(new Error('external probe requires a paid feature'), {
status: 402,
feature: 'external_probe',
}),
);
render(() => <AvailabilityTargetSlot onCancel={vi.fn()} onSaved={vi.fn()} />);
fireEvent.input(screen.getByLabelText('Name'), { target: { value: 'Remote MQTT' } });
fireEvent.input(screen.getByPlaceholderText('service.local'), {
target: { value: 'mqtt.remote.local' },
});
fireEvent.change(screen.getByLabelText('Run from'), { target: { value: 'host-edge-01' } });
fireEvent.click(screen.getByRole('button', { name: 'Add service/device check' }));
await waitFor(() =>
expect(screen.getByRole('heading', { name: 'External Probes' })).toBeInTheDocument(),
);
expect(screen.getByLabelText('Run from')).toBeDisabled();
expect(screen.getByRole('link', { name: 'View plans' })).toBeInTheDocument();
});
});
});
@@ -29,6 +29,16 @@ vi.mock('@/api/availabilityTargets', () => ({
},
}));
const resourceMocks = vi.hoisted(() => ({
resources: [] as Array<Record<string, unknown>>,
}));
vi.mock('@/hooks/useResources', () => ({
useResources: () => ({
resources: () => resourceMocks.resources,
}),
}));
vi.mock('../ConnectionEditor/CredentialSlots/AvailabilityTargetSlot', () => ({
AvailabilityTargetSlot: (props: {
editingTargetId?: string | null;
@@ -79,6 +89,7 @@ const targets: AvailabilityTarget[] = [
describe('AvailabilitySettingsPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
resourceMocks.resources = [];
routeState.pathname = '/settings/monitoring/availability';
routeState.search = '';
vi.mocked(AvailabilityTargetsAPI.list).mockResolvedValue(targets);
@@ -101,6 +112,53 @@ describe('AvailabilitySettingsPanel', () => {
expect(screen.getByText('Online · 8 ms')).toBeInTheDocument();
});
it('attributes probe-reported checks to the agent host that ran them', async () => {
resourceMocks.resources = [
{
id: 'agent:edge-01',
type: 'agent',
name: 'edge-01',
displayName: 'Edge 01',
platformId: 'edge-01',
platformType: 'agent',
sourceType: 'agent',
sources: ['agent'],
status: 'online',
lastSeen: 1_700_000_000_000,
agent: { agentId: 'host-edge-01' },
},
];
vi.mocked(AvailabilityTargetsAPI.list).mockResolvedValue([
{
...targets[0],
probeAgentId: 'host-edge-01',
status: { ...targets[0].status!, probeAgentId: 'host-edge-01' },
},
{
...targets[0],
id: 'stale-probe',
name: 'Stale probe',
probeAgentId: 'host-gone',
status: {
...targets[0].status!,
targetId: 'stale-probe',
available: false,
outcome: 'indeterminate',
lastError: 'no recent report from probe agent',
probeAgentId: 'host-gone',
},
},
]);
render(() => <AvailabilitySettingsPanel />);
await waitFor(() => expect(screen.getByText('via Edge 01')).toBeInTheDocument());
// Unknown host ids still get attributed, by raw id.
expect(screen.getByText('via host-gone')).toBeInTheDocument();
// Stale probe reports reuse the existing indeterminate warning treatment.
expect(screen.getByText('No recent probe report')).toBeInTheDocument();
});
it('opens add and edit dialogs from the canonical availability route', async () => {
render(() => <AvailabilitySettingsPanel />);
@@ -6,6 +6,7 @@ import {
getAvailabilityTargetAddressLabel,
getAvailabilityTargetKindLabel,
getAvailabilityTargetMethodLabel,
getAvailabilityTargetProbeSourceLabel,
getAvailabilityTargetStatusClass,
getAvailabilityTargetStatusLabel,
getAvailabilityTargetsSummary,
@@ -159,4 +160,74 @@ describe('availabilitySettingsModel', () => {
),
).toBe('bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-300');
});
it('attributes probe-reported results to the assigned agent host', () => {
const options = [{ id: 'host-edge-01', label: 'Edge 01' }];
const probed = target({
probeAgentId: 'host-edge-01',
status: {
...target(),
targetId: 'mqtt-broker',
available: true,
latencyMillis: 12,
probeAgentId: 'host-edge-01',
},
});
expect(getAvailabilityTargetProbeSourceLabel(probed, options)).toBe('via Edge 01');
// A locally executed check carries no attribution chip.
expect(
getAvailabilityTargetProbeSourceLabel(
target({ status: { ...target(), targetId: 'mqtt-broker', available: true } }),
options,
),
).toBeNull();
// Unknown hosts degrade to the raw id rather than disappearing.
expect(
getAvailabilityTargetProbeSourceLabel(
target({
status: {
...target(),
targetId: 'mqtt-broker',
available: true,
probeAgentId: 'host-gone',
},
}),
options,
),
).toBe('via host-gone');
});
it('renders a stale probe assignment with the existing indeterminate treatment', () => {
const stale = target({
probeAgentId: 'host-edge-01',
status: {
...target(),
targetId: 'mqtt-broker',
available: false,
outcome: 'indeterminate',
lastError: 'no recent report from probe agent',
probeAgentId: 'host-edge-01',
},
});
expect(getAvailabilityTargetStatusLabel(stale)).toBe('No recent probe report');
// No new visual language: it reuses the amber indeterminate badge.
expect(getAvailabilityTargetStatusClass(stale)).toBe(
'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
);
// The UDP open-or-filtered case keeps its own copy.
expect(
getAvailabilityTargetStatusLabel(
target({
status: {
...target(),
targetId: 'mqtt-broker',
available: true,
outcome: 'indeterminate',
},
}),
),
).toBe('Open or filtered');
});
});
@@ -1,4 +1,10 @@
import type { AvailabilityTarget, AvailabilityTargetKind } from '@/api/availabilityTargets';
import {
PROBE_AGENT_STALE_LABEL,
getProbeSourceChipLabel,
isProbeAgentStaleStatus,
type ProbeAgentOption,
} from '@/utils/availabilityProbeAgents';
export const AVAILABILITY_SETTINGS_PATH = '/settings/monitoring/availability';
export const AVAILABILITY_ADD_QUERY_PARAM = 'add';
@@ -102,6 +108,10 @@ export function getAvailabilityTargetStatusLabel(target: AvailabilityTarget): st
if (!target.enabled) return 'Paused';
const status = target.status;
if (!status) return 'Not checked yet';
// A probe-assigned check whose agent stopped reporting derives to
// indeterminate at read time. It shares the warning treatment with the UDP
// open-or-filtered case but needs its own copy.
if (isProbeAgentStaleStatus(status)) return PROBE_AGENT_STALE_LABEL;
if (status.outcome === 'indeterminate') return 'Open or filtered';
if (status.available) {
return typeof status.latencyMillis === 'number'
@@ -123,6 +133,17 @@ export function getAvailabilityTargetStatusClass(target: AvailabilityTarget): st
return 'bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-300';
}
/**
* Source attribution for the latest observation. Returns null when the check
* ran locally, so the chip only appears for probe-reported results.
*/
export function getAvailabilityTargetProbeSourceLabel(
target: AvailabilityTarget,
probeAgentOptions: readonly ProbeAgentOption[],
): string | null {
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(
@@ -2,6 +2,7 @@ import { A } from '@solidjs/router';
import { For, Show, createMemo, 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 { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import {
@@ -22,6 +23,7 @@ import {
getAvailabilityProbeEndpointLabel,
getAvailabilityProbePresentation,
} from '@/utils/availabilityProbePresentation';
import { getProbeSourceChipLabel, type ProbeAgentOption } from '@/utils/availabilityProbeAgents';
import {
buildAvailabilitySettingsPath,
buildAvailabilityTargetAddPath,
@@ -59,6 +61,8 @@ export const AvailabilityChecksTable: Component<{
emptyIcon: JSX.Element;
emptyTitle: string;
emptyDescription: string;
/** Connected agent hosts, used to name the source of probe-reported results. */
probeAgentOptions?: readonly ProbeAgentOption[];
}> = (props) => {
const tableState = createPlatformTableFilterState({
resources: () => props.resources,
@@ -181,6 +185,11 @@ export const AvailabilityChecksTable: Component<{
probe()?.methodLabel ?? availability()?.protocol ?? 'Probe';
const result = () => probe()?.resultLabel ?? indicator().label;
const target = () => formatTarget(check);
const probeSource = () =>
getProbeSourceChipLabel(
props.probeAgentOptions ?? [],
availability()?.probeAgentId,
);
return (
<TableRow
@@ -226,6 +235,19 @@ export const AvailabilityChecksTable: Component<{
<span class={probe()?.toneClassName ?? ''} title={probe()?.detailLabel}>
{result()}
</span>
<Show when={probeSource()}>
{(sourceLabel) => (
<MetadataBadge
tone="muted"
size="xs"
appearance="outline"
class="mt-0.5 flex"
data-availability-probe-source={availability()?.probeAgentId}
>
{sourceLabel()}
</MetadataBadge>
)}
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content md:table-cell`}
@@ -28,6 +28,7 @@ import { useUnifiedResources } from '@/hooks/useUnifiedResources';
import { STANDALONE_PATH, buildStandalonePath } from '@/routing/resourceLinks';
import { updateStore } from '@/stores/updates';
import { formatRelativeTime } from '@/utils/format';
import { buildProbeAgentOptions } from '@/utils/availabilityProbeAgents';
import { AvailabilityChecksTable } from './AvailabilityChecksTable';
import { AgentsMachinesTable } from './AgentsMachinesTable';
import {
@@ -229,6 +230,7 @@ export function StandalonePageSurface() {
</Show>
<AvailabilityChecksTable
resources={model().availabilityChecks}
probeAgentOptions={buildProbeAgentOptions(model().machines)}
emptyIcon={availabilityIcon()}
emptyTitle="No availability checks"
emptyDescription="Add ping, TCP, MQTT, ESPHome, or HTTP checks for devices and services that cannot run Pulse Agent."
@@ -2,6 +2,7 @@ import { Route, Router } from '@solidjs/router';
import { cleanup, 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 { AvailabilityChecksTable } from '../AvailabilityChecksTable';
const availabilityResource = (overrides: Partial<Resource> = {}): Resource =>
@@ -32,7 +33,7 @@ const availabilityResource = (overrides: Partial<Resource> = {}): Resource =>
...overrides,
}) as Resource;
const renderTable = (resources: Resource[]) =>
const renderTable = (resources: Resource[], probeAgentOptions?: ProbeAgentOption[]) =>
render(() => (
<Router>
<Route
@@ -43,6 +44,7 @@ const renderTable = (resources: Resource[]) =>
emptyIcon={<span />}
emptyTitle="No checks"
emptyDescription="Add checks"
probeAgentOptions={probeAgentOptions}
/>
)}
/>
@@ -99,6 +101,58 @@ describe('AvailabilityChecksTable', () => {
]);
});
it('chips probe-reported results with the agent host that produced them', () => {
vi.spyOn(Date, 'now').mockReturnValue(1_700_000_600_000);
renderTable(
[
availabilityResource({
availability: {
targetId: 'mock-availability-mqtt-meter',
protocol: 'tcp',
address: 'power-meter-01.lab.local',
port: 1883,
enabled: true,
available: true,
latencyMillis: 7,
lastChecked: '2023-11-14T22:18:20.000Z',
pollIntervalSeconds: 90,
probeAgentId: 'host-edge-01',
},
}),
],
[{ id: 'host-edge-01', label: 'Edge 01' }],
);
expect(screen.getByText('via Edge 01')).toBeInTheDocument();
});
it('falls back to the raw agent id when the host list does not contain it', () => {
vi.spyOn(Date, 'now').mockReturnValue(1_700_000_600_000);
renderTable([
availabilityResource({
availability: {
targetId: 'mock-availability-mqtt-meter',
protocol: 'tcp',
address: 'power-meter-01.lab.local',
enabled: true,
available: true,
lastChecked: '2023-11-14T22:18:20.000Z',
probeAgentId: 'host-gone',
},
}),
]);
expect(screen.getByText('via host-gone')).toBeInTheDocument();
});
it('shows no source chip for locally executed checks', () => {
renderTable([availabilityResource()]);
expect(screen.queryByText(/^via /)).not.toBeInTheDocument();
});
it('links the empty state back to the availability check add flow', () => {
renderTable([]);
+5
View File
@@ -1451,6 +1451,11 @@ export interface ResourceAvailabilityMeta {
failureThreshold?: number;
pollIntervalSeconds?: number;
timeoutMillis?: number;
/**
* Host agent that produced the latest observation. Absent when the check ran
* on the Pulse server itself.
*/
probeAgentId?: string;
correlationState?: 'attached' | 'standalone' | 'ambiguous' | 'unresolved';
correlationRule?: string;
correlationReason?: string;
@@ -0,0 +1,161 @@
import { describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource';
import {
EXTERNAL_PROBE_FEATURE,
LOCAL_PROBE_AGENT_LABEL,
PROBE_AGENT_STALE_ERROR,
buildProbeAgentOptions,
getExternalProbeGateBody,
getExternalProbeGateTitle,
getExternalProbeLockedHelpText,
getProbeAgentLabel,
getProbeSourceChipLabel,
isExternalProbeLicenseError,
isProbeAgentMissing,
isProbeAgentStaleStatus,
} from '@/utils/availabilityProbeAgents';
const agentResource = (overrides: Partial<Resource> = {}): Resource =>
({
id: 'agent:edge-01',
name: 'edge-01',
displayName: 'Edge 01',
type: 'agent',
platformId: 'edge-01',
platformType: 'agent',
sourceType: 'agent',
sources: ['agent'],
status: 'online',
lastSeen: 1_700_000_000_000,
agent: { agentId: 'host-edge-01' },
...overrides,
}) as Resource;
describe('buildProbeAgentOptions', () => {
it('lists connected Pulse Agent hosts by stable agent id and display name', () => {
const options = buildProbeAgentOptions([
agentResource(),
agentResource({
id: 'agent:branch-02',
name: 'branch-02',
displayName: 'Branch 02',
platformId: 'branch-02',
agent: { agentId: 'host-branch-02' },
} as Partial<Resource>),
]);
expect(options).toEqual([
{ id: 'host-branch-02', label: 'Branch 02' },
{ id: 'host-edge-01', label: 'Edge 01' },
]);
});
it('excludes offline hosts, provider-owned rows, and non-agent resources', () => {
const options = buildProbeAgentOptions([
agentResource({ status: 'offline' }),
agentResource({
id: 'agent:truenas',
platformType: 'truenas',
sources: ['truenas'],
agent: { agentId: 'host-truenas' },
} as Partial<Resource>),
{
id: 'availability:mqtt',
type: 'network-endpoint',
name: 'mqtt',
displayName: 'mqtt',
platformType: 'availability',
status: 'online',
} as Resource,
]);
expect(options).toEqual([]);
});
it('deduplicates resource rows that resolve to the same agent id', () => {
const options = buildProbeAgentOptions([
agentResource(),
agentResource({ id: 'agent:edge-01-duplicate' } as Partial<Resource>),
]);
expect(options).toHaveLength(1);
expect(options[0].id).toBe('host-edge-01');
});
});
describe('probe agent labelling', () => {
const options = [{ id: 'host-edge-01', label: 'Edge 01' }];
it('names the local Pulse server for an empty assignment', () => {
expect(getProbeAgentLabel(options, '')).toBe(LOCAL_PROBE_AGENT_LABEL);
expect(getProbeSourceChipLabel(options, '')).toBeNull();
expect(getProbeSourceChipLabel(options, undefined)).toBeNull();
});
it('attributes probe-reported results to the host display name', () => {
expect(getProbeSourceChipLabel(options, 'host-edge-01')).toBe('via Edge 01');
});
it('falls back to the raw id when the host list does not contain it', () => {
expect(getProbeAgentLabel(options, 'host-unknown')).toBe('host-unknown');
expect(getProbeSourceChipLabel(options, 'host-unknown')).toBe('via host-unknown');
expect(isProbeAgentMissing(options, 'host-unknown')).toBe(true);
expect(isProbeAgentMissing(options, 'host-edge-01')).toBe(false);
expect(isProbeAgentMissing(options, '')).toBe(false);
});
});
describe('isProbeAgentStaleStatus', () => {
it('detects a probe assignment that stopped reporting', () => {
expect(
isProbeAgentStaleStatus({ outcome: 'indeterminate', lastError: PROBE_AGENT_STALE_ERROR }),
).toBe(true);
});
it('leaves other indeterminate states alone', () => {
expect(isProbeAgentStaleStatus({ outcome: 'indeterminate', lastError: '' })).toBe(false);
expect(
isProbeAgentStaleStatus({ outcome: 'unreachable', lastError: PROBE_AGENT_STALE_ERROR }),
).toBe(false);
expect(isProbeAgentStaleStatus(null)).toBe(false);
expect(isProbeAgentStaleStatus(undefined)).toBe(false);
});
});
describe('isExternalProbeLicenseError', () => {
it('recognizes the canonical 402 license_required body', () => {
expect(
isExternalProbeLicenseError(
Object.assign(new Error('external probe requires a paid feature'), {
status: 402,
feature: EXTERNAL_PROBE_FEATURE,
}),
),
).toBe(true);
});
it('recognizes a bare license_required message and the feature key alone', () => {
expect(isExternalProbeLicenseError(new Error('license_required'))).toBe(true);
expect(
isExternalProbeLicenseError(Object.assign(new Error('nope'), { feature: 'external_probe' })),
).toBe(true);
});
it('does not swallow unrelated failures', () => {
expect(isExternalProbeLicenseError(new Error('unknown_probe_agent'))).toBe(false);
expect(isExternalProbeLicenseError(undefined)).toBe(false);
expect(isExternalProbeLicenseError('license_required')).toBe(false);
});
});
describe('external probe gate copy', () => {
it('uses the generated catalog name and the canonical minimum tier label', () => {
expect(getExternalProbeGateTitle()).toBe('External Probes');
expect(getExternalProbeGateBody()).toContain('Pro');
expect(getExternalProbeGateBody()).toContain('this Pulse server');
// Same shape as getTabLockReason: name the minimum tier, not the feature.
expect(getExternalProbeLockedHelpText()).toBe(
'Remote probe hosts require Pro. This check runs from the Pulse server.',
);
});
});
@@ -375,6 +375,7 @@ describe('licensePresentation', () => {
'Audit Logging',
'PDF/CSV Reporting',
'Centralized Agent Profiles',
'External Probes',
],
supplementalBadges: [],
supplementalSummary: '',
@@ -673,6 +674,7 @@ describe('licensePresentation', () => {
'Audit Logging',
'PDF/CSV Reporting',
'Centralized Agent Profiles',
'External Probes',
],
supplementalBadges: ['Grandfathered price'],
supplementalSummary:
@@ -1260,6 +1262,7 @@ describe('licensePresentation', () => {
'Audit Logging',
'PDF/CSV Reporting',
'Centralized Agent Profiles',
'External Probes',
],
actionLabel: 'Choose Patrol mode',
actionUrl: PATROL_CONTROL_STARTER_URL,
@@ -144,6 +144,7 @@ describe('selfHostedPlans', () => {
'Audit Logging',
'PDF/CSV Reporting',
'Centralized Agent Profiles',
'External Probes',
]);
expect(SELF_HOSTED_PLAN_BY_TIER.community.highlights).toEqual(
expect.arrayContaining(['Real-time monitoring', 'Watch-only Patrol']),
@@ -0,0 +1,149 @@
/**
* External probe (Pro) presentation and selection helpers.
*
* An availability check normally runs from the Pulse server itself. With the
* `external_probe` entitlement a check can instead be assigned to a connected
* Pulse Agent host, which runs the probe and reports results back. The empty
* string is the canonical "local Pulse server" value on the wire, and clearing
* an assignment means sending that empty string explicitly.
*/
import type { AvailabilityProbeStatus } from '@/api/availabilityTargets';
import type { Resource } from '@/types/resource';
import {
getActionableAgentIdFromResource,
isPulseAgentPlatformResource,
} from '@/utils/agentResources';
import { getFeatureMinTierLabel, getLicenseFeatureLabel } from '@/utils/licensePresentation';
import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity';
import { isConnectedHealthStatus } from '@/utils/status';
/** Runtime capability key that gates probe assignment. */
export const EXTERNAL_PROBE_FEATURE = 'external_probe';
/** Wire value for "run this check on the Pulse server itself". */
export const LOCAL_PROBE_AGENT_VALUE = '';
/** Default option label for the local Pulse server. */
export const LOCAL_PROBE_AGENT_LABEL = 'This Pulse server';
/** Canonical server error for a probe assignment whose agent stopped reporting. */
export const PROBE_AGENT_STALE_ERROR = 'no recent report from probe agent';
/** Status label used when a probe-assigned check has gone stale. */
export const PROBE_AGENT_STALE_LABEL = 'No recent probe report';
export interface ProbeAgentOption {
/** Stable host agent id, matching the id the server validates against. */
id: string;
/** Human readable host name. */
label: string;
}
/**
* Build the selectable probe agent hosts from unified resources.
*
* Only genuine Pulse Agent machines qualify — the server resolves a probe
* assignment against its live host snapshot, so provider-owned rows (Proxmox,
* TrueNAS, VMware, Kubernetes) are not valid probe targets. Multiple resource
* rows can resolve to one agent id, so options are deduplicated.
*/
export function buildProbeAgentOptions(resources: readonly Resource[]): ProbeAgentOption[] {
const byId = new Map<string, ProbeAgentOption>();
for (const resource of resources) {
if (!isPulseAgentPlatformResource(resource)) continue;
if (!isConnectedHealthStatus(resource.status)) continue;
const id = getActionableAgentIdFromResource(resource);
if (!id || byId.has(id)) continue;
byId.set(id, { id, label: getPreferredInfrastructureDisplayName(resource) || id });
}
return [...byId.values()].sort((left, right) => left.label.localeCompare(right.label));
}
/**
* Resolve a probe agent id to its display name, falling back to the raw id when
* the host is not currently in the list.
*/
export function getProbeAgentLabel(
options: readonly ProbeAgentOption[],
probeAgentId?: string | null,
): string {
const normalized = (probeAgentId ?? '').trim();
if (!normalized) return LOCAL_PROBE_AGENT_LABEL;
return options.find((option) => option.id === normalized)?.label ?? normalized;
}
/** Chip copy attributing an observation to the host that produced it. */
export function getProbeSourceChipLabel(
options: readonly ProbeAgentOption[],
probeAgentId?: string | null,
): string | null {
const normalized = (probeAgentId ?? '').trim();
if (!normalized) return null;
return `via ${getProbeAgentLabel(options, normalized)}`;
}
/** True when a saved assignment points at a host that is not currently listed. */
export function isProbeAgentMissing(
options: readonly ProbeAgentOption[],
probeAgentId?: string | null,
): boolean {
const normalized = (probeAgentId ?? '').trim();
if (!normalized) return false;
return !options.some((option) => option.id === normalized);
}
interface ProbeStaleStatusLike {
outcome?: string;
lastError?: string;
}
/**
* A probe-assigned check whose agent stopped reporting derives to
* `indeterminate` at read time. It is a warning state, not a failure.
*/
export function isProbeAgentStaleStatus(
status?: ProbeStaleStatusLike | AvailabilityProbeStatus | null,
): boolean {
if (!status) return false;
if (status.outcome !== 'indeterminate') return false;
return (status.lastError ?? '').trim().toLowerCase().includes(PROBE_AGENT_STALE_ERROR);
}
/** Headline for the external probe upgrade gate. */
export function getExternalProbeGateTitle(): string {
return getLicenseFeatureLabel(EXTERNAL_PROBE_FEATURE);
}
/** Body copy for the external probe upgrade gate. */
export function getExternalProbeGateBody(): string {
return `Running an availability check from a remote Pulse Agent host requires ${getFeatureMinTierLabel(
EXTERNAL_PROBE_FEATURE,
)}. Checks continue to run from this Pulse server on every plan.`;
}
/** Inline help shown under the locked control, mirroring `getTabLockReason`. */
export function getExternalProbeLockedHelpText(): string {
return `Remote probe hosts require ${getFeatureMinTierLabel(EXTERNAL_PROBE_FEATURE)}. This check runs from the Pulse server.`;
}
interface LicenseRequiredErrorLike {
status?: number;
feature?: string;
message?: string;
}
/**
* Detect the canonical 402 `license_required` response, so a race between the
* cached runtime capabilities and the server still lands on the upgrade gate
* instead of a raw error string.
*/
export function isExternalProbeLicenseError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const candidate = error as LicenseRequiredErrorLike;
if (candidate.status === 402) return true;
if ((candidate.feature ?? '').trim() === EXTERNAL_PROBE_FEATURE) return true;
return (candidate.message ?? '').toLowerCase().includes('license_required');
}