Accept ping alias for availability targets

This commit is contained in:
rcourtman 2026-06-29 22:43:13 +01:00
parent 597c369a63
commit 4eac3b535c
7 changed files with 183 additions and 4 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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,

View file

@ -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)

View file

@ -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 == "" {

View file

@ -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"),

View file

@ -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,
}
],