From c0b190bfa15aac18ae27f026e3ae17727acbf5ff Mon Sep 17 00:00:00 2001 From: Pulse Test Date: Sun, 30 Aug 2026 17:35:33 +0100 Subject: [PATCH] Add application response contracts to availability checks --- .../v6/internal/SERVICE_ASSURANCE_PLAN.md | 17 +- .../v6/internal/subsystems/agent-lifecycle.md | 11 + .../v6/internal/subsystems/api-contracts.md | 23 + .../v6/internal/subsystems/monitoring.md | 24 +- .../internal/subsystems/storage-recovery.md | 10 + .../internal/subsystems/unified-resources.md | 9 + frontend-modern/browser-verification.json | 37 +- .../src/api/availabilityTargets.ts | 48 ++ .../AvailabilityProbeStatusCard.tsx | 40 ++ .../AvailabilityTargetSlot.tsx | 538 +++++++++++++++--- .../__tests__/AvailabilityTargetSlot.test.tsx | 175 ++++++ .../AvailabilityProbeStatusCard.test.tsx | 23 + frontend-modern/src/types/resource.ts | 4 + internal/api/availability_handlers.go | 182 +++++- internal/api/availability_handlers_test.go | 132 +++++ internal/availabilityprobe/probe.go | 222 +++++++- internal/availabilityprobe/probe_test.go | 78 +++ internal/config/availability.go | 250 +++++++- internal/config/availability_test.go | 92 ++- internal/hostagent/availability.go | 31 +- internal/hostagent/availability_test.go | 29 + internal/monitoring/availability_poller.go | 128 +++-- .../monitoring/availability_probe_agent.go | 70 ++- .../availability_probe_agent_test.go | 5 +- .../unifiedresources/code_standards_test.go | 14 +- internal/unifiedresources/types.go | 56 +- pkg/agents/host/report.go | 20 +- 27 files changed, 2020 insertions(+), 248 deletions(-) diff --git a/docs/release-control/v6/internal/SERVICE_ASSURANCE_PLAN.md b/docs/release-control/v6/internal/SERVICE_ASSURANCE_PLAN.md index 8c380992e..2fec1f442 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 — SLICE A IMPLEMENTED +Status: ACCEPTED — SLICES A AND B 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 B +fleet presentation. Its release proofs live in the owner contract. Slices C through H remain ordered future work; acceptance of this product lane does not imply that deferred breadth is already delivered. @@ -258,6 +258,19 @@ Exit conditions: content; - current reachability-only configurations migrate without semantic change. +Implementation record (2026-08-30): Slice B is delivered through one bounded, +explicit HTTP/S contract shared by local and assigned-agent execution. The +saved target owns method, accepted status range, optional POST body, headers, +Basic or bearer authentication, text assertion, and structured JSON field +equality. Write-only values are encrypted at rest, omitted from API and +evidence payloads, and reused only while the endpoint origin is unchanged; +changing scheme, host, or effective port requires explicit credential +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 C through H remain ordered future work. + ### Slice C: Discovery-Led Assurance Onboarding Deliver: diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index b7d896a27..640e7ffbe 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -306,6 +306,17 @@ shape so local and remote checks derive identical trust vocabulary. Certificate identity, validity, and trust are observation payloads only. They do not renew the target lease, agent enrollment, host heartbeat, or command readiness beyond the accepted report's existing server-receipt evidence. +An assigned HTTP/S application-response contract travels to the owning agent +only through its authenticated remote-config assignment. The agent may receive +the encrypted-at-rest request body, Basic password, bearer token, and operator +header values because it must execute the check, but it must never copy those +values, response bytes, or arbitrary remote content into logs, module status, +the host report, telemetry, or Patrol context. The shared probe returns only +the overall outcome, transport outcome, typed application outcome, HTTP status, +bounded failure code/message, latency, and optional certificate observation. +Local and assigned-agent execution must therefore use the same method, status, +body-size, assertion, redirect, and outbound-URL limits, while the report +ingestion boundary continues to reject stale revisions and non-owning agents. Explicit disk inclusion is an agent lifecycle input. The CLI, persisted service arguments, unified host agent, and Docker/Podman agent must carry the diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index bdf8e351c..44bad58f0 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1465,6 +1465,29 @@ payload shape change when the portal presents compact client rows. the same secret-free `certificate` observation returned by the runtime probe, while reachability success remains independent from trust posture so an operator can diagnose an untrusted but reachable endpoint. + HTTP/S targets may also carry one explicit `http` application-response + contract. Its bounded vocabulary is `HEAD`, `GET`, or `POST`, one accepted + status range, at most sixteen operator headers, optional Basic or bearer + authentication, an optional POST body of at most 8 KiB, and bounded text + and JSON-path assertions over at most 64 KiB of response content. An absent + `http` block preserves the legacy HEAD-with-GET-fallback behavior for saved + targets without a migration rewrite. Every execution-field edit, including + a contract or secret change, advances the server-authored configuration + revision and therefore the existing availability-history revision boundary. + Request bodies, passwords, bearer tokens, and header values are write-only. + List/create/update responses return only `httpSecrets` presence markers; + Updates and unsaved tests that identify an existing target merge omitted + write-only values from encrypted persistence before validation/execution + only while scheme, host, and effective port remain unchanged. An endpoint + origin change clears omitted body/header values and requires Basic or + bearer credentials to be entered again before the target can be saved or + tested; stored credentials must never follow an address edit silently. + Test and current-status responses expose transport reachability separately + from the typed application outcome, status code, and bounded failure code, + but never return the request body, response body, credential values, or + arbitrary remote content. Contract tests must pin redaction, unchanged- + secret preservation, bounded evaluation, and the reachable-but-incorrect + application case. Availability target protocol vocabulary is canonicalized at the API/config boundary: clients may submit `ping` as a user-facing alias for the ICMP check, but saved targets, API reads, probe status, connections rows, and diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 3f645d35d..c519f2296 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -761,10 +761,14 @@ cleanup so readers cannot retain orphaned runtime or alert projections. poll-provider path. `internal/monitoring/availability_poller.go` owns ICMP, TCP, and HTTP probes, provider health, scheduler task construction, and supplemental unified-resource records for saved availability targets. - HTTP and HTTPS probes start with `HEAD` and retry once with `GET` only when - the endpoint explicitly reports that `HEAD` is unsupported (`405 Method - Not Allowed` or `501 Not Implemented`). Other server-error responses remain - failed probes and must not be converted into reachability evidence. + Legacy HTTP and HTTPS targets without an explicit response contract start + with `HEAD` and retry once with `GET` only when the endpoint explicitly + reports that `HEAD` is unsupported (`405 Method Not Allowed` or `501 Not + Implemented`). Explicit application contracts instead execute their bounded + `HEAD`, `GET`, or `POST` request exactly once, distinguish a received HTTP + response from an application assertion failure, and inspect no more than + 64 KiB of response content. Other server-error responses remain failed + probes and must not be converted into healthy application evidence. Failed endpoint probes are observed runtime state for that target; they must publish provider health and incidents without dead-lettering the scheduler task itself. @@ -1464,6 +1468,18 @@ retention lifecycle, are deleted with the target, and retain no target address, agent identity, raw error, certificate detail, or customer identity. The monitoring-read batch path is bounded to 200 targets and 120 presentation buckets without a query per target. +HTTP/S application response contracts extend that same source-owned provider +without creating a second uptime product or result store. Current status and +unified-resource availability data carry the transport outcome separately from +the typed application outcome, HTTP status, and bounded failure code; the +overall availability outcome remains the alert/history state so a reachable +endpoint returning an incorrect application response can still fail the saved +service check. Request bodies, response bodies, Basic passwords, bearer tokens, +and operator header values never enter status, categorical history, incidents, +evidence envelopes, telemetry, logs, or Patrol context. Execution-defined +contract edits use the existing server-authored configuration revision so +history visibly breaks at the new proof definition instead of blending unlike +checks. HTTPS checks also author one canonical certificate observation from the same probe execution. `internal/availabilityprobe` captures the presented leaf and `pkg/tlsutil/certificate.go` derives subject, issuer, SANs, SHA-256 fingerprint, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 8e6517732..f234e58a8 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -1747,6 +1747,16 @@ recovery scope, or a storage/recovery-owned secret source. `certificate_*` incident on those availability facets remain monitoring and alerts context. They do not prove storage encryption, backup integrity, repository authenticity, protection coverage, or restore readiness. + The same neutrality applies to HTTP/S application-response contracts. + Transport and application outcomes may explain liveness, but accepted + status ranges, text/JSON assertions, and typed failure codes are not backup + verification or restore evidence. Request bodies, response bodies, Basic + passwords, bearer tokens, and operator header values remain encrypted + configuration inputs and must never enter storage/recovery inventory, + history, exports, evidence, diagnostics, or recovery authority. + Omitted write-only values may be reused only while the HTTP/S endpoint + origin is unchanged; a scheme, host, or effective-port change must not + replay stored values to the new endpoint. 31. Keep infrastructure summary chart bucketing and short response caching presentation-only on the adjacent shared API boundary. When `internal/api/chartapi/service.go` normalizes mixed-cadence infrastructure history into equal-time summary buckets or serves a cached summary payload for repeated operator-facing summary-card requests, storage and recovery may consume the resulting visual context only; they must not reinterpret those normalized chart samples, cached timestamps, or cache hits as recovery freshness windows, backup cadence, or restore evidence. Expiry sweeps, oldest-payload eviction, and byte/cardinality limits in that shared chart cache are transport memory controls only. Storage and recovery diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 9105b06c3..925f67936 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -4653,6 +4653,15 @@ 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. +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`, +`applicationStatusCode`, and `applicationFailureCode` describe whether the +bounded saved contract passed. The overall resource status still follows the +saved check outcome, so an answering endpoint with an incorrect application +response is not presented as healthy. Resource payloads, evidence envelopes, +incidents, relationships, and history must never contain request or response +bodies, Basic passwords, bearer tokens, or operator header values. Shared unified-resource consumers now also normalize org scope through `frontend-modern/src/utils/orgScope.ts` before building cache keys or multi-tenant resource fetch state, so the canonical resource hooks do not diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index c7e6fdbe9..896a3c871 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,18 +1,22 @@ { "version": 1, - "base_sha": "712f1a3bfeff8f78de242b97fc66526d8f7c5761", - "verified_at": "2026-08-30T15:06:15Z", + "base_sha": "34849268e58652489d2a359886fb7fdd5bddd959", + "verified_at": "2026-08-30T16:33:41Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/shared/Dialog.tsx", - "frontend-modern/src/components/shared/useDialogState.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/types/resource.ts" ], "content_sha256": { - "frontend-modern/src/components/shared/Dialog.tsx": "58b41841c2ac21939a9e1493754c314e77a1650d1d024de152ebbb39ba3d67a0", - "frontend-modern/src/components/shared/useDialogState.ts": "290cab00130c6de5731499a008cc432eb588c97c62e24d0b21940946d37e6e9c" + "frontend-modern/src/api/availabilityTargets.ts": "e8eba84f95617889782888152ef55fd92668071d35873e622de3a4fb0f39fee5", + "frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx": "0f498ccc635f02f4d64848995bc3bb9a1fc4cf060812d0f66fa2a918976c25a8", + "frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx": "8f47a84f61d5ea083743a7945803adc85c7495b67bd1d97df807ec91750a2330", + "frontend-modern/src/types/resource.ts": "a524ae7b84ad2400e2a7cf1fab213824f3a9335cdadb9f42f1d73a5a0b21978f" }, "routes": [ - "/settings/infrastructure" + "/settings/monitoring/availability?add=target" ], "viewports": [ { @@ -25,15 +29,18 @@ } ], "states": [ - "The Add infrastructure dialog remained visible and horizontally contained with its close control focused at desktop and narrow viewports", - "The active dialog portal stayed interactive while the app root and every other body-level surface were inert", - "A body-level surface added after opening became inert before inspection", - "Closing restored ordinary surfaces while preserving a surface that was already inert before opening" + "new HTTP availability check with the explicit application-response contract", + "GET contract with text assertion and reachable endpoint whose application assertion failed", + "GET contract with reachable endpoint and passing application assertion", + "POST contract with request body, bearer authentication, and operator header controls", + "phone-width failure and success feedback positioned above the sticky action footer" ], "interactions": [ - "opened Add infrastructure from its real settings trigger at desktop and narrow viewports", - "verified a late-added background action could not be activated while the modal was open", - "closed with Escape and verified focus returned to the Add infrastructure trigger", - "activated the late-added action after close to verify restored operability" + "changed probe type from ICMP to HTTP", + "changed request method between GET and POST", + "selected bearer authentication and added then removed an operator header", + "entered an endpoint and text assertion and ran test-before-save", + "confirmed transport-reachable application failure and full contract success messages", + "verified the contract controls and feedback at desktop and phone widths" ] } diff --git a/frontend-modern/src/api/availabilityTargets.ts b/frontend-modern/src/api/availabilityTargets.ts index 6b6df8d9f..482b729d2 100644 --- a/frontend-modern/src/api/availabilityTargets.ts +++ b/frontend-modern/src/api/availabilityTargets.ts @@ -6,6 +6,42 @@ const AVAILABILITY_TARGETS_PATH = '/api/availability-targets'; export type AvailabilityProbeProtocol = 'icmp' | 'tcp' | 'udp' | 'http' | 'https'; export type AvailabilityUDPMode = 'response_required' | 'open_or_filtered'; export type AvailabilityTargetKind = 'machine' | 'service' | 'device'; +export type AvailabilityHTTPMethod = 'HEAD' | 'GET' | 'POST'; +export type AvailabilityHTTPAuthType = 'none' | 'basic' | 'bearer'; + +export interface AvailabilityHTTPHeader { + id: string; + name: string; + /** Write-only. Omitted values preserve the stored secret on update. */ + value?: string; +} + +export interface AvailabilityHTTPConfig { + method: AvailabilityHTTPMethod; + headers?: AvailabilityHTTPHeader[]; + authentication: { + type: AvailabilityHTTPAuthType; + username?: string; + /** Write-only. */ + password?: string; + /** Write-only. */ + bearerToken?: string; + }; + /** Write-only POST body. */ + body?: string; + expectedStatusMin: number; + expectedStatusMax: number; + textContains?: string; + jsonPath?: string; + jsonEquals?: string; +} + +export interface AvailabilityHTTPSecretState { + bodyConfigured: boolean; + passwordConfigured: boolean; + bearerTokenConfigured: boolean; + headers?: Array<{ id: string; valueConfigured: boolean }>; +} export interface AvailabilityProbeStatus { targetId: string; @@ -14,6 +50,10 @@ export interface AvailabilityProbeStatus { address: string; protocol: AvailabilityProbeProtocol | string; outcome?: 'reachable' | 'unreachable' | 'indeterminate' | string; + transportOutcome?: 'reachable' | 'unreachable' | 'indeterminate' | string; + applicationOutcome?: 'not_configured' | 'passed' | 'failed' | string; + applicationStatusCode?: number; + applicationFailureCode?: string; enabled: boolean; available: boolean; lastChecked?: string; @@ -56,6 +96,8 @@ export interface AvailabilityTarget { * string because the server decodes updates onto the existing record. */ probeAgentId?: string; + http?: AvailabilityHTTPConfig; + httpSecrets?: AvailabilityHTTPSecretState; status?: AvailabilityProbeStatus; } @@ -63,6 +105,12 @@ export interface AvailabilityTestResponse { success: boolean; latencyMillis: number; outcome?: 'reachable' | 'unreachable' | 'indeterminate' | string; + transportOutcome?: 'reachable' | 'unreachable' | 'indeterminate' | string; + application?: { + outcome: 'not_configured' | 'passed' | 'failed' | string; + statusCode?: number; + failureCode?: string; + }; error?: string; certificate?: ResourceCertificateObservation; } diff --git a/frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx b/frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx index f16bffbfe..49284797b 100644 --- a/frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx +++ b/frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx @@ -74,6 +74,30 @@ export function AvailabilityProbeStatusCard(props: AvailabilityProbeStatusCardPr return err.length > 40 ? `${err.slice(0, 40)}…` : err; }; const certificate = () => props.availability.certificate; + const transportLabel = () => { + switch (props.availability.transportOutcome) { + case 'reachable': + return 'Endpoint answered'; + case 'unreachable': + return 'No response'; + case 'indeterminate': + return 'Indeterminate'; + default: + return null; + } + }; + const applicationLabel = () => { + if ( + !props.availability.applicationOutcome || + props.availability.applicationOutcome === 'not_configured' + ) { + return null; + } + const status = props.availability.applicationStatusCode + ? ` · HTTP ${props.availability.applicationStatusCode}` + : ''; + return `${props.availability.applicationOutcome === 'passed' ? 'Contract passed' : 'Contract failed'}${status}`; + }; const certificateTrust = () => { switch (certificate()?.trustStatus) { case 'trusted': @@ -168,6 +192,22 @@ export function AvailabilityProbeStatusCard(props: AvailabilityProbeStatusCardPr presentation()?.freshnessLabel === 'stale' ? 'text-amber-600 dark:text-amber-300' : '' } /> + + {(label) => } + + + {(label) => ( + + )} + {(label) => ( ({ - id: target.id, - name: target.name ?? '', - targetKind: target.targetKind ?? 'service', - address: target.address ?? '', - protocol: target.protocol ?? 'icmp', - port: target.port ? String(target.port) : '', - path: target.path ?? '', - udpMode: target.udpMode ?? 'response_required', - 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), - failureThreshold: String(target.failureThreshold ?? 2), - monitorCertificate: target.certificateMonitoringDisabled !== true, - certificateExpiryWarningDays: String(target.certificateExpiryWarningDays ?? 30), -}); +const formFromTarget = (target: AvailabilityTarget): AvailabilityForm => { + const headerSecrets = new Map( + (target.httpSecrets?.headers ?? []).map((header) => [header.id, header.valueConfigured]), + ); + return { + id: target.id, + name: target.name ?? '', + targetKind: target.targetKind ?? 'service', + address: target.address ?? '', + protocol: target.protocol ?? 'icmp', + port: target.port ? String(target.port) : '', + path: target.path ?? '', + udpMode: target.udpMode ?? 'response_required', + 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), + failureThreshold: String(target.failureThreshold ?? 2), + monitorCertificate: target.certificateMonitoringDisabled !== true, + certificateExpiryWarningDays: String(target.certificateExpiryWarningDays ?? 30), + httpMethod: target.http?.method ?? 'GET', + httpStatusMin: String(target.http?.expectedStatusMin ?? 200), + httpStatusMax: String(target.http?.expectedStatusMax ?? 399), + httpTextContains: target.http?.textContains ?? '', + httpJSONPath: target.http?.jsonPath ?? '', + httpJSONEquals: target.http?.jsonEquals ?? '', + httpAuthType: target.http?.authentication.type ?? 'none', + httpUsername: target.http?.authentication.username ?? '', + httpPassword: '', + httpPasswordConfigured: target.httpSecrets?.passwordConfigured ?? false, + httpBearerToken: '', + httpBearerTokenConfigured: target.httpSecrets?.bearerTokenConfigured ?? false, + httpBody: '', + httpBodyConfigured: target.httpSecrets?.bodyConfigured ?? false, + httpBodyTouched: false, + httpHeaders: (target.http?.headers ?? []).map((header) => ({ + id: header.id, + name: header.name, + value: '', + valueConfigured: headerSecrets.get(header.id) ?? false, + })), + }; +}; const parsePositiveInt = (value: string): number | undefined => { const parsed = Number.parseInt(value.trim(), 10); @@ -129,7 +196,11 @@ const parsePositiveInt = (value: string): number | undefined => { }; const availabilityTestSuccessDescription = (result: AvailabilityTestResponse): string => { - const base = `Probe reached the target in ${result.latencyMillis} ms.`; + const application = result.application; + const base = + application?.outcome === 'passed' + ? `Endpoint answered in ${result.latencyMillis} ms and the application contract passed${application.statusCode ? ` (HTTP ${application.statusCode})` : ''}.` + : `Probe reached the target in ${result.latencyMillis} ms.`; const certificate = result.certificate; if (!certificate) return base; const trust = certificate.trustStatus.replace('-', ' '); @@ -144,8 +215,23 @@ const availabilityTestSuccessDescription = (result: AvailabilityTestResponse): s })}.`; }; +const availabilityTestFailureDescription = (result: AvailabilityTestResponse): string => { + if (result.transportOutcome === 'reachable' && result.application?.outcome === 'failed') { + const status = result.application.statusCode ? ` HTTP ${result.application.statusCode}.` : ''; + return `Endpoint answered in ${result.latencyMillis} ms, but the application contract failed.${status} ${result.error || 'The response did not satisfy the configured assertion.'}`; + } + return result.error || 'Probe failed.'; +}; + const payloadFromForm = (form: AvailabilityForm): AvailabilityTarget => { const port = parsePositiveInt(form.port); + const isHTTP = form.protocol === 'http' || form.protocol === 'https'; + const httpPassword = form.httpPassword ? form.httpPassword : undefined; + const httpBearerToken = form.httpBearerToken ? form.httpBearerToken : undefined; + const httpBody = + form.httpMethod === 'POST' && (form.httpBodyTouched || form.httpBody) + ? form.httpBody + : undefined; return { id: form.id, name: form.name.trim(), @@ -169,6 +255,30 @@ const payloadFromForm = (form: AvailabilityForm): AvailabilityTarget => { certificateMonitoringDisabled: form.protocol === 'https' ? !form.monitorCertificate : false, certificateExpiryWarningDays: form.protocol === 'https' ? parsePositiveInt(form.certificateExpiryWarningDays) : 0, + http: isHTTP + ? { + method: form.httpMethod, + headers: form.httpHeaders.map((header) => ({ + id: header.id, + name: header.name.trim(), + value: header.value || undefined, + })), + authentication: { + type: form.httpAuthType, + username: form.httpAuthType === 'basic' ? form.httpUsername.trim() : undefined, + password: form.httpAuthType === 'basic' ? httpPassword : undefined, + bearerToken: form.httpAuthType === 'bearer' ? httpBearerToken : undefined, + }, + body: httpBody, + expectedStatusMin: parsePositiveInt(form.httpStatusMin) ?? 200, + expectedStatusMax: parsePositiveInt(form.httpStatusMax) ?? 399, + textContains: + form.httpMethod !== 'HEAD' ? form.httpTextContains.trim() || undefined : undefined, + jsonPath: form.httpMethod !== 'HEAD' ? form.httpJSONPath.trim() || undefined : undefined, + jsonEquals: + form.httpMethod !== 'HEAD' ? form.httpJSONEquals.trim() || undefined : undefined, + } + : undefined, }; }; @@ -228,6 +338,12 @@ export const AvailabilityTargetSlot: Component = (p const [testing, setTesting] = createSignal(false); const [error, setError] = createSignal(null); const [testResult, setTestResult] = createSignal(null); + let feedbackElement: HTMLDivElement | undefined; + const revealFeedback = () => { + if (typeof feedbackElement?.scrollIntoView === 'function') { + feedbackElement.scrollIntoView({ block: 'nearest' }); + } + }; // 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); @@ -262,6 +378,29 @@ export const AvailabilityTargetSlot: Component = (p setTestResult(null); }; + const updateHTTPHeader = (index: number, patch: Partial) => { + updateForm({ + httpHeaders: form().httpHeaders.map((header, headerIndex) => + headerIndex === index ? { ...header, ...patch } : header, + ), + }); + }; + + const addHTTPHeader = () => { + const id = + globalThis.crypto?.randomUUID?.() ?? + `http-header-${Date.now()}-${form().httpHeaders.length + 1}`; + updateForm({ + httpHeaders: [...form().httpHeaders, { id, name: '', value: '', valueConfigured: false }], + }); + }; + + const removeHTTPHeader = (index: number) => { + updateForm({ + httpHeaders: form().httpHeaders.filter((_, headerIndex) => headerIndex !== index), + }); + }; + const selectedPresetConfig = () => availabilityPresetById(selectedPreset()); const addressPlaceholder = () => @@ -328,8 +467,10 @@ export const AvailabilityTargetSlot: Component = (p try { const result = await AvailabilityTargetsAPI.test(payloadFromForm(form())); setTestResult(result); + queueMicrotask(revealFeedback); } catch (err) { setError(err instanceof Error ? err.message : 'Availability test failed.'); + queueMicrotask(revealFeedback); } finally { setTesting(false); } @@ -435,7 +576,7 @@ export const AvailabilityTargetSlot: Component = (p ? 'Use a hostname or IP address and the port to open.' : form().protocol === 'udp' ? 'Use a hostname or unicast IP. UDP checks send one small datagram per poll.' - : 'Use a full URL or a hostname. HTTP statuses below 500 count as reachable.'} + : 'Use a full URL or hostname, then define the accepted application response below.'} = (p /> + +
+
+

+ What proves this service is working? +

+

+ Pulse records whether the endpoint answered separately from whether this response + contract passed. Response bodies and credentials are never stored as evidence. +

+
+ +
+ + updateForm({ httpMethod: event.currentTarget.value as AvailabilityHTTPMethod }) + } + > + + + + + + +
+ + +