From 16a1574a18e73f6d48252d643575566ca392b919 Mon Sep 17 00:00:00 2001 From: Pulse Test Date: Sun, 30 Aug 2026 21:44:32 +0100 Subject: [PATCH] Add multi-location service delivery evidence --- .../v6/internal/SERVICE_ASSURANCE_PLAN.md | 25 +- .../v6/internal/subsystems/agent-lifecycle.md | 11 + .../v6/internal/subsystems/api-contracts.md | 12 + .../v6/internal/subsystems/monitoring.md | 47 +- .../internal/subsystems/security-privacy.md | 8 + .../internal/subsystems/unified-resources.md | 12 + frontend-modern/browser-verification.json | 46 +- .../src/api/availabilityTargets.ts | 27 ++ .../AvailabilityProbeStatusCard.tsx | 84 +++- .../AvailabilityProbeStatusCard.test.tsx | 63 +++ .../AvailabilityTargetSlot.tsx | 131 ++++-- .../__tests__/AvailabilityTargetSlot.test.tsx | 43 +- .../Settings/availabilitySettingsModel.ts | 17 + .../standalone/AvailabilityChecksTable.tsx | 10 +- frontend-modern/src/types/resource.ts | 24 + .../src/utils/availabilityProbeAgents.ts | 45 ++ internal/api/availability_handlers.go | 38 +- internal/api/availability_probe_agent_test.go | 36 ++ internal/api/connections_aggregator.go | 6 + internal/api/platform_mock_connections.go | 43 +- internal/config/availability.go | 155 ++++++- internal/config/availability_test.go | 39 ++ internal/mock/availability_fixtures.go | 32 ++ internal/monitoring/availability_poller.go | 425 ++++++++++++++++-- .../monitoring/availability_probe_agent.go | 72 ++- .../availability_probe_agent_test.go | 101 ++++- .../monitoring/canonical_guardrails_test.go | 25 +- internal/monitoring/monitor.go | 2 + .../unifiedresources/code_standards_test.go | 8 +- internal/unifiedresources/types.go | 24 + 30 files changed, 1403 insertions(+), 208 deletions(-) create mode 100644 frontend-modern/src/components/Infrastructure/__tests__/AvailabilityProbeStatusCard.test.tsx diff --git a/docs/release-control/v6/internal/SERVICE_ASSURANCE_PLAN.md b/docs/release-control/v6/internal/SERVICE_ASSURANCE_PLAN.md index 214b8eeb3..99b4d8503 100644 --- a/docs/release-control/v6/internal/SERVICE_ASSURANCE_PLAN.md +++ b/docs/release-control/v6/internal/SERVICE_ASSURANCE_PLAN.md @@ -1,7 +1,7 @@ # Infrastructure-Aware Service Assurance Plan Last updated: 2026-08-30 -Status: ACCEPTED — SLICES A, B, AND C IMPLEMENTED +Status: ACCEPTED — SLICES A, B, C, AND D IMPLEMENTED Governance surfaces: - `status.json.coverage_gaps.infrastructure-aware-service-assurance` @@ -228,7 +228,7 @@ Implementation record (2026-08-30): Slice A is delivered through the monitoring-owned categorical history store and rollups, server-authored configuration revisions and remote receipt timeline, the bounded `/api/availability-history` batch contract, and the URL-owned Availability -fleet presentation. Its release proofs live in the owner contract. Slices D +fleet presentation. Its release proofs live in the owner contract. Slices E through H remain ordered future work; acceptance of this product lane does not imply that deferred breadth is already delivered. @@ -269,7 +269,7 @@ re-entry. Current status and unified-resource facets preserve transport reachability separately from typed application correctness while the overall result remains the alert and history outcome. Legacy targets without an explicit contract retain their previous HEAD-with-bounded-GET-fallback -semantics. Slices D through H remain ordered future work. +semantics. Slices E through H remain ordered future work. ### Slice C: Discovery-Led Assurance Onboarding @@ -304,7 +304,7 @@ standalone endpoints, and creates one explicitly enabled availability target with the drawer's canonical resource ID only after the operator chooses the activation action. A machine-scoped queue supports bulk review and evidence-bound dismiss/restore without bulk activation or guessed resource -attachment. Slices D through H remain ordered future work. +attachment. Slices E through H remain ordered future work. ### Slice D: Multi-Location Delivery Evidence @@ -332,6 +332,23 @@ Exit conditions: - location names and customer-identifying network details obey the existing privacy and redaction policy. +Implementation record (2026-08-30): Slice D is delivered through a canonical +set of source-owned observation-location IDs on each saved target. The local +Pulse runtime and any selected eligible connected agents may observe the same +logical verification concurrently; monitoring retains their state, latency, +server-receipt freshness, and coverage separately, then derives one aggregate +state without duplicating the target resource. A reachable path plus a failed +or missing path is `degraded`, every current path must be unreachable before +the target is `unavailable`, and incomplete all-failed evidence remains +`unknown`. Stale agent paths become indeterminate evidence, retries remain +idempotent through the existing observation ID and configuration revision, +and only the assigned agent may author its location. Settings presents the +location set as a bounded multi-select, while the Availability fleet and +resource detail surfaces expose reporting coverage and path disagreement. +Compatibility with the former single `probeAgentId` contract is retained at +the API boundary without keeping single-agent execution as the canonical +model. Slices E through H remain ordered future work. + ### Slice E: Infrastructure-Aware Incident Synthesis Deliver deterministic incident grouping and causal narrowing over canonical diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index b73d9fca2..83952581d 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -6211,6 +6211,17 @@ remain ingestible through a deterministic server fallback, but they do not gain authority to author revision boundaries, target identity, retention, or the service-assurance timeline. +The server may assign one target to several agent-backed observation locations, +but each agent receives and reports only the targets whose canonical +`agent:` location it owns. Location membership is server-authored +configuration, not agent enrollment identity: it does not grant control-plane +authority, create another agent resource, or let one reporter stand in for a +different site. Reassignment increments the target configuration revision, so +late results from the previous location set are rejected. Local Pulse execution +can coexist with assigned agent execution for the same logical target, while +license lapse removes every remote location from the effective assignment and +leaves the local path as the fail-safe execution boundary. + Observer configuration is explicit, versioned, and file-backed. It contains no raw token values and resolves each token from a separate private absolute-path file. Proxmox registration is also destination-scoped: the primary retains its diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 7dd5e7b12..a40997736 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -8808,6 +8808,18 @@ metadata, but API freshness and `lastSeenAt` projections use the server-authored receipt time supplied by monitoring. API consumers must not substitute the agent clock for disconnect detection or apply a second, shorter generic Connections staleness window. +Availability target writes now use `observationLocationIds` as the canonical +bounded set. Values are source-owned IDs (`pulse:local` or +`agent:`), are normalized and deduplicated by the server, and every +remote member requires the external-probe entitlement plus an existing agent. +The compatibility `probeAgentId` field is accepted for older single-agent +clients and is emitted only as a compatibility hint when the selected set can +be represented by one remote path. Current status and unified-resource +transport add aggregate state, disagreement, expected/reporting counts, and a +per-location list containing only location ID, state, latency, observation and +receipt times, freshness, and a bounded failure code. Callers must consume that +server-authored aggregate rather than treating the latest reporting path as the +whole service result. Mock availability fixtures must still behave like saved targets: `/api/connections` reports them as availability rows, `/api/availability-targets` lists them with probe status, and saved-test calls return the synthetic probe result instead of diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index ba0616d60..7e64851ae 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -1566,24 +1566,35 @@ the frontend-primitives-owned Standalone surface may read the same `network-endpoint` projection to show current reachability, latency, check age, and failure state without creating another monitoring provider or top-level availability route. -Availability targets may also be assigned to a remote host agent. Reachability -outcome and the optional certificate observation travel in the same bounded -report entry, and the server clones that observation before status and resource -projection so report buffers cannot alias live state. While the -`external_probe` entitlement is active, a probe-assigned target is executed -exclusively by its assigned agent: monitoring must not schedule or run it -locally, so the check never executes twice. The assignment is effective only for -as long as the entitlement holds; on lapse the effective assignment collapses to -local and the normal poll provider resumes the target on its next planning -cycle, without a restart. Reported results are accepted only from the agent that -currently owns the target, and results for any other target or from any other -agent are dropped. Failure accounting, thresholds, and incident projection stay -server-side. The agent-authored observation time remains visible as the target's -last check, but staleness uses server receipt time so slow or fast agent clocks -cannot manufacture or conceal a disconnect. When an assigned agent stops -reporting, monitoring derives indeterminate with a stale-report explanation at -read time through the shared probe-status snapshot rather than mutating stored -state, so every availability consumer sees the same staleness verdict. +Availability execution is location-owned. Each saved target has a normalized, +deduplicated set of observation-location IDs: `pulse:local` for this Pulse +runtime and `agent:` for an eligible connected host agent. The legacy +single `probeAgentId` field remains an input compatibility boundary only; the +location set owns scheduling, configuration revision, assignment, status, and +resource projection. While `external_probe` is entitled, Pulse may schedule +the local path and every selected agent path for one logical target at the same +time. On entitlement lapse the effective set collapses to the local path +without a restart. An agent result is accepted only for its exact selected +location and current target revision; another agent cannot author that path. + +Reachability outcome and the optional certificate observation still travel in +the same bounded report entry, and the server clones that observation before +status and resource projection so report buffers cannot alias live state. +Monitoring retains one current status per target and location, using server +receipt time for remote freshness while keeping the agent-authored observation +time as evidence metadata. A stale or disconnected agent path derives as +indeterminate at read time; slow or fast agent clocks cannot manufacture or +conceal a disconnect. Aggregation is conservative: all reachable paths are +healthy, mixed reachable and failed/unknown paths are degraded, all current +paths unreachable are unavailable, and no reachable path with incomplete or +indeterminate coverage is unknown. Only aggregate unavailability advances the +target failure threshold or authors an outage incident. A path-local failure +therefore cannot claim that the service is universally down. Single-location +targets preserve their prior status and history semantics. Multi-location +targets write only their aggregate service conclusion to the logical target's +deletable categorical history; current per-location evidence remains a status +and resource projection until a separately governed location-history schema +can preserve deletion, retention, and bounded-query semantics end to end. Mock-mode Discovery context follows the same fixture-graph rule. Demo service details such as detected version, config/data/log paths, Docker bind mounts, ports, and suggested web URLs may be authored in mock fixtures, but consumers diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 4ad8d7943..21519bcd4 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -1331,6 +1331,14 @@ alerts. Those counts may describe scale and feature adoption, but they must not include hostnames, resource IDs, infrastructure identifiers, credentials, prompts, chat messages, command text, action output, token values, or personal information. +Multi-location availability evidence stays inside the same privacy floor. +Saved configuration and authenticated operator surfaces may use source-owned +location IDs and agent display names to distinguish selected paths, but +categorical history, evidence envelopes, telemetry, incidents, logs, and +Patrol context must not retain or export customer network names, addresses, +agent identity, or location labels. Durable categorical history remains keyed +only by the logical target and its aggregate conclusion; location identity is +not a new telemetry, history, or customer-identity dimension. That same outbound usage telemetry floor now also permits content-free update funnel counters derived from local update history inside the same rotating 30-day telemetry window: update attempts, successful updates, failed or diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 71d6de57f..b71946edf 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -4670,6 +4670,18 @@ the shared resource drawer by canonical resource ID, while already carried by that resource. History absence or failure cannot overwrite the unified resource's current status, correlation, source ownership, or navigation identity. +One availability resource may now carry several source-owned observation +locations without cloning the resource, relationship, or fleet row. Its +availability facet preserves the aggregate state, disagreement flag, +expected/reporting coverage, and the current state, latency, freshness, and +bounded failure reason for each path. Resource status remains active with +warning evidence when paths disagree, becomes stale/incomplete when coverage +is unknown, and becomes inactive with an outage incident only when every +current path is unreachable at the configured threshold. Frontend adapters, +REST, websocket, and mock projections must preserve this list rather than +flattening it to the most recent observer. The Availability table may summarize +the count and reporting coverage; the shared resource detail card owns the +expanded path comparison. For HTTP/S checks with an explicit application response contract, the availability facet preserves two source-owned facts: `transportOutcome` describes whether the endpoint answered, while `applicationOutcome`, diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 6167c0471..66f7f3631 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,24 +1,29 @@ { "version": 1, - "base_sha": "d9e9bf3679ab579be9f8c7350932008c287ad5ad", - "verified_at": "2026-08-30T20:24:19Z", + "base_sha": "e094a55b4500d2a10b6ab44b350c2fffcc55e73d", + "verified_at": "2026-08-30T20:37:10Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts", - "frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx", - "frontend-modern/src/hooks/useWorkloads.ts", - "frontend-modern/src/types/api.ts", - "frontend-modern/src/types/resource.ts" + "frontend-modern/src/api/availabilityTargets.ts", + "frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx", + "frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx", + "frontend-modern/src/components/Settings/availabilitySettingsModel.ts", + "frontend-modern/src/features/standalone/AvailabilityChecksTable.tsx", + "frontend-modern/src/types/resource.ts", + "frontend-modern/src/utils/availabilityProbeAgents.ts" ], "content_sha256": { - "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts": "8b1913ff24e6f5dbf074091f2bbaff1a8c3298126515ea288b055a02c0153997", - "frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx": "621af65f92c9d58312f019790ecf2eb6986fb260724e9ef53213972fd9b55579", - "frontend-modern/src/hooks/useWorkloads.ts": "85719e8c93f503843cd09c1be41c66f9995288d1280ca2eedf7d47c3443bd9a1", - "frontend-modern/src/types/api.ts": "be1b1889588085791880dbd6716955710cd66e3dc69339998f8b9ad30c0ac378", - "frontend-modern/src/types/resource.ts": "74b26ae53a507f5608a3c191f7c386cffab636e0a95aeffa02028bd8199297b0" + "frontend-modern/src/api/availabilityTargets.ts": "0a20277a8c453b753e8c09f7250722670211a33bcae2addad7d19502f921f365", + "frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx": "ec13f364ee9c55f6859c448797fed427255999723524ff0f51cb54cc9f864ea5", + "frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx": "622e0c7ad72c4f313c8d971295662e6549d9f13c5fbd788d3cc8803c827af176", + "frontend-modern/src/components/Settings/availabilitySettingsModel.ts": "cd03ed43bca379c8359c11c67f235d49a4a1c6b905c987a4ce404ce59443bbf6", + "frontend-modern/src/features/standalone/AvailabilityChecksTable.tsx": "3c775ad5b08023f9a2814a9dc939992db363a7e5113a351d4fb65ad0946f0d97", + "frontend-modern/src/types/resource.ts": "a4e87ae08ec69349122075ca422ced08a56e1f14294088488198c5ff8af25dba", + "frontend-modern/src/utils/availabilityProbeAgents.ts": "7f92c5fc5012bdbb322825b4a03f26fb5dc6d20d260cda8b2c2e86b66283b005" }, "routes": [ - "/browser-helper-summary-harness.html (temporary current-build Vite harness for the Docker host drawer)" + "/standalone/availability", + "/settings/monitoring/availability?add=target&targetKind=service" ], "viewports": [ { @@ -31,14 +36,15 @@ } ], "states": [ - "typed-helper summary mode with reduced-coverage warning and no container update management card", - "direct-runtime mode without the reduced-coverage warning and with container update management controls", - "return to typed-helper summary mode after direct-runtime mode" + "multi-location target editor with the local Pulse path and connected-agent paths, including the Community-tier remote-path entitlement boundary", + "degraded service with one reachable and one unreachable observation path, preserved as Paths disagree with 2/2 reporting", + "expanded resource evidence showing per-path state, latency, checked time, freshness, aggregate degraded state, and no universal outage", + "desktop and narrow layouts with settled data, dialog bounds, responsive navigation, and no page or dialog horizontal overflow" ], "interactions": [ - "loaded summary mode and confirmed warning visibility, update-control omission, and no desktop horizontal overflow", - "switched to direct-runtime mode and confirmed the warning disappeared and update controls appeared", - "switched back to summary mode and confirmed the warning returned and update controls were removed", - "inspected the final summary state at 390x844 and confirmed no horizontal overflow or clipped warning copy" + "opened the add service/device check flow and inspected the bounded observation-location selector without saving a mock target", + "opened the Frontend Swarm service resource details from Availability checks and expanded the multi-location evidence", + "confirmed the local path reports 9 ms while edge-london reports Unreachable and the aggregate remains Degraded", + "repeated the final route, state, clipping, and overflow checks at 1280x800 and 390x844 after rebasing onto the current main base" ] } diff --git a/frontend-modern/src/api/availabilityTargets.ts b/frontend-modern/src/api/availabilityTargets.ts index 482b729d2..c0366b79c 100644 --- a/frontend-modern/src/api/availabilityTargets.ts +++ b/frontend-modern/src/api/availabilityTargets.ts @@ -67,9 +67,33 @@ export interface AvailabilityProbeStatus { * Absent (omitempty) when the local Pulse server ran the check. */ probeAgentId?: string; + aggregateState?: 'healthy' | 'degraded' | 'unavailable' | 'unknown' | string; + disagreement?: boolean; + expectedLocations?: number; + reportingLocations?: number; + locations?: AvailabilityObservationLocationStatus[]; certificate?: ResourceCertificateObservation; } +export interface AvailabilityObservationLocationStatus { + locationId: string; + kind: 'pulse' | 'agent' | string; + probeAgentId?: string; + outcome?: 'reachable' | 'unreachable' | 'indeterminate' | string; + transportOutcome?: string; + applicationOutcome?: string; + applicationStatusCode?: number; + applicationFailureCode?: string; + available: boolean; + lastChecked?: string; + lastSuccess?: string; + freshnessAt?: string; + latencyMillis?: number; + consecutiveFailures?: number; + lastError?: string; + stale?: boolean; +} + export interface AvailabilityTarget { id: string; configRevision?: number; @@ -96,6 +120,9 @@ export interface AvailabilityTarget { * string because the server decodes updates onto the existing record. */ probeAgentId?: string; + /** Canonical execution set. `pulse:local` names this Pulse server and + * `agent:` names a connected Pulse Agent observation location. */ + observationLocationIds?: string[]; http?: AvailabilityHTTPConfig; httpSecrets?: AvailabilityHTTPSecretState; status?: AvailabilityProbeStatus; diff --git a/frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx b/frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx index 49284797b..9be3448ce 100644 --- a/frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx +++ b/frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx @@ -46,8 +46,13 @@ export function AvailabilityProbeStatusCards(props: AvailabilityProbeStatusCards } export function AvailabilityProbeStatusCard(props: AvailabilityProbeStatusCardProps) { - const isUp = () => props.availability.available === true; - const isDown = () => props.availability.available === false; + const aggregateState = () => props.availability.aggregateState; + const isUp = () => + aggregateState() ? aggregateState() === 'healthy' : props.availability.available === true; + const isDegraded = () => aggregateState() === 'degraded'; + const isUnknown = () => aggregateState() === 'unknown'; + const isDown = () => + aggregateState() ? aggregateState() === 'unavailable' : props.availability.available === false; const latency = () => { const ms = props.availability.latencyMillis; return typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? `${Math.round(ms)}ms` : null; @@ -129,6 +134,19 @@ export function AvailabilityProbeStatusCard(props: AvailabilityProbeStatusCardPr if (days === 0) return `${date} (today)`; return `${date} (${days}d)`; }; + const locationLabel = (location: NonNullable[number]) => + location.kind === 'pulse' + ? 'This Pulse server' + : location.probeAgentId || location.locationId.replace(/^agent:/, '') || 'Pulse Agent'; + const locationResult = (location: NonNullable[number]) => { + if (location.stale) return 'No recent report'; + if (!location.lastChecked) return 'Awaiting evidence'; + if (location.outcome === 'reachable') { + return location.latencyMillis ? `${Math.round(location.latencyMillis)} ms` : 'Reachable'; + } + if (location.outcome === 'unreachable') return 'Unreachable'; + return 'Unknown'; + }; return ( @@ -145,11 +163,22 @@ export function AvailabilityProbeStatusCard(props: AvailabilityProbeStatusCardPr 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300': isFreshUp(), 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300': isDown() && !isStale(), - 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300': isStale(), - 'bg-base-200 text-muted': !isUp() && !isDown() && !isStale(), + 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300': + isStale() || isDegraded(), + 'bg-base-200 text-muted': isUnknown() || (!isUp() && !isDown() && !isStale()), }} > - {isStale() ? 'Stale' : isUp() ? 'Up' : isDown() ? 'Down' : 'Not checked'} + {isDegraded() + ? 'Paths disagree' + : isUnknown() + ? 'Coverage incomplete' + : isStale() + ? 'Stale' + : isUp() + ? 'Up' + : isDown() + ? 'Down' + : 'Not checked'}
@@ -171,6 +200,51 @@ export function AvailabilityProbeStatusCard(props: AvailabilityProbeStatusCardPr } /> + 0}> +
+
+ Observation paths + + {props.availability.reportingLocations ?? 0}/ + {props.availability.expectedLocations ?? props.availability.locations?.length ?? 0}{' '} + reporting + +
+ + {(location) => ( +
+
+
+ {locationLabel(location)} +
+ + {(checked) =>
checked {checked()}
} +
+
+ + {locationResult(location)} + +
+ )} +
+
+
{ + it('shows disagreement and keeps every observation path visible', () => { + render(() => ( + + )); + + expect(screen.getByText('Paths disagree')).toBeInTheDocument(); + const paths = screen.getByText('Observation paths').parentElement?.parentElement; + expect(paths).not.toBeNull(); + const scope = within(paths!); + expect(scope.getByText('2/3 reporting')).toBeInTheDocument(); + expect(scope.getByText('This Pulse server')).toBeInTheDocument(); + expect(scope.getByText('8 ms')).toBeInTheDocument(); + expect(scope.getByText('edge-a')).toBeInTheDocument(); + expect(scope.getByText('Unreachable')).toBeInTheDocument(); + expect(scope.getByText('edge-b')).toBeInTheDocument(); + expect(scope.getByText('No recent report')).toBeInTheDocument(); + }); +}); diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx index f2163dce7..69e98c81f 100644 --- a/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx +++ b/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx @@ -33,13 +33,14 @@ import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity' import { getResourceTypeLabel } from '@/utils/resourceTypePresentation'; import { EXTERNAL_PROBE_FEATURE, - LOCAL_PROBE_AGENT_LABEL, - buildProbeAgentOptions, + AGENT_OBSERVATION_LOCATION_PREFIX, + LOCAL_OBSERVATION_LOCATION_ID, + agentIdFromObservationLocation, + buildObservationLocationOptions, getExternalProbeGateBody, getExternalProbeGateTitle, getExternalProbeLockedHelpText, isExternalProbeLicenseError, - isProbeAgentMissing, } from '@/utils/availabilityProbeAgents'; import { hasFeature, loadRuntimeCapabilities, runtimeCapabilitiesLoaded } from '@/stores/license'; import { getUpgradeActionDestination } from '@/stores/licenseCommercial'; @@ -58,7 +59,7 @@ interface AvailabilityForm { udpRequest: string; udpExpectedResponse: string; linkedResourceId: string; - probeAgentId: string; + observationLocationIds: string[]; enabled: boolean; pollIntervalSeconds: string; timeoutMillis: string; @@ -118,7 +119,7 @@ const newAvailabilityForm = ( udpRequest: '', udpExpectedResponse: '', linkedResourceId: '', - probeAgentId: '', + observationLocationIds: [LOCAL_OBSERVATION_LOCATION_ID], enabled: true, pollIntervalSeconds: '60', timeoutMillis: '2000', @@ -159,7 +160,11 @@ const formFromTarget = (target: AvailabilityTarget): AvailabilityForm => { udpRequest: target.udpRequest ?? '', udpExpectedResponse: target.udpExpectedResponse ?? '', linkedResourceId: target.linkedResourceId ?? '', - probeAgentId: target.probeAgentId ?? '', + observationLocationIds: target.observationLocationIds?.length + ? [...target.observationLocationIds] + : target.probeAgentId + ? [`${AGENT_OBSERVATION_LOCATION_PREFIX}${target.probeAgentId}`] + : [LOCAL_OBSERVATION_LOCATION_ID], enabled: target.enabled ?? true, pollIntervalSeconds: String(target.pollIntervalSeconds ?? 60), timeoutMillis: String(target.timeoutMillis ?? 2000), @@ -247,7 +252,11 @@ const payloadFromForm = (form: AvailabilityForm): AvailabilityTarget => { // 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(), + probeAgentId: + form.observationLocationIds.length === 1 + ? agentIdFromObservationLocation(form.observationLocationIds[0]) + : '', + observationLocationIds: [...form.observationLocationIds], enabled: form.enabled, pollIntervalSeconds: parsePositiveInt(form.pollIntervalSeconds), timeoutMillis: parsePositiveInt(form.timeoutMillis), @@ -348,10 +357,11 @@ export const AvailabilityTargetSlot: Component = (p // 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 observationLocationOptions = createMemo(() => buildObservationLocationOptions(resources())); + const missingObservationLocations = createMemo(() => { + const known = new Set(observationLocationOptions().map((option) => option.id)); + return form().observationLocationIds.filter((locationId) => !known.has(locationId)); + }); const externalProbeLicensed = createMemo( () => !probeLicenseRejected() && runtimeCapabilitiesLoaded() && hasFeature(EXTERNAL_PROBE_FEATURE), @@ -378,6 +388,15 @@ export const AvailabilityTargetSlot: Component = (p setTestResult(null); }; + const toggleObservationLocation = (locationId: string, selected: boolean) => { + const current = form().observationLocationIds; + const next = selected + ? [...new Set([...current, locationId])] + : current.filter((candidate) => candidate !== locationId); + if (next.length === 0) return; + updateForm({ observationLocationIds: next }); + }; + const updateHTTPHeader = (index: number, patch: Partial) => { updateForm({ httpHeaders: form().httpHeaders.map((header, headerIndex) => @@ -490,7 +509,12 @@ export const AvailabilityTargetSlot: Component = (p } props.onSaved(); } catch (err) { - if (payload.probeAgentId && isExternalProbeLicenseError(err)) { + if ( + payload.observationLocationIds?.some((locationId) => + locationId.startsWith(AGENT_OBSERVATION_LOCATION_PREFIX), + ) && + isExternalProbeLicenseError(err) + ) { setProbeLicenseRejected(true); setError(getExternalProbeGateBody()); return; @@ -610,28 +634,67 @@ 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) => } +
+ Observation locations +

+ One logical check can be observed from several networks. Pulse keeps each path separate + and shows disagreement instead of turning one path failure into a universal outage. +

+
+ + {(option) => { + const selected = () => form().observationLocationIds.includes(option.id); + const locked = () => option.kind === 'agent' && externalProbeLocked(); + return ( + + ); + }} - + + {(locationId) => ( + + )} + +
+ +

{getExternalProbeLockedHelpText()}

+
= (p />
-
+