diff --git a/frontend-modern/public/docs/CONFIGURATION.md b/frontend-modern/public/docs/CONFIGURATION.md index bc89181c3..010c97934 100644 --- a/frontend-modern/public/docs/CONFIGURATION.md +++ b/frontend-modern/public/docs/CONFIGURATION.md @@ -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` diff --git a/frontend-modern/src/api/__tests__/availabilityTargets.test.ts b/frontend-modern/src/api/__tests__/availabilityTargets.test.ts index 244112d59..f54e68849 100644 --- a/frontend-modern/src/api/__tests__/availabilityTargets.test.ts +++ b/frontend-modern/src/api/__tests__/availabilityTargets.test.ts @@ -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: '', diff --git a/frontend-modern/src/api/availabilityTargets.ts b/frontend-modern/src/api/availabilityTargets.ts index b5b31db76..6e9160ad4 100644 --- a/frontend-modern/src/api/availabilityTargets.ts +++ b/frontend-modern/src/api/availabilityTargets.ts @@ -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, diff --git a/frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx b/frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx index b5a736d5a..95698fb41 100644 --- a/frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx +++ b/frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx @@ -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([]); const [loading, setLoading] = createSignal(false); const [error, setError] = createSignal(null); @@ -272,6 +278,20 @@ export const AvailabilitySettingsPanel: Component = () => { > {getAvailabilityTargetStatusLabel(target)} + + {(sourceLabel) => ( + + {sourceLabel()} + + )} +
{getAvailabilityTargetKindLabel(target)} diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx index cfda16835..e70865a75 100644 --- a/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx +++ b/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx @@ -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 = (p const [testing, setTesting] = createSignal(false); const [error, setError] = createSignal(null); const [testResult, setTestResult] = createSignal(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 = (p }; onMount(async () => { + void loadRuntimeCapabilities(); const targetId = props.editingTargetId?.trim(); if (!targetId) return; setLoading(true); @@ -287,6 +324,11 @@ export const AvailabilityTargetSlot: Component = (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 = (p )} +
+ 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) => } + + + +
+ +
+
+