From 4eac3b535c0d5c23e126e14e4e215430cdb03398 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 29 Jun 2026 22:43:13 +0100 Subject: [PATCH] Accept ping alias for availability targets --- docs/API.md | 46 +++++++++++++++++++ docs/CONFIGURATION.md | 41 +++++++++++++++++ .../v6/internal/subsystems/api-contracts.md | 5 ++ internal/api/availability_handlers_test.go | 46 +++++++++++++++++++ internal/config/availability.go | 19 ++++++-- internal/config/availability_test.go | 28 +++++++++++ .../release_control/subsystem_lookup_test.py | 2 +- 7 files changed, 183 insertions(+), 4 deletions(-) diff --git a/docs/API.md b/docs/API.md index 8652b6b28..e1be789e5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1264,6 +1264,52 @@ These routes manage Docker / Podman telemetry and container actions reported by --- +## Availability Checks + +Agentless availability checks monitor endpoint-only devices and services with +ICMP ping, TCP port, HTTP, or HTTPS probes. They are managed from +**Settings -> Monitoring -> Availability checks** and are also exposed through +the API for automation. + +### Target Management + +- `GET /api/availability-targets` (`settings:read`) - List configured targets and latest probe status. +- `POST /api/availability-targets` (`settings:write`) - Add a target. +- `PUT /api/availability-targets/{id}` (`settings:write`) - Update a target. +- `DELETE /api/availability-targets/{id}` (`settings:write`) - Remove a target. +- `POST /api/availability-targets/test` (`settings:write`) - Test an unsaved target. +- `POST /api/availability-targets/{id}/test` (`settings:write`) - Test a saved target. + +Target payload fields: + +- `name` - Display name. +- `targetKind` - `machine`, `service`, or `device`; defaults to `service`. +- `address` - Hostname, IP address, or URL. +- `protocol` - `icmp`, `tcp`, `http`, or `https`. The input alias `ping` is accepted and is stored/returned as canonical `icmp`. +- `port` - Required for `tcp`, optional for `http`/`https`, and omitted for `icmp`. +- `path` - Optional HTTP path. +- `enabled` - Whether the target is scheduled. +- `pollIntervalSeconds` - Minimum 10 seconds; defaults to 60. +- `timeoutMillis` - Minimum 250 milliseconds; defaults to 2000. +- `failureThreshold` - Number of consecutive failures before alerting; defaults to 2. +- `linkedResourceId` - Optional resource id hint for attaching the probe facet to an existing resource. + +Example ping-only target: + +```json +{ + "name": "Garage temperature sensor", + "targetKind": "device", + "address": "garage-sensor.local", + "protocol": "ping", + "enabled": true +} +``` + +The create response and subsequent reads return `"protocol": "icmp"`. + +--- + ## 🐟 TrueNAS TrueNAS connection management endpoints for adding, testing, and removing TrueNAS SCALE/CORE instances. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index b15aed62d..a36ccc481 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -402,6 +402,47 @@ Pulse uses a powerful alerting engine with hysteresis (separate trigger/clear th --- +## Availability Checks (`availability_targets.enc`) + +Availability checks are agentless probes for devices and services where Pulse +cannot install an agent or does not need full machine telemetry. Use them for +simple ping monitoring, TCP service checks, and HTTP/HTTPS status checks. + +**Managed via UI**: Settings -> Monitoring -> Availability checks + +Supported protocols: + +| Protocol | Use case | Required fields | +| ---------- | ---------- | ---------------- | +| `icmp` | Ping-only reachability for devices, computers, and appliances | `address` | +| `ping` | API input alias for `icmp`; saved targets return `icmp` | `address` | +| `tcp` | A reachable port such as MQTT, SSH, or a custom service | `address`, `port` | +| `http` / `https` | Web UI or health endpoint availability | `address`, optional `port`, optional `path` | + +Saved targets include `name`, `targetKind` (`machine`, `service`, or +`device`), `address`, `protocol`, `enabled`, polling interval, timeout, +failure threshold, and an optional `linkedResourceId`. ICMP ping is the +default probe for new targets. Availability targets publish +`network-endpoint` resources and can raise downtime alerts after the configured +failure threshold. + +Example API payload for simple ping monitoring: + +```json +{ + "name": "Garage temperature sensor", + "targetKind": "device", + "address": "garage-sensor.local", + "protocol": "ping", + "enabled": true +} +``` + +Pulse stores and returns that target as `protocol: "icmp"` so dashboards, +alerts, and resource projections keep one canonical protocol value. + +--- + ## 🔒 HTTPS / TLS Enable HTTPS by providing certificate files via environment variables. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index bab72fd5e..4bd714b86 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -994,6 +994,11 @@ payload shape change when the portal presents compact client rows. frontend-primitives' `CalloutCard` for shared settings callout chrome; API contracts own the target CRUD/test payload semantics and endpoint routing, not local colored alert shells. + 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 + unified-resource availability facets must continue to use the canonical + `icmp` value. Connections ledger rows with type `availability` must route management handoffs, pause, remove, and test actions to those availability-target endpoints and must not reuse node, diff --git a/internal/api/availability_handlers_test.go b/internal/api/availability_handlers_test.go index b232b6a25..9337bf02b 100644 --- a/internal/api/availability_handlers_test.go +++ b/internal/api/availability_handlers_test.go @@ -91,6 +91,52 @@ func TestAvailabilityHandlersCRUDPersistsTargets(t *testing.T) { } } +func TestAvailabilityHandlersCreateNormalizesPingAlias(t *testing.T) { + persistence := config.NewConfigPersistence(t.TempDir()) + handler := NewAvailabilityHandlers( + func(_ context.Context) *config.ConfigPersistence { return persistence }, + nil, + ) + + createBody := availabilityRequestBody(t, config.AvailabilityTarget{ + Name: "Garage sensor", + Address: "https://garage-sensor.local/status", + Protocol: config.AvailabilityProbeProtocol("ping"), + Enabled: true, + PollIntervalSecs: 30, + TimeoutMillis: 1000, + FailureThreshold: 2, + }) + createReq := httptest.NewRequest(http.MethodPost, "/api/availability-targets", createBody) + createRec := httptest.NewRecorder() + handler.HandleAdd(createRec, createReq) + if createRec.Code != http.StatusCreated { + t.Fatalf("HandleAdd status = %d, body=%s", createRec.Code, createRec.Body.String()) + } + + var created config.AvailabilityTarget + if err := json.NewDecoder(createRec.Body).Decode(&created); err != nil { + t.Fatalf("decode created target: %v", err) + } + if created.Protocol != config.AvailabilityProbeICMP { + t.Fatalf("created Protocol = %q, want %q", created.Protocol, config.AvailabilityProbeICMP) + } + if created.Address != "garage-sensor.local" { + t.Fatalf("created Address = %q, want garage-sensor.local", created.Address) + } + + loaded, err := persistence.LoadAvailabilityTargets() + if err != nil { + t.Fatalf("LoadAvailabilityTargets() error = %v", err) + } + if len(loaded) != 1 { + t.Fatalf("LoadAvailabilityTargets() length = %d, want 1", len(loaded)) + } + if loaded[0].Protocol != config.AvailabilityProbeICMP { + t.Fatalf("persisted Protocol = %q, want %q", loaded[0].Protocol, config.AvailabilityProbeICMP) + } +} + func TestAvailabilityHandlersTestSavedTarget(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) diff --git a/internal/config/availability.go b/internal/config/availability.go index c4977c983..e8c160e6a 100644 --- a/internal/config/availability.go +++ b/internal/config/availability.go @@ -22,6 +22,8 @@ const ( AvailabilityProbeTCP AvailabilityProbeProtocol = "tcp" AvailabilityProbeHTTP AvailabilityProbeProtocol = "http" AvailabilityProbeHTTPS AvailabilityProbeProtocol = "https" + + availabilityProbePingAlias AvailabilityProbeProtocol = "ping" ) type AvailabilityTargetKind string @@ -71,6 +73,8 @@ func (t *AvailabilityTarget) ApplyDefaults() { } if strings.TrimSpace(string(t.Protocol)) == "" { t.Protocol = AvailabilityProbeICMP + } else { + t.Protocol = normalizeAvailabilityProbeProtocol(t.Protocol) } if strings.TrimSpace(string(t.TargetKind)) == "" { t.TargetKind = AvailabilityTargetService @@ -127,7 +131,8 @@ func (t AvailabilityTarget) Validate() error { default: return fmt.Errorf("unsupported availability target kind %q", t.TargetKind) } - switch t.Protocol { + protocol := normalizeAvailabilityProbeProtocol(t.Protocol) + switch protocol { case AvailabilityProbeICMP: if t.Port != 0 { return fmt.Errorf("icmp availability targets must not set a port") @@ -152,7 +157,7 @@ func (t AvailabilityTarget) Validate() error { if t.FailureThreshold > 0 && t.FailureThreshold > 10 { return fmt.Errorf("availability failure threshold must be 10 or less") } - if t.Protocol == AvailabilityProbeHTTP || t.Protocol == AvailabilityProbeHTTPS { + if protocol == AvailabilityProbeHTTP || protocol == AvailabilityProbeHTTPS { if _, err := t.HTTPURL(); err != nil { return err } @@ -206,7 +211,7 @@ func NormalizeAvailabilityTarget(target AvailabilityTarget) AvailabilityTarget { target.ID = strings.TrimSpace(target.ID) target.Name = strings.TrimSpace(target.Name) target.TargetKind = AvailabilityTargetKind(strings.ToLower(strings.TrimSpace(string(target.TargetKind)))) - target.Protocol = AvailabilityProbeProtocol(strings.ToLower(strings.TrimSpace(string(target.Protocol)))) + target.Protocol = normalizeAvailabilityProbeProtocol(target.Protocol) if target.Protocol == AvailabilityProbeHTTP || target.Protocol == AvailabilityProbeHTTPS { target.Address = strings.TrimSpace(target.Address) } else { @@ -218,6 +223,14 @@ func NormalizeAvailabilityTarget(target AvailabilityTarget) AvailabilityTarget { return target } +func normalizeAvailabilityProbeProtocol(protocol AvailabilityProbeProtocol) AvailabilityProbeProtocol { + normalized := AvailabilityProbeProtocol(strings.ToLower(strings.TrimSpace(string(protocol)))) + if normalized == availabilityProbePingAlias { + return AvailabilityProbeICMP + } + return normalized +} + func normalizeAvailabilityAddress(raw string) string { value := strings.TrimSpace(raw) if value == "" { diff --git a/internal/config/availability_test.go b/internal/config/availability_test.go index 580f3d6e4..62b819ebf 100644 --- a/internal/config/availability_test.go +++ b/internal/config/availability_test.go @@ -44,6 +44,21 @@ func TestNormalizeAvailabilityTargetReducesICMPAddressToHost(t *testing.T) { } } +func TestNormalizeAvailabilityTargetAcceptsPingAlias(t *testing.T) { + target := NormalizeAvailabilityTarget(AvailabilityTarget{ + Address: " https://device.local:8443/status ", + Protocol: AvailabilityProbeProtocol(" Ping "), + Enabled: true, + }) + + if target.Protocol != AvailabilityProbeICMP { + t.Fatalf("Protocol = %q, want %q", target.Protocol, AvailabilityProbeICMP) + } + if target.Address != "device.local" { + t.Fatalf("Address = %q, want device.local", target.Address) + } +} + func TestAvailabilityTargetProbeAddressUsesHTTPHostname(t *testing.T) { target := NormalizeAvailabilityTarget(AvailabilityTarget{ Address: "http://solar-inverter.lab.local/status", @@ -86,6 +101,19 @@ func TestAvailabilityTargetValidateRejectsTCPWithoutPort(t *testing.T) { } } +func TestAvailabilityTargetValidateAcceptsPingAlias(t *testing.T) { + target := AvailabilityTarget{ + TargetKind: AvailabilityTargetService, + Address: "device.local", + Protocol: AvailabilityProbeProtocol("ping"), + Enabled: true, + } + + if err := target.Validate(); err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } +} + func TestAvailabilityTargetValidateRejectsUnsupportedTargetKind(t *testing.T) { target := NormalizeAvailabilityTarget(AvailabilityTarget{ TargetKind: AvailabilityTargetKind("database"), diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 878b19c2c..c44e68c15 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -2916,7 +2916,7 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 1088, + "line": 1093, "heading_line": 137, } ],