mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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>
|
||||
|
||||
+75
@@ -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>
|
||||
|
||||
+183
@@ -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([]);
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user