diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 61c699d05..44cc86f7e 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -4716,3 +4716,14 @@ mutations require `monitoring:write`, and slash-containing canonical record IDs are parsed as opaque identities. Governed action planning still enters the existing action lifecycle and entitlement boundary before any agent executor is considered. + +### Alert intent remains outside agent authority + +Alert-intent policy reads and writes, UDP availability probes, operator-state +lookups, and backup-task context do not widen the agent lifecycle. The server +evaluates these read-state inputs without issuing an agent command, accepting a +new report shape, rotating credentials, or changing remote configuration. +Neither an expected-transient decision nor an indeterminate UDP outcome may be +translated into an agent action. Any later customer-infrastructure mutation +still requires the canonical Actions planner, approval, executor, receipt, +audit, and verification boundaries. diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 2cf3da93f..a4d18a44c 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -1186,3 +1186,34 @@ unambiguous/live/ambiguous decision matrix, reload behavior, and `frontend-modern/src/features/alerts/thresholds/hooks/__tests__/truenasThresholdPersistence.test.tsx` proves the browser-side TrueNAS save/refetch contract. + +### Versioned alert-intent policy + +The alerts runtime owns one versioned alert-intent document and its durable +pending-condition state. Stable signal keys are `*`, `state.offline`, +`incident.availability`, and `metric.`. Effective fields resolve +independently from legacy metric behavior, document defaults, resource-type +overrides, and canonical-resource overrides, in that order. Keys are +normalized before validation and collisions fail closed; updates use revision +compare-and-swap so a stale browser cannot overwrite a newer document. + +Intent affects when detector truth becomes eligible for an active alert; it +does not mutate the underlying observation or create a second alert identity. +Without an explicit applicable rule, established alert behavior remains +compatible. With a rule, the first matched time is durable across restart and +becomes the canonical alert start time once eligible. Preview evaluates the +same resolver but restores pending state before returning, so it is read-only. +Invalid documents, persistence failures, or revision conflicts must leave the +prior in-memory and durable policy active. + +Operator maintenance and intentionally-offline state are read only through the +canonical unified-resource identity. Backup-aware offline deferral consumes +fresh, matching, active task evidence, applies the configured post-backup grace, +and always terminates at its hard cap. Missing, stale, future-skewed, +finished, or mismatched backup evidence cannot suppress an outage. This policy +changes alert activation only: notification delivery, recovery assurance, and +customer-infrastructure mutation retain their existing owners. + +`internal/alerts/intent_policy_test.go` proves precedence, normalization, +operator and backup contexts, preview immutability, restart continuity, and +first-match lifecycle identity. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index a3eeabdb3..47ba08bc2 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -7907,3 +7907,28 @@ authorize an alternate execution route. acceptance and typed-conflict payloads, while `frontend-modern/src/features/patrol/__tests__/patrolRunAcceptance.test.ts` proves bounded status/history reconciliation against the returned run ID. + +### Alert intent and UDP availability transport + +`GET /api/alerts/intent-policies` and +`POST /api/alerts/intent-policies/preview` require `monitoring:read`; +`PUT /api/alerts/intent-policies` requires `monitoring:write`. The versioned +document carries schema version, revision, optional update time, and typed +default, resource-type, and canonical-resource rules. A stale revision returns +`409 Conflict`; invalid policy or preview input returns `400`; unavailable +runtime or persistence ownership returns `503`. A save failure restores the +previous in-memory document and returns `500`. Preview is bounded and read-only +and cannot advance pending grace state. + +Availability targets add `udp` plus `response_required` and +`open_or_filtered` modes. Request and expected-response payloads remain +explicit configuration fields. Test responses add the probe `outcome` while +retaining the existing success, latency, and error fields for compatible +clients. A silent open-or-filtered result is `indeterminate`, not successful +reachability and not a transport failure that may be promoted to an outage. + +`internal/api/alerts_endpoints_test.go` proves scopes, revision conflicts, +rollback, and preview behavior. +`frontend-modern/src/api/__tests__/alertIntentPolicies.test.ts` and the +availability target API and settings tests prove the canonical browser routes +and additive UDP wire fields. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 0cf28bc75..4024584b8 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -5165,3 +5165,28 @@ states show bounded guidance with no executable command. The focused proofs are `frontend-modern/src/components/Settings/__tests__/DiagnosticsResultsPanel.test.tsx`, `frontend-modern/src/components/Settings/__tests__/settingsHeaderMeta.branchcov0713.test.ts`, and `frontend-modern/src/utils/__tests__/updatesPresentation.test.ts`. + +### Alert intent and three-state availability presentation + +The Alerts thresholds surface composes the versioned intent-policy editor from +the shared form, status, disclosure, and loading primitives. It must preserve +field-wise inheritance: omitted fields inherit, while explicit false and zero +values remain deliberate overrides. Save uses the displayed revision, reports +revision conflict without replacing local edits, and refreshes from the +server-owned document after success. Preview renders clear, +expected-transient, pending-grace, and would-activate as distinct states and +never presents preview as a write. + +Availability controls expose UDP mode, request payload, and optional expected +response only where valid for the selected protocol. Unified-resource +presentation keeps `indeterminate` visibly distinct from reachable and +unreachable: open-or-filtered UDP uses warning treatment and bounded evidence +copy, never a green success tone or a fabricated latency. The primitive layer +does not infer detector, operator-intent, or recovery truth. + +The focused proofs are +`frontend-modern/src/features/alerts/__tests__/AlertIntentPolicyPanel.test.tsx`, +`frontend-modern/src/features/alerts/__tests__/ThresholdsTab.test.tsx`, +`frontend-modern/src/components/Settings/ConnectionEditor/__tests__/AvailabilityTargetSlot.test.tsx`, +and +`frontend-modern/src/utils/__tests__/availabilityProbePresentation.test.ts`. diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 5c74a93df..2e4773b62 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -1830,3 +1830,29 @@ before point reconciliation so completeness and permission failure cannot be lost behind a successful cached-artifact path. Shared protection semantics stay in `internal/recovery/`; PBS monitoring owns only this explicit evidence-quality adapter. + +### Alert-intent evidence adapters and UDP outcomes + +Monitoring supplies read-only context to the alerts-owned intent resolver. The +operator-state adapter resolves source-native references to one canonical +unified-resource ID before reading durable operator intent. Lookup failure, +ambiguity, absence, or store error yields no suppression context; monitoring +does not synthesize maintenance state. + +Backup-aware offline intent consumes a PVE task only when VMID, instance, and +node match and the task is active. `pollBackupTasks` stamps server observation +time. Evidence older than five minutes, more than one minute in the future, +finished, terminal, or missing an observation time fails closed. This +short-lived alert context is separate from PBS protection evidence and from +recovery assurance; it cannot claim that a backup is restorable or authorize a +restore. + +Availability probing owns three outcomes: reachable, unreachable, and +indeterminate. UDP response-required mode needs a request and treats timeout or +mismatch as unreachable. Open-or-filtered mode may return indeterminate after +the full response deadline. Indeterminate clears accumulated failure count, +projects warning evidence, and emits no availability incident; it never claims +reachability. `internal/monitoring/availability_udp_test.go`, +`internal/monitoring/monitor_alert_intent_test.go`, and the backup polling +assertion in `internal/monitoring/monitor_full_coverage_test.go` are the focused +proofs. diff --git a/docs/release-control/v6/internal/subsystems/notifications.md b/docs/release-control/v6/internal/subsystems/notifications.md index 58b4f95d5..b33ab62ac 100644 --- a/docs/release-control/v6/internal/subsystems/notifications.md +++ b/docs/release-control/v6/internal/subsystems/notifications.md @@ -256,3 +256,28 @@ retry/sent/failed/dead-letter/cancelled outcomes and open-to-enqueue latency without destination, record, resource, or evidence labels. Delivery state remains notification truth only and cannot resolve or reopen the alert lifecycle. + +### Occurrence-bound delivery receipts + +The notification owner records successful firing delivery by exact alert ID, +nanosecond start time, and a normalized destination identity. Email recipients, +webhook ID plus URL, and Apprise mode/base/config/targets are part of that +identity, so a later occurrence or reconfigured destination cannot inherit an +older receipt. A resolved notification is eligible only for destinations that +received that exact firing occurrence. Successful recovery delivery deletes +the receipt; persistent receipts survive restart and are retention-bounded. +Receipt read failure suppresses recovery rather than inventing prior delivery. + +Cooldown state is published only after firing receipts are recorded. Persistent +workers atomically claim a still-pending row after taking per-alert delivery +gates, and resolution cancellation takes the corresponding exclusive gates. +This prevents a stale pending snapshot from being sent after cancellation while +preserving grouped-row and operational-link lifecycle semantics. A destination +with delivery disabled or failed has no receipt and receives no misleading +recovery. These rules remain delivery truth only and cannot resolve the +alerts-owned lifecycle. + +`internal/notifications/delivery_receipts_test.go`, +`internal/notifications/notifications_test.go`, and +`internal/notifications/queue_test.go` prove occurrence and destination +isolation, persistence, cleanup, and cancellation/claim ordering. diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 42ae8167b..7fee29909 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -2064,6 +2064,7 @@ "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ + "internal/alerts/intent_policy_test.go", "internal/alerts/migration_characterization_test.go", "internal/alerts/operational_contract_test.go", "internal/alerts/unified_eval_parity_test.go", @@ -2110,11 +2111,13 @@ "frontend-modern/src/components/Alerts/Thresholds/hooks/__tests__/useCollapsedSections.test.ts", "frontend-modern/src/components/Alerts/Thresholds/sections/__tests__/CollapsibleSection.test.tsx", "frontend-modern/src/components/Alerts/WebhookConfig.test.tsx", + "frontend-modern/src/features/alerts/__tests__/AlertIntentPolicyPanel.test.tsx", "frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.test.ts", "frontend-modern/src/features/alerts/__tests__/helpers.test.ts", "frontend-modern/src/features/alerts/__tests__/OverviewTab.emptystate.test.tsx", "frontend-modern/src/features/alerts/__tests__/OverviewTab.timelineerror.test.tsx", "frontend-modern/src/features/alerts/__tests__/OverviewTab.total24h.test.tsx", + "frontend-modern/src/features/alerts/__tests__/ThresholdsTab.test.tsx", "frontend-modern/src/features/alerts/identity.test.ts", "frontend-modern/src/features/alerts/thresholds/__tests__/helpers.test.ts", "frontend-modern/src/features/alerts/thresholds/hooks/__tests__/truenasThresholdPersistence.test.tsx", @@ -2270,6 +2273,7 @@ "internal/alerts/guest_snapshot_test.go", "internal/alerts/history_concurrency_test.go", "internal/alerts/history_test.go", + "internal/alerts/intent_policy_test.go", "internal/alerts/operational_contract_test.go", "internal/alerts/synology_test.go", "internal/alerts/threshold_resolution_shared_test.go", @@ -2639,6 +2643,21 @@ "internal/api/update_readiness_test.go" ] }, + { + "id": "alert-intent-policy-api", + "label": "alert intent policy transport proof", + "match_prefixes": [], + "match_files": [ + "frontend-modern/src/api/alertIntentPolicies.ts", + "internal/api/alerts.go" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "frontend-modern/src/api/__tests__/alertIntentPolicies.test.ts", + "internal/api/alerts_endpoints_test.go" + ] + }, { "id": "backend-payload-contracts", "label": "backend API payload proof", @@ -5190,6 +5209,7 @@ "internal/models/deepcopy_test.go", "internal/models/state_host_test.go", "internal/monitoring/issue1595_collection_trust_test.go", + "internal/monitoring/monitor_full_coverage_test.go", "internal/monitoring/monitor_host_agents_test.go", "internal/monitoring/monitor_package_updates_test.go", "internal/unifiedresources/adapter_coverage_test.go", @@ -5286,6 +5306,8 @@ "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ + "internal/monitoring/monitor_alert_intent_test.go", + "internal/monitoring/monitor_full_coverage_test.go", "internal/monitoring/pbs_protection_observation_test.go", "internal/monitoring/recovery_ingest_test.go", "internal/recovery/mapper/proxmox/mapper_test.go", @@ -5338,8 +5360,10 @@ "test_prefixes": [], "exact_files": [ "internal/monitoring/availability_poller_test.go", + "internal/monitoring/availability_udp_test.go", "internal/monitoring/canonical_guardrails_test.go", "internal/monitoring/issue1595_collection_trust_test.go", + "internal/monitoring/monitor_alert_intent_test.go", "internal/monitoring/monitor_alert_override_migration_test.go", "internal/monitoring/monitor_backups_readstate_test.go", "internal/monitoring/monitor_host_agents_test.go", @@ -6782,6 +6806,7 @@ "internal/hostagent/issue1595_sas_collection_test.go", "internal/monitoring/issue1595_collection_trust_test.go", "internal/truenas/contract_test.go", + "internal/unifiedresources/availability_projection_test.go", "internal/unifiedresources/canonical_identity_test.go", "internal/unifiedresources/canonical_ids_types_test.go", "internal/unifiedresources/code_standards_test.go", diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 7ed674e35..c1d535c95 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -2187,6 +2187,21 @@ while storage detail drawers and filter controls must route summary series IDs, source tones, and disk metrics through the shared storage helpers instead of reconstructing them from local table state. +### Backup-aware alert intent is not recovery assurance + +The alerts subsystem may defer an offline alert while monitoring supplies +fresh, matching evidence of an active PVE backup task. That evidence is a +short-lived operational context only. It does not create or update a recovery +point, prove backup completeness, validate restore permissions, establish +retention, or change protected/attention/unprotected/unknown posture. + +Storage and recovery remain the sole owners of deterministic collection trust +and recovery assurance. Stale, future-skewed, terminal, or mismatched task +evidence fails closed for alert deferral, while PBS and provider recovery +evidence continues through its independent mapper and posture rules. An alert +grace period or hard-cap expiry cannot be interpreted as backup success or +failure and grants no restore or infrastructure mutation authority. + ### Physical-disk collection truth Storage surfaces consume the unified physical-disk collection contract without diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 699bffab0..dc55da1ac 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -1815,6 +1815,30 @@ through the canonical resource model, but unified-resource consumers must not reintroduce removed workload aliases or feature-local resource-type shims just to satisfy one table, drawer, or badge surface. +### Alert-intent identity and availability evidence projection + +The monitor adapter may expose two optional read capabilities to the alert +intent resolver: source-reference resolution to the current canonical resource +ID and durable operator-state lookup for that ID. These are read-only views over +the existing registry and store. Display aliases do not become persistence +keys, an unresolved reference supplies no operator suppression, and the adapter +does not mutate operator state or alert policy. + +The canonical availability facet additively carries `probeOutcome` and +`udpMode`. `indeterminate` is evidence that UDP reachability could not be +distinguished from filtering; it is neither available nor unavailable truth. +Backend and frontend resource types must retain these values through JSON and +read-state projection without changing canonical identity, correlation, or +incident ownership. Legacy clients may ignore the additive fields and continue +reading the established boolean and status fields, but new consumers must not +convert an indeterminate outcome into a successful check. + +`internal/unifiedresources/availability_projection_test.go` proves the wire +shape, `internal/unifiedresources/monitor_adapter_read_state_test.go` proves +canonical identity and operator-state forwarding, and +`frontend-modern/src/types/__tests__/resource.test.ts` proves the browser +projection. + ### Physical-disk identity and collection read state `pkg/diskinventory` owns deterministic physical-disk fallback identity and diff --git a/frontend-modern/src/api/__tests__/alertIntentPolicies.test.ts b/frontend-modern/src/api/__tests__/alertIntentPolicies.test.ts new file mode 100644 index 000000000..91b13e7a2 --- /dev/null +++ b/frontend-modern/src/api/__tests__/alertIntentPolicies.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + AlertIntentPoliciesAPI, + type AlertIntentPolicyDocument, + type AlertIntentPolicyPreviewRequest, +} from '@/api/alertIntentPolicies'; +import { apiFetchJSON } from '@/utils/apiClient'; + +vi.mock('@/utils/apiClient', () => ({ + apiFetchJSON: vi.fn(), +})); + +const mockedApiFetchJSON = vi.mocked(apiFetchJSON); + +describe('AlertIntentPoliciesAPI', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('reads, updates, and previews policies through the canonical routes', async () => { + const document: AlertIntentPolicyDocument = { + schemaVersion: 1, + revision: 4, + defaults: { + 'state.offline': { + graceSeconds: 90, + honorOperatorState: true, + }, + }, + }; + const previewRequest: AlertIntentPolicyPreviewRequest = { + resourceId: 'vm:pve-a:101', + resourceType: 'vm', + signal: 'state.offline', + conditionActive: true, + firstMatchedAt: '2026-07-20T12:00:00Z', + }; + const preview = { + resourceId: previewRequest.resourceId, + resourceType: previewRequest.resourceType, + signal: previewRequest.signal, + status: 'pending_grace' as const, + reason: 'grace period active', + effective: { + graceSeconds: 90, + honorOperatorState: true, + sources: { graceSeconds: 'defaults.state.offline' }, + explicit: true, + }, + contexts: [], + warnings: [], + }; + + mockedApiFetchJSON.mockResolvedValueOnce(document); + await expect(AlertIntentPoliciesAPI.get()).resolves.toEqual(document); + expect(mockedApiFetchJSON).toHaveBeenLastCalledWith('/api/alerts/intent-policies'); + + mockedApiFetchJSON.mockResolvedValueOnce({ ...document, revision: 5 }); + await expect(AlertIntentPoliciesAPI.update(document)).resolves.toEqual({ + ...document, + revision: 5, + }); + expect(mockedApiFetchJSON).toHaveBeenLastCalledWith('/api/alerts/intent-policies', { + method: 'PUT', + body: JSON.stringify(document), + }); + + mockedApiFetchJSON.mockResolvedValueOnce(preview); + await expect(AlertIntentPoliciesAPI.preview(previewRequest)).resolves.toEqual(preview); + expect(mockedApiFetchJSON).toHaveBeenLastCalledWith('/api/alerts/intent-policies/preview', { + method: 'POST', + body: JSON.stringify(previewRequest), + }); + }); +}); diff --git a/frontend-modern/src/api/alertIntentPolicies.ts b/frontend-modern/src/api/alertIntentPolicies.ts new file mode 100644 index 000000000..31dee9584 --- /dev/null +++ b/frontend-modern/src/api/alertIntentPolicies.ts @@ -0,0 +1,86 @@ +import { apiFetchJSON } from '@/utils/apiClient'; + +const ALERT_INTENT_POLICIES_PATH = '/api/alerts/intent-policies'; + +export type AlertIntentSignal = + '*' | 'state.offline' | 'incident.availability' | `metric.${string}`; + +export interface BackupOfflineIntentPolicy { + enabled: boolean; + postGraceSeconds?: number; + maxDeferralSeconds?: number; +} + +export interface AlertIntentRule { + graceSeconds?: number; + honorOperatorState?: boolean; + backupOffline?: BackupOfflineIntentPolicy; +} + +export interface AlertIntentPolicyDocument { + schemaVersion: number; + revision: number; + updatedAt?: string; + defaults?: Record; + resourceTypes?: Record>; + resources?: Record>; +} + +export interface AlertIntentPolicyPreviewRequest { + resourceId: string; + resourceType: string; + signal: AlertIntentSignal; + conditionActive: boolean; + firstMatchedAt?: string; + backupActive?: boolean; + backupObservedAt?: string; +} + +export interface AlertIntentPolicyPreview { + resourceId: string; + resourceType: string; + signal: string; + status: 'clear' | 'expected_transient' | 'pending_grace' | 'would_activate'; + reason: string; + effective: { + graceSeconds: number; + honorOperatorState: boolean; + backupOffline?: BackupOfflineIntentPolicy; + sources: Record; + explicit: boolean; + }; + firstMatchedAt?: string; + eligibleAt?: string; + hardCapAt?: string; + remainingSeconds?: number; + contexts: Array<{ + kind: string; + active: boolean; + evidence?: string; + observedAt?: string; + expiresAt?: string; + }>; + warnings: string[]; +} + +export class AlertIntentPoliciesAPI { + static async get(): Promise { + return apiFetchJSON(ALERT_INTENT_POLICIES_PATH); + } + + static async update(document: AlertIntentPolicyDocument): Promise { + return apiFetchJSON(ALERT_INTENT_POLICIES_PATH, { + method: 'PUT', + body: JSON.stringify(document), + }); + } + + static async preview( + request: AlertIntentPolicyPreviewRequest, + ): Promise { + return apiFetchJSON(`${ALERT_INTENT_POLICIES_PATH}/preview`, { + method: 'POST', + body: JSON.stringify(request), + }); + } +} diff --git a/frontend-modern/src/api/availabilityTargets.ts b/frontend-modern/src/api/availabilityTargets.ts index 9151f88a7..b5b31db76 100644 --- a/frontend-modern/src/api/availabilityTargets.ts +++ b/frontend-modern/src/api/availabilityTargets.ts @@ -2,7 +2,8 @@ import { apiFetchJSON } from '@/utils/apiClient'; const AVAILABILITY_TARGETS_PATH = '/api/availability-targets'; -export type AvailabilityProbeProtocol = 'icmp' | 'tcp' | 'http' | 'https'; +export type AvailabilityProbeProtocol = 'icmp' | 'tcp' | 'udp' | 'http' | 'https'; +export type AvailabilityUDPMode = 'response_required' | 'open_or_filtered'; export type AvailabilityTargetKind = 'machine' | 'service' | 'device'; export interface AvailabilityProbeStatus { @@ -11,6 +12,7 @@ export interface AvailabilityProbeStatus { targetKind?: AvailabilityTargetKind | string; address: string; protocol: AvailabilityProbeProtocol | string; + outcome?: 'reachable' | 'unreachable' | 'indeterminate' | string; enabled: boolean; available: boolean; lastChecked?: string; @@ -29,6 +31,9 @@ export interface AvailabilityTarget { protocol: AvailabilityProbeProtocol; port?: number; path?: string; + udpMode?: AvailabilityUDPMode; + udpRequest?: string; + udpExpectedResponse?: string; linkedResourceId?: string; enabled: boolean; pollIntervalSeconds?: number; @@ -40,6 +45,7 @@ export interface AvailabilityTarget { export interface AvailabilityTestResponse { success: boolean; latencyMillis: number; + outcome?: 'reachable' | 'unreachable' | 'indeterminate' | string; error?: string; } diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx index 56ea12dfd..cfda16835 100644 --- a/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx +++ b/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx @@ -15,6 +15,7 @@ import { type AvailabilityTarget, type AvailabilityTargetKind, type AvailabilityTestResponse, + type AvailabilityUDPMode, } from '@/api/availabilityTargets'; import { AVAILABILITY_TARGET_PRESETS, @@ -37,6 +38,9 @@ interface AvailabilityForm { protocol: AvailabilityProbeProtocol; port: string; path: string; + udpMode: AvailabilityUDPMode; + udpRequest: string; + udpExpectedResponse: string; linkedResourceId: string; enabled: boolean; pollIntervalSeconds: string; @@ -68,6 +72,9 @@ const newAvailabilityForm = ( protocol: 'icmp', port: '', path: '', + udpMode: 'response_required', + udpRequest: '', + udpExpectedResponse: '', linkedResourceId: '', enabled: true, pollIntervalSeconds: '60', @@ -83,6 +90,9 @@ const formFromTarget = (target: AvailabilityTarget): AvailabilityForm => ({ 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 ?? '', enabled: target.enabled ?? true, pollIntervalSeconds: String(target.pollIntervalSeconds ?? 60), @@ -104,7 +114,10 @@ const payloadFromForm = (form: AvailabilityForm): AvailabilityTarget => { address: form.address.trim(), protocol: form.protocol, port: form.protocol === 'icmp' ? undefined : port, - path: form.protocol === 'http' ? form.path.trim() : undefined, + path: form.protocol === 'http' || form.protocol === 'https' ? form.path.trim() : undefined, + udpMode: form.protocol === 'udp' ? form.udpMode : undefined, + udpRequest: form.protocol === 'udp' ? form.udpRequest : undefined, + udpExpectedResponse: form.protocol === 'udp' ? form.udpExpectedResponse : undefined, linkedResourceId: form.linkedResourceId.trim() || undefined, enabled: form.enabled, pollIntervalSeconds: parsePositiveInt(form.pollIntervalSeconds), @@ -118,6 +131,9 @@ const presetSensitiveFormKeys: ReadonlySet = new Set([ 'port', 'protocol', 'targetKind', + 'udpMode', + 'udpRequest', + 'udpExpectedResponse', ]); const initialPresetForTargetKind = ( @@ -189,8 +205,8 @@ export const AvailabilityTargetSlot: Component = (p const addressPlaceholder = () => selectedPresetConfig()?.addressPlaceholder ?? - (form().protocol === 'http' - ? 'http://service.local/status' + (form().protocol === 'http' || form().protocol === 'https' + ? `${form().protocol}://service.local/status` : form().targetKind === 'machine' ? 'server.local' : form().targetKind === 'service' @@ -198,7 +214,8 @@ export const AvailabilityTargetSlot: Component = (p : 'device.local'); const portPlaceholder = () => - selectedPresetConfig()?.portPlaceholder ?? (form().protocol === 'http' ? 'Optional' : '1883'); + selectedPresetConfig()?.portPlaceholder ?? + (form().protocol === 'http' || form().protocol === 'https' ? 'Optional' : '1883'); const namePlaceholder = () => form().targetKind === 'machine' @@ -330,10 +347,14 @@ export const AvailabilityTargetSlot: Component = (p > + + = (p /> - + + + + updateForm({ udpMode: event.currentTarget.value as AvailabilityUDPMode }) + } + help="Response required is alert-safe. Open or filtered reports silence as indeterminate and only fails on an explicit port-unreachable response." + > + + + + + +