From d6ca8b12e65e5eaa83d56ee1fa51875bbe82fc4e Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 6 May 2026 10:35:34 +0100 Subject: [PATCH] Add agentless availability targets Refs #1460 --- .../v6/internal/subsystems/agent-lifecycle.md | 8 + .../v6/internal/subsystems/alerts.md | 6 + .../v6/internal/subsystems/api-contracts.md | 18 + .../subsystems/frontend-primitives.md | 8 + .../v6/internal/subsystems/monitoring.md | 17 + .../subsystems/performance-and-scalability.md | 6 + .../internal/subsystems/storage-recovery.md | 6 + .../internal/subsystems/unified-resources.md | 14 +- .../api/__tests__/availabilityTargets.test.ts | 65 +++ .../src/api/__tests__/connections.test.ts | 15 + .../src/api/availabilityTargets.ts | 82 +++ frontend-modern/src/api/connections.ts | 12 + .../AvailabilityTargetSlot.tsx | 374 ++++++++++++ .../ConnectionEditor/useConnectionEditor.ts | 1 + .../Settings/InfrastructureSourcePicker.tsx | 3 +- .../Settings/InfrastructureWorkspace.tsx | 22 + .../InfrastructureOperationsModel.test.tsx | 34 ++ .../InfrastructureWorkspace.test.tsx | 12 + .../__tests__/reportingResourceTypes.test.ts | 1 + .../Settings/connectionsTableModel.ts | 9 +- .../infrastructureOperationsModel.tsx | 7 +- .../Settings/infrastructureWorkspaceModel.ts | 15 +- .../Settings/useConnectionsLedger.ts | 8 + .../__tests__/useUnifiedResources.test.ts | 2 +- .../src/hooks/useUnifiedResources.ts | 29 +- .../src/types/__tests__/resource.test.ts | 24 +- frontend-modern/src/types/api.ts | 2 +- frontend-modern/src/types/resource.ts | 27 +- .../agentCapabilityPresentation.test.ts | 2 + .../__tests__/canonicalResourceTypes.test.ts | 2 + ...frastructureOnboardingPresentation.test.ts | 33 +- .../__tests__/reportingResourceTypes.test.ts | 1 + .../__tests__/resourceTypeCompat.test.ts | 2 + .../utils/__tests__/sourcePlatforms.test.ts | 2 + .../src/utils/agentCapabilityPresentation.ts | 7 +- .../src/utils/canonicalResourceTypes.ts | 1 + .../infrastructureOnboardingPresentation.ts | 37 +- .../src/utils/reportingResourceTypes.ts | 1 + .../src/utils/resourceTypeCompat.ts | 8 +- frontend-modern/src/utils/sourcePlatforms.ts | 12 +- internal/alerts/unified_incidents.go | 4 + internal/alerts/unified_incidents_test.go | 54 ++ internal/api/availability_handlers.go | 294 ++++++++++ internal/api/availability_handlers_test.go | 140 +++++ internal/api/connections_aggregator.go | 70 ++- internal/api/connections_aggregator_test.go | 53 ++ internal/api/connections_grouping.go | 2 + internal/api/connections_handlers.go | 5 + internal/api/connections_types.go | 17 +- internal/api/monitored_system_ledger.go | 2 +- internal/api/resources.go | 6 + internal/api/route_inventory_test.go | 6 + internal/api/router.go | 5 + internal/api/router_routes_registration.go | 44 ++ internal/config/availability.go | 209 +++++++ internal/config/availability_test.go | 103 ++++ internal/config/persistence.go | 31 + internal/monitoring/availability_poller.go | 537 ++++++++++++++++++ .../monitoring/availability_poller_test.go | 128 +++++ .../monitoring/canonical_guardrails_test.go | 41 ++ internal/monitoring/monitor.go | 37 ++ internal/monitoring/monitor_polling_test.go | 78 +++ internal/monitoring/poll_providers.go | 4 + internal/monitoring/scheduler.go | 7 +- .../unifiedresources/canonical_identity.go | 21 + .../canonical_identity_test.go | 45 ++ internal/unifiedresources/clone.go | 9 + .../unifiedresources/incident_categories.go | 5 + internal/unifiedresources/registry.go | 27 +- internal/unifiedresources/registry_test.go | 57 ++ internal/unifiedresources/types.go | 39 +- pkg/reporting/engine.go | 2 + pkg/reporting/engine_test.go | 6 + .../release_control/subsystem_lookup_test.py | 4 +- 74 files changed, 2961 insertions(+), 66 deletions(-) create mode 100644 frontend-modern/src/api/__tests__/availabilityTargets.test.ts create mode 100644 frontend-modern/src/api/availabilityTargets.ts create mode 100644 frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx create mode 100644 internal/api/availability_handlers.go create mode 100644 internal/api/availability_handlers_test.go create mode 100644 internal/config/availability.go create mode 100644 internal/config/availability_test.go create mode 100644 internal/monitoring/availability_poller.go create mode 100644 internal/monitoring/availability_poller_test.go diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index ead806de0..1ac1d4af7 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -43,6 +43,7 @@ management, and fleet control surfaces. 20. `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx` 21. `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/TrueNASCredentialSlot.tsx` 22. `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx` +22a. `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx` 23. `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx` 24. `frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx` 25. `frontend-modern/src/components/Settings/InfrastructureSourcePicker.tsx` @@ -156,6 +157,13 @@ platform-connection lifecycle state. Once a TrueNAS or VMware setup form marks the connection disabled, lifecycle surfaces must treat a canonical zero-delta or removal-only preview as a valid save path instead of holding the dialog in an add-only posture. +Agentless availability targets belong to the infrastructure source-management +surface but are not host-agent lifecycle. The lifecycle UI may expose them from +Add infrastructure, Manage, and the connections ledger, but it must keep their +credential slot on the availability target API and must not ask for SSH, setup +tokens, auto-registration, agent profiles, or install commands. Their managed +rows are platform-connection-style rows whose lifecycle actions are pause, +test, edit, and remove only. The lifecycle-owned onboarding presentation helper must consume the governed platform support manifest for readiness stage, primary mode, canonical projections, and support-floor posture. diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index d7ab83c18..ded7ac621 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -170,6 +170,12 @@ directory once and then resolve only the fixed `alert-history.json` and before any filesystem read, write, rename, or delete. Future history-persistence changes must not reintroduce raw `filepath.Join(dataDir, ...)` joins from caller-supplied directories or ad hoc history filenames. +Agentless availability incidents now enter alerts through the same unified +resource incident bridge as storage, PBS, VM, and host resource incidents. +`network-endpoint` resources with `SourceAvailability` incidents must create +canonical `resource-incident` alerts with provider display `Availability`; +availability alerting must not introduce a second endpoint-only evaluator or +alert identity family outside `internal/alerts/unified_incidents.go`. Notification transport, provider delivery, queue safety, and notification API transport now live under the explicit `notifications` subsystem inside the diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index fa94acf3a..46e5c34fb 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -94,6 +94,9 @@ product API routes free of maintainer commercial analytics. 60. `internal/api/connections_probe.go` 61. `frontend-modern/src/api/connections.ts` 62. `frontend-modern/src/api/hostedSignup.ts` +63. `internal/api/availability_handlers.go` +64. `frontend-modern/src/api/availabilityTargets.ts` +65. `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx` ## Shared Boundaries @@ -130,6 +133,15 @@ product API routes free of maintainer commercial analytics. navigate to canonical destinations, but must not import or call local `upgradeMetrics`, `conversionEvents`, or infrastructure onboarding metrics wrappers. + Agentless availability targets share this same settings/API boundary as + platform-connection-managed infrastructure, not host-install lifecycle. + `internal/api/availability_handlers.go` owns CRUD and test payloads for + `/api/availability-targets`, while + `frontend-modern/src/api/availabilityTargets.ts` and + `AvailabilityTargetSlot.tsx` own the browser transport shape. Connections + ledger rows with type `availability` must route pause, remove, and test + actions to those availability-target endpoints and must not reuse node, + SSH, or Pulse Agent setup payloads. 13. `frontend-modern/src/components/Settings/NodeModalAuthenticationSection.tsx` shared with `agent-lifecycle`: the node setup authentication section is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary. 16. `frontend-modern/src/components/Settings/NodeModalBasicInfoSection.tsx` shared with `agent-lifecycle`: the node setup basic-info section is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary. 17. `frontend-modern/src/components/Settings/nodeModalModel.ts` shared with `agent-lifecycle`: the pure node setup modal model is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary. @@ -3392,6 +3404,12 @@ alongside `proxmox`, `pbs`, and `pmg`, and the settings reporting/install surfaces must keep those platform-managed rows navigable back to platform connections instead of presenting host uninstall or stop-monitoring actions that only apply to `agent`, `docker`, and `kubernetes`. +Agentless availability targets extend that platform-managed distinction. The +API contract for `availability` rows is an address/protocol probe target plus +runtime status, projected through the connections ledger and unified resources +as a `network-endpoint`. Browser callers may test unsaved or saved targets, but +the persisted target list remains owned by `/api/availability-targets` and +must not be reconstructed from resource snapshots or monitored-system counts. That same shared metrics-history contract now also owns physical-disk live I/O windows. `internal/api/router.go` must accept `resourceType=disk` on `/api/metrics-store/history`, keep `30m` as a valid compact live range, and diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 91f80304f..cc397c7d7 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -161,6 +161,7 @@ work extends shared components instead of creating new local variants. 130. `frontend-modern/src/utils/platformSupportManifest.ts` 131. `frontend-modern/src/utils/sourcePlatformOptions.ts` 132. `frontend-modern/src/utils/sourcePlatforms.ts` +133. `frontend-modern/src/utils/infrastructureOnboardingPresentation.ts` ## Shared Boundaries @@ -2790,3 +2791,10 @@ the normalised (not canonical) path when resolving Proxmox agent and path checks so that deep links such as `/settings/infrastructure/platforms/proxmox/pbs` resolve to the correct agent before the canonical-redirect fires, rather than after it has already collapsed the path. +The shared frontend source/platform vocabulary now also includes +`availability` as an agentless infrastructure source and `network-endpoint` as +the canonical resource projection. Picker cards, source labels, badges, and +settings add-flow copy must use the shared onboarding and source-platform +helpers instead of feature-local wording, so availability probes stay visually +aligned with the single Infrastructure settings surface without pretending to +be a host agent install. diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index cdce494ae..4e125f8d3 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -52,6 +52,8 @@ truth for live infrastructure data. 28. `internal/monitoring/guest_disk_stability.go` 29. `internal/monitoring/mock_metrics_history.go` 30. `internal/monitoring/mock_chart_history.go` +31. `internal/monitoring/availability_poller.go` +32. `internal/monitoring/scheduler.go` ## Shared Boundaries @@ -77,6 +79,13 @@ truth for live infrastructure data. `Disabled=false` must remain the migration-safe default for existing `nodes.json` content; the poller must never create a client or mark an instance reachable when `Disabled` is true. +12. Add or change agentless availability monitoring only through the + 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. + Failed endpoint probes are observed runtime state for that target; they + must publish provider health and incidents without dead-lettering the + scheduler task itself. ## Forbidden Paths @@ -113,6 +122,14 @@ This subsystem now sits under the dedicated core monitoring runtime lane so discovery, metrics-history correctness, and platform-specific runtime coverage can be governed as first-class product work instead of staying diluted inside architecture coherence. +That same monitoring boundary now owns agentless availability targets as a +first-class provider, not as a settings-only helper. Saved availability targets +load from the config persistence boundary, schedule through +`InstanceTypeAvailability`, and publish `SourceAvailability` +`network-endpoint` supplemental records for unified-resource consumers. ICMP is +the default low-overhead check, while TCP and HTTP are canonical fallbacks for +devices or runtimes where ICMP is unavailable or the useful signal is a port or +web interface. That same monitoring boundary also owns the escalation callback bridge into the alerts delivery layer. Monitor-owned escalation handling may still publish canonical escalation state to websocket consumers, but notification fan-out diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 146d952a9..afec101a5 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -485,6 +485,12 @@ splits. `InfrastructureSummary.tsx` and `infrastructureSummaryModel.ts` add alerting count derived from `activeAlerts`. These additions must remain read-only projections from existing websocket state — they must not introduce new polling loops or widen fetch scope on the hot-path boundary. +Agentless availability endpoints participate in the same unified-resource +consumer hot path as other infrastructure resources. Adding +`network-endpoint` to resource queries, filters, and summary counts must reuse +the existing unified-resource hydration and websocket paths; frontend +consumers must not add an endpoint-specific polling loop or a second table +model just to show availability status. This lane already has strong evidence and guardrails, but it still trails on score because critical hot paths need more complete protection and verification. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 3254c261e..8f94955c5 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -827,6 +827,12 @@ now surface `poolsDegraded` and `disksFailing` health indicators alongside pool/disk counts. `RecoverySummary.tsx` gains an aggregate health-state summary row. These additions project from existing websocket pool/disk state; they must not introduce new API polling or widen the storage-fetch boundary. +Agentless availability endpoints are adjacent infrastructure context only, not +storage or recovery inventory. Storage/recovery consumers may receive +`network-endpoint` resources through shared unified-resource snapshots, but +they must not fold those endpoints into protected-item counts, storage health +rollups, recovery evidence, or storage/recovery licensing or readiness +messages unless a separately governed storage/recovery relationship is added. That same owned summary path now also runs through `useStorageSummaryCharts.ts`: the storage page owns one page-scoped summary range and one shared storage-summary history fetch, and both the sticky diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 3cb505143..fa776f804 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -112,6 +112,7 @@ cross-source deduplication. 88. `frontend-modern/src/utils/platformSupportManifest.generated.ts` 89. `internal/unifiedresources/kubernetes_metric_ids.go` 90. `internal/unifiedresources/policy_posture.go` +91. `internal/unifiedresources/clone.go` ## Shared Boundaries @@ -136,10 +137,15 @@ cross-source deduplication. v5 Docker users can still find the runtime surface while Podman-backed rows are not mislabeled as Docker-only. 17. `internal/api/resources.go` shared with `api-contracts`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary. - ## Extension Points 1. Add new resource types and identity fields in `internal/unifiedresources/types.go` + Agentless availability endpoints enter the model as + `ResourceTypeNetworkEndpoint` with `SourceAvailability` and + `AvailabilityData`. Canonical identity must prefer the saved target id as + `availability:`, keep the probe address as hostname/platform + identity when no stronger identity exists, and preserve availability payloads + through clone, merge, API transport, and frontend decode paths. 2. Add typed accessors and views in `internal/unifiedresources/views.go` 3. Add source ingestion/adaptation in the adapter layer only Infrastructure table platform presentation extends through @@ -510,6 +516,12 @@ canonical resource identity, discovery normalization, and platform-runtime coverage stay governed as a first-class Pulse product surface, including the shared VMware signal-metadata and `resource-incident` timeline vocabulary that canonical resources expose to alerts, AI, and frontend consumers. +Agentless availability checks are now canonical resources rather than +connection-only status rows. `SourceAvailability` emits `network-endpoint` +records with the saved target id, probe address, protocol, cadence, last check, +failure count, and threshold in `AvailabilityData`. Registry merge policy must +preserve that payload and incident state without trying to fold endpoints into +hosts, VMs, or storage resources solely because an address matches. That same frontend-owned compatibility boundary must remain intentionally narrow. Shared resource adapters may admit explicit aliases such as `host`, `truenas`, and `ceph`, and VMware detail mappers may project typed metadata diff --git a/frontend-modern/src/api/__tests__/availabilityTargets.test.ts b/frontend-modern/src/api/__tests__/availabilityTargets.test.ts new file mode 100644 index 000000000..531f16655 --- /dev/null +++ b/frontend-modern/src/api/__tests__/availabilityTargets.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AvailabilityTargetsAPI, type AvailabilityTarget } from '@/api/availabilityTargets'; +import { apiFetchJSON } from '@/utils/apiClient'; + +vi.mock('@/utils/apiClient', () => ({ + apiFetchJSON: vi.fn(), +})); + +const mockedApiFetchJSON = vi.mocked(apiFetchJSON); + +describe('AvailabilityTargetsAPI', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('lists, creates, updates, removes, and tests through canonical routes', async () => { + const target: AvailabilityTarget = { + id: 'sensor-1', + name: 'Energy monitor', + address: '192.0.2.10', + protocol: 'icmp', + enabled: true, + }; + + mockedApiFetchJSON.mockResolvedValueOnce([target]); + await expect(AvailabilityTargetsAPI.list()).resolves.toEqual([target]); + expect(mockedApiFetchJSON).toHaveBeenLastCalledWith('/api/availability-targets'); + + mockedApiFetchJSON.mockResolvedValueOnce(target); + await AvailabilityTargetsAPI.create(target); + expect(mockedApiFetchJSON).toHaveBeenLastCalledWith('/api/availability-targets', { + method: 'POST', + body: JSON.stringify(target), + }); + + mockedApiFetchJSON.mockResolvedValueOnce({ ...target, enabled: false }); + await AvailabilityTargetsAPI.update('sensor/1', { enabled: false }); + expect(mockedApiFetchJSON).toHaveBeenLastCalledWith('/api/availability-targets/sensor%2F1', { + method: 'PUT', + body: JSON.stringify({ enabled: false }), + }); + + mockedApiFetchJSON.mockResolvedValueOnce({ success: true }); + await AvailabilityTargetsAPI.remove('sensor/1'); + expect(mockedApiFetchJSON).toHaveBeenLastCalledWith('/api/availability-targets/sensor%2F1', { + method: 'DELETE', + }); + + mockedApiFetchJSON.mockResolvedValueOnce({ success: true, latencyMillis: 5 }); + await AvailabilityTargetsAPI.test(target); + expect(mockedApiFetchJSON).toHaveBeenLastCalledWith('/api/availability-targets/test', { + method: 'POST', + body: JSON.stringify(target), + }); + + mockedApiFetchJSON.mockResolvedValueOnce({ success: true, latencyMillis: 5 }); + await AvailabilityTargetsAPI.testSaved('sensor/1'); + expect(mockedApiFetchJSON).toHaveBeenLastCalledWith( + '/api/availability-targets/sensor%2F1/test', + { + method: 'POST', + }, + ); + }); +}); diff --git a/frontend-modern/src/api/__tests__/connections.test.ts b/frontend-modern/src/api/__tests__/connections.test.ts index 2f277b049..1db288188 100644 --- a/frontend-modern/src/api/__tests__/connections.test.ts +++ b/frontend-modern/src/api/__tests__/connections.test.ts @@ -263,4 +263,19 @@ describe('ConnectionsAPI', () => { }); expect(result).toEqual(response); }); + + it('routes availability pause and remove actions to availability targets', async () => { + mockedApiFetchJSON.mockResolvedValueOnce({ success: true }); + await ConnectionsAPI.setEnabled('availability:sensor/1', false); + expect(mockedApiFetchJSON).toHaveBeenLastCalledWith('/api/availability-targets/sensor%2F1', { + method: 'PUT', + body: JSON.stringify({ enabled: false }), + }); + + mockedApiFetchJSON.mockResolvedValueOnce({ success: true }); + await ConnectionsAPI.remove('availability:sensor/1'); + expect(mockedApiFetchJSON).toHaveBeenLastCalledWith('/api/availability-targets/sensor%2F1', { + method: 'DELETE', + }); + }); }); diff --git a/frontend-modern/src/api/availabilityTargets.ts b/frontend-modern/src/api/availabilityTargets.ts new file mode 100644 index 000000000..7c59057ff --- /dev/null +++ b/frontend-modern/src/api/availabilityTargets.ts @@ -0,0 +1,82 @@ +import { apiFetchJSON } from '@/utils/apiClient'; + +const AVAILABILITY_TARGETS_PATH = '/api/availability-targets'; + +export type AvailabilityProbeProtocol = 'icmp' | 'tcp' | 'http'; + +export interface AvailabilityProbeStatus { + targetId: string; + name: string; + address: string; + protocol: AvailabilityProbeProtocol | string; + enabled: boolean; + available: boolean; + lastChecked?: string; + lastSuccess?: string; + latencyMillis?: number; + consecutiveFailures?: number; + lastError?: string; + failureThreshold?: number; +} + +export interface AvailabilityTarget { + id: string; + name: string; + address: string; + protocol: AvailabilityProbeProtocol; + port?: number; + path?: string; + enabled: boolean; + pollIntervalSeconds?: number; + timeoutMillis?: number; + failureThreshold?: number; + status?: AvailabilityProbeStatus; +} + +export interface AvailabilityTestResponse { + success: boolean; + latencyMillis: number; + error?: string; +} + +export class AvailabilityTargetsAPI { + static async list(): Promise { + return apiFetchJSON(AVAILABILITY_TARGETS_PATH); + } + + static async create(target: AvailabilityTarget): Promise { + return apiFetchJSON(AVAILABILITY_TARGETS_PATH, { + method: 'POST', + body: JSON.stringify(target), + }); + } + + static async update( + id: string, + target: Partial, + ): Promise { + return apiFetchJSON(`${AVAILABILITY_TARGETS_PATH}/${encodeURIComponent(id)}`, { + method: 'PUT', + body: JSON.stringify(target), + }); + } + + static async remove(id: string): Promise { + await apiFetchJSON(`${AVAILABILITY_TARGETS_PATH}/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); + } + + static async test(target: AvailabilityTarget): Promise { + return apiFetchJSON(`${AVAILABILITY_TARGETS_PATH}/test`, { + method: 'POST', + body: JSON.stringify(target), + }); + } + + static async testSaved(id: string): Promise { + return apiFetchJSON(`${AVAILABILITY_TARGETS_PATH}/${encodeURIComponent(id)}/test`, { + method: 'POST', + }); + } +} diff --git a/frontend-modern/src/api/connections.ts b/frontend-modern/src/api/connections.ts index a4d0db6ac..180cb6f2e 100644 --- a/frontend-modern/src/api/connections.ts +++ b/frontend-modern/src/api/connections.ts @@ -7,6 +7,7 @@ export type ConnectionType = | 'pmg' | 'vmware' | 'truenas' + | 'availability' | 'agent' | 'docker' | 'kubernetes'; @@ -188,6 +189,12 @@ export class ConnectionsAPI { body: JSON.stringify({ enabled }), }); return; + case 'availability': + await apiFetchJSON(`/api/availability-targets/${encodeURIComponent(suffix)}`, { + method: 'PUT', + body: JSON.stringify({ enabled }), + }); + return; case 'agent': case 'docker': case 'kubernetes': @@ -217,6 +224,11 @@ export class ConnectionsAPI { method: 'DELETE', }); return; + case 'availability': + await apiFetchJSON(`/api/availability-targets/${encodeURIComponent(suffix)}`, { + method: 'DELETE', + }); + return; case 'agent': await MonitoringAPI.deleteAgent(suffix); return; diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx new file mode 100644 index 000000000..322316fee --- /dev/null +++ b/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx @@ -0,0 +1,374 @@ +import { Component, Show, createSignal, onMount } from 'solid-js'; +import { + formCheckbox, + formControl, + formField, + formHelpText, + formLabel, +} from '@/components/shared/Form'; +import { FormSelect } from '@/components/shared/FormSelect'; +import { + AvailabilityTargetsAPI, + type AvailabilityProbeProtocol, + type AvailabilityTarget, + type AvailabilityTestResponse, +} from '@/api/availabilityTargets'; + +const buttonClass = + 'inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md border border-border px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60'; +const primaryButtonClass = + 'inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60'; + +interface AvailabilityForm { + id: string; + name: string; + address: string; + protocol: AvailabilityProbeProtocol; + port: string; + path: string; + enabled: boolean; + pollIntervalSeconds: string; + timeoutMillis: string; + failureThreshold: string; +} + +export interface AvailabilityTargetSlotProps { + editingTargetId?: string | null; + onCancel: () => void; + onSaved: () => void; + onToggleEnabled?: () => void; + togglePending?: boolean; + connectionEnabled?: boolean; + onDelete?: () => void; + deletePending?: boolean; + deleteConfirming?: boolean; + deleteError?: string | null; +} + +const newAvailabilityForm = (): AvailabilityForm => ({ + id: '', + name: '', + address: '', + protocol: 'icmp', + port: '', + path: '', + enabled: true, + pollIntervalSeconds: '60', + timeoutMillis: '2000', + failureThreshold: '2', +}); + +const formFromTarget = (target: AvailabilityTarget): AvailabilityForm => ({ + id: target.id, + name: target.name ?? '', + address: target.address ?? '', + protocol: target.protocol ?? 'icmp', + port: target.port ? String(target.port) : '', + path: target.path ?? '', + enabled: target.enabled ?? true, + pollIntervalSeconds: String(target.pollIntervalSeconds ?? 60), + timeoutMillis: String(target.timeoutMillis ?? 2000), + failureThreshold: String(target.failureThreshold ?? 2), +}); + +const parsePositiveInt = (value: string): number | undefined => { + const parsed = Number.parseInt(value.trim(), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +}; + +const payloadFromForm = (form: AvailabilityForm): AvailabilityTarget => { + const port = parsePositiveInt(form.port); + return { + id: form.id, + name: form.name.trim(), + address: form.address.trim(), + protocol: form.protocol, + port: form.protocol === 'icmp' ? undefined : port, + path: form.protocol === 'http' ? form.path.trim() : undefined, + enabled: form.enabled, + pollIntervalSeconds: parsePositiveInt(form.pollIntervalSeconds), + timeoutMillis: parsePositiveInt(form.timeoutMillis), + failureThreshold: parsePositiveInt(form.failureThreshold), + }; +}; + +const testToneClass = (result: AvailabilityTestResponse) => + result.success + ? 'border-green-300 bg-green-50 text-green-800 dark:border-green-900 dark:bg-green-950 dark:text-green-200' + : 'border-rose-300 bg-rose-50 text-rose-800 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200'; + +export const AvailabilityTargetSlot: Component = (props) => { + const [form, setForm] = createSignal(newAvailabilityForm()); + const [loading, setLoading] = createSignal(false); + const [saving, setSaving] = createSignal(false); + const [testing, setTesting] = createSignal(false); + const [error, setError] = createSignal(null); + const [testResult, setTestResult] = createSignal(null); + + const updateForm = (patch: Partial) => { + setForm((current) => ({ ...current, ...patch })); + setError(null); + setTestResult(null); + }; + + onMount(async () => { + const targetId = props.editingTargetId?.trim(); + if (!targetId) return; + setLoading(true); + setError(null); + try { + const targets = await AvailabilityTargetsAPI.list(); + const target = targets.find((item) => item.id === targetId); + if (!target) { + setError('The saved availability target could not be found.'); + return; + } + setForm(formFromTarget(target)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load availability target.'); + } finally { + setLoading(false); + } + }); + + const handleTest = async () => { + setTesting(true); + setError(null); + setTestResult(null); + try { + const result = await AvailabilityTargetsAPI.test(payloadFromForm(form())); + setTestResult(result); + } catch (err) { + setError(err instanceof Error ? err.message : 'Availability test failed.'); + } finally { + setTesting(false); + } + }; + + const handleSave = async () => { + setSaving(true); + setError(null); + setTestResult(null); + const payload = payloadFromForm(form()); + try { + const targetId = props.editingTargetId?.trim(); + if (targetId) { + await AvailabilityTargetsAPI.update(targetId, payload); + } else { + await AvailabilityTargetsAPI.create(payload); + } + props.onSaved(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save availability target.'); + } finally { + setSaving(false); + } + }; + + const isBusy = () => loading() || saving() || testing() || Boolean(props.deletePending); + const isEditing = () => Boolean(props.editingTargetId); + + return ( +
+ +
+ Loading target… +
+
+ +
+ + + updateForm({ protocol: event.currentTarget.value as AvailabilityProbeProtocol }) + } + > + + + + + + + + + + + + + + +
+ +
+
+ + + {(result) => ( +
+ {result().success + ? `Probe reached the target in ${result().latencyMillis} ms.` + : result().error || 'Probe failed.'} +
+ )} +
+ + + {(message) => ( + + )} + + + + {(message) => ( + + )} + + + +
+ Click remove again to confirm. Historical resource data and alerts remain available. +
+
+ +
+
+ + +
+
+ + + + + + + +
+
+
+ ); +}; diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts b/frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts index 09e49c50d..b82df940a 100644 --- a/frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts +++ b/frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts @@ -42,6 +42,7 @@ export const CONNECTION_TYPE_LABELS: Record = { pmg: 'Proxmox Mail Gateway', vmware: getInfrastructureOnboardingProductPresentation('vmware').label, truenas: getInfrastructureOnboardingProductPresentation('truenas').label, + availability: getInfrastructureOnboardingProductPresentation('availability').label, agent: 'Pulse Agent', docker: 'Docker', kubernetes: 'Kubernetes', diff --git a/frontend-modern/src/components/Settings/InfrastructureSourcePicker.tsx b/frontend-modern/src/components/Settings/InfrastructureSourcePicker.tsx index 604fd8c87..99edb7f02 100644 --- a/frontend-modern/src/components/Settings/InfrastructureSourcePicker.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureSourcePicker.tsx @@ -1,5 +1,5 @@ import { Component, For, Show } from 'solid-js'; -import { Archive, Cpu, Database, Mail, Search, Server, ServerCog } from 'lucide-solid'; +import { Activity, Archive, Cpu, Database, Mail, Search, Server, ServerCog } from 'lucide-solid'; import type { InfrastructureOnboardingConnectionType } from '@/utils/infrastructureOnboardingPresentation'; import { getInfrastructureSourcePickerGroups, @@ -22,6 +22,7 @@ const CARD_ICON: Record = (props) => { diff --git a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx index fa52f902d..df790abd1 100644 --- a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx @@ -10,6 +10,7 @@ import { AgentProfilesPanel } from './AgentProfilesPanel'; import type { AgentUninstallCommands } from './ConnectionsTable'; import { ConnectionEditor } from './ConnectionEditor/ConnectionEditor'; import { NodeCredentialSlot } from './ConnectionEditor/CredentialSlots/NodeCredentialSlot'; +import { AvailabilityTargetSlot } from './ConnectionEditor/CredentialSlots/AvailabilityTargetSlot'; import { TrueNASCredentialSlot } from './ConnectionEditor/CredentialSlots/TrueNASCredentialSlot'; import { VMwareCredentialSlot } from './ConnectionEditor/CredentialSlots/VMwareCredentialSlot'; import type { Connection, ConnectionType, ProbeCandidate } from '@/api/connections'; @@ -60,6 +61,7 @@ const ADD_STEP_TO_TYPE: Record = { pve: 'pve', pbs: 'pbs', pmg: 'pmg', + availability: 'availability', truenas: 'truenas', vmware: 'vmware', }; @@ -75,6 +77,7 @@ const describeManagedSourceType = (type: ConnectionType | null): string => { type === 'agent' || type === 'vmware' || type === 'truenas' || + type === 'availability' || type === 'pve' || type === 'pbs' || type === 'pmg' @@ -700,6 +703,23 @@ const InfrastructureWorkspaceContent: Component = /> ); } + case 'availability': { + const targetId = aggregatorSuffix(connection.id); + return ( + void rowActions.togglePause(connection)} + togglePending={rowActions.pendingAction(connection.id) === 'pause'} + connectionEnabled={connection.enabled} + onDelete={() => void rowActions.requestRemove(connection)} + deletePending={rowActions.pendingAction(connection.id) === 'remove'} + deleteConfirming={rowActions.confirmingRemove(connection.id)} + deleteError={rowActions.actionError(connection.id)} + /> + ); + } default: return (
@@ -760,6 +780,8 @@ const InfrastructureWorkspaceContent: Component = onSaved={context.onSaved} /> ); + case 'availability': + return ; case 'agent': return renderAgentAddSlot(); default: diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx index 8a73e94d6..e5e9803bb 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx @@ -7,6 +7,8 @@ import useInfrastructureInstallStateSource from '../useInfrastructureInstallStat import { INSTALL_PROFILE_OPTIONS, getCapabilityManagementPath, + getCapabilitySurfaceLabel, + getPlatformConnectionsViewForCapability, hasMachineInstallActions, getPowerShellInstallProfileEnvFromFlags, getStopMonitoringScopeLabel, @@ -135,6 +137,38 @@ describe('infrastructure operations model', () => { expect(getCapabilityManagementPath('truenas')).toBe('/settings/infrastructure'); }); + it('treats availability probes as agentless platform-managed items', () => { + const item: ConnectedInfrastructureItem = { + id: 'availability:energy-meter', + name: 'Energy meter', + hostname: '192.0.2.44', + status: 'active', + surfaces: [ + { + id: 'availability:energy-meter', + kind: 'availability', + label: 'Availability data', + detail: 'Pulse is checking this network endpoint with an agentless probe.', + idLabel: 'Target ID', + idValue: 'energy-meter', + }, + ], + }; + + const row = rowFromConnectedInfrastructureItem(item, { + label: 'N/A', + detail: '', + category: 'na', + }); + + expect(row.capabilities).toEqual(['availability']); + expect(row.installFlags).toEqual([]); + expect(hasMachineInstallActions(row)).toBe(false); + expect(getCapabilitySurfaceLabel('availability')).toBe('Availability data'); + expect(getCapabilityManagementPath('availability')).toBe('/settings/infrastructure'); + expect(getPlatformConnectionsViewForCapability('availability')).toBeNull(); + }); + it('maps install-profile flags into PowerShell installer env assignments', () => { expect( getPowerShellInstallProfileEnvFromFlags([ diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx index 8e52f0b06..4c6829bb2 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx @@ -144,6 +144,10 @@ vi.mock('../ConnectionEditor/CredentialSlots/NodeCredentialSlot', () => ({ ), })); +vi.mock('../ConnectionEditor/CredentialSlots/AvailabilityTargetSlot', () => ({ + AvailabilityTargetSlot: () =>
availability
, +})); + vi.mock('../ConnectionEditor/CredentialSlots/TrueNASCredentialSlot', () => ({ TrueNASCredentialSlot: () =>
truenas
, })); @@ -690,6 +694,14 @@ describe('InfrastructureWorkspace', () => { expect(screen.getByTestId('truenas-section')).toBeInTheDocument(); }); + it('renders the agentless availability target route from the shared workspace', async () => { + routeState.search = '?add=availability'; + renderWorkspace(); + + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + expect(screen.getByTestId('availability-section')).toBeInTheDocument(); + }); + it('opens the manage dialog directly from an existing source card', async () => { renderWorkspace({ pveNodes: () => [{ name: 'zeus', host: 'https://10.0.0.1:8006' } as any], diff --git a/frontend-modern/src/components/Settings/__tests__/reportingResourceTypes.test.ts b/frontend-modern/src/components/Settings/__tests__/reportingResourceTypes.test.ts index 0a89f765f..ecf8f3f45 100644 --- a/frontend-modern/src/components/Settings/__tests__/reportingResourceTypes.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/reportingResourceTypes.test.ts @@ -8,6 +8,7 @@ describe('toReportingResourceType', () => { expect(toReportingResourceType('system-container')).toBe('system-container'); expect(toReportingResourceType('app-container')).toBe('app-container'); expect(toReportingResourceType('docker-host')).toBe('docker-host'); + expect(toReportingResourceType('network-endpoint')).toBe('network-endpoint'); expect(toReportingResourceType('storage')).toBe('storage'); }); diff --git a/frontend-modern/src/components/Settings/connectionsTableModel.ts b/frontend-modern/src/components/Settings/connectionsTableModel.ts index 714cf736f..9b8ade658 100644 --- a/frontend-modern/src/components/Settings/connectionsTableModel.ts +++ b/frontend-modern/src/components/Settings/connectionsTableModel.ts @@ -164,11 +164,12 @@ const SURFACE_LABELS: Record = { datasets: 'Datasets', pools: 'Pools', replication: 'Replication', + availability: 'Availability', }; export const surfaceLabel = (key: string): string => SURFACE_LABELS[key] ?? key; -export type InfrastructureSourceKind = 'api' | 'agent' | 'both' | 'unknown'; +export type InfrastructureSourceKind = 'api' | 'agent' | 'both' | 'probe' | 'unknown'; export interface InfrastructureSourcePresentation { label: string; @@ -195,6 +196,12 @@ const SOURCE_PRESENTATION: Record { return 'PMG data'; case 'truenas': return 'TrueNAS data'; + case 'availability': + return 'Availability data'; default: return getAgentCapabilityLabel(capability); } @@ -180,7 +182,7 @@ export const getStopMonitoringSurfaces = (row: UnifiedAgentRow) => { }; export const isPlatformConnectionsCapability = (capability: AgentCapability) => - ['proxmox', 'pbs', 'pmg', 'truenas'].includes(capability); + ['proxmox', 'pbs', 'pmg', 'truenas', 'availability'].includes(capability); export const getPlatformConnectionsViewForCapability = ( capability: AgentCapability, @@ -192,6 +194,8 @@ export const getPlatformConnectionsViewForCapability = ( return 'proxmox'; case 'truenas': return 'truenas'; + case 'availability': + return null; default: return null; } @@ -422,6 +426,7 @@ const agentCapabilityFromSurfaceKind = ( case 'pbs': case 'pmg': case 'truenas': + case 'availability': return kind; default: return 'agent'; diff --git a/frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts b/frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts index 6ee71106f..07c7f684b 100644 --- a/frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts +++ b/frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts @@ -4,6 +4,7 @@ export type InfrastructureAddStep = | 'pve' | 'pbs' | 'pmg' + | 'availability' | 'truenas' | 'vmware'; export type InfrastructurePanelStep = 'pick' | InfrastructureAddStep; @@ -31,6 +32,7 @@ export function normalizeInfrastructurePanelStep( case 'pve': case 'pbs': case 'pmg': + case 'availability': case 'truenas': case 'vmware': return value!.trim() as InfrastructurePanelStep; @@ -43,17 +45,13 @@ export function buildInfrastructureWorkspacePath(): string { return INFRASTRUCTURE_BASE_PATH; } -export function buildInfrastructureOnboardingPath( - step: InfrastructurePanelStep = 'agent', -): string { +export function buildInfrastructureOnboardingPath(step: InfrastructurePanelStep = 'agent'): string { const params = new URLSearchParams(); params.set(INFRASTRUCTURE_ADD_QUERY_PARAM, step); return `${INFRASTRUCTURE_BASE_PATH}?${params.toString()}`; } -export function deriveAddStepFromLegacyPath( - pathname: string, -): InfrastructurePanelStep | null { +export function deriveAddStepFromLegacyPath(pathname: string): InfrastructurePanelStep | null { if (matchesPathPrefix(pathname, LEGACY_INSTALL_PATH)) return 'agent'; if (matchesExactPath(pathname, LEGACY_PLATFORMS_PATH)) return 'pick'; return null; @@ -68,7 +66,10 @@ export function deriveAddStepFromLocation( pathname: string, search: string, ): InfrastructurePanelStep | null { - if (pathname === INFRASTRUCTURE_BASE_PATH || pathname.startsWith(`${INFRASTRUCTURE_BASE_PATH}/`)) { + if ( + pathname === INFRASTRUCTURE_BASE_PATH || + pathname.startsWith(`${INFRASTRUCTURE_BASE_PATH}/`) + ) { const stepFromQuery = deriveAddStepFromSearch(search); if (stepFromQuery) { return stepFromQuery; diff --git a/frontend-modern/src/components/Settings/useConnectionsLedger.ts b/frontend-modern/src/components/Settings/useConnectionsLedger.ts index bb95b78f5..483059172 100644 --- a/frontend-modern/src/components/Settings/useConnectionsLedger.ts +++ b/frontend-modern/src/components/Settings/useConnectionsLedger.ts @@ -30,6 +30,7 @@ export const CONNECTION_TYPE_LABELS: Record = { pmg: 'Proxmox Mail Gateway', vmware: 'VMware vCenter', truenas: 'TrueNAS SCALE', + availability: 'Network Endpoint', agent: 'Pulse Unified Agent', docker: 'Docker', kubernetes: 'Kubernetes', @@ -76,6 +77,7 @@ const EDITABLE_CONNECTION_TYPES: readonly ConnectionType[] = [ 'pmg', 'vmware', 'truenas', + 'availability', ]; const CONNECTION_STATE_SEVERITY: Record = { @@ -107,6 +109,7 @@ const coverageLabelsFor = (connections: readonly Connection[]): string[] => { const subtitleFor = (connections: readonly Connection[], primaryConnection: Connection): string => { const hasPlatformAPI = connections.some((connection) => PLATFORM_API_TYPES.has(connection.type)); const hasPulseAgent = connections.some((connection) => connection.type === 'agent'); + const hasAvailabilityProbe = connections.some((connection) => connection.type === 'availability'); if (hasPlatformAPI && hasPulseAgent) { return 'via platform API and Pulse Agent'; @@ -119,6 +122,9 @@ const subtitleFor = (connections: readonly Connection[], primaryConnection: Conn if (hasPulseAgent) { return 'via Pulse Agent'; } + if (hasAvailabilityProbe) { + return 'via availability probe'; + } const productLabel = CONNECTION_TYPE_LABELS[primaryConnection.type] ?? primaryConnection.type; return `via ${productLabel}`; @@ -127,9 +133,11 @@ const subtitleFor = (connections: readonly Connection[], primaryConnection: Conn const sourceFor = (connections: readonly Connection[]): InfrastructureSourceKind => { const hasPlatformAPI = connections.some((connection) => PLATFORM_API_TYPES.has(connection.type)); const hasPulseAgent = connections.some((connection) => connection.type === 'agent'); + const hasAvailabilityProbe = connections.some((connection) => connection.type === 'availability'); if (hasPlatformAPI && hasPulseAgent) return 'both'; if (hasPlatformAPI) return 'api'; if (hasPulseAgent) return 'agent'; + if (hasAvailabilityProbe) return 'probe'; return 'unknown'; }; diff --git a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts index b951a1c0f..81d79bb06 100644 --- a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts +++ b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts @@ -207,7 +207,7 @@ describe('useUnifiedResources', () => { await waitForResourceCount(() => result!.resources().length); expect(apiFetchMock).toHaveBeenNthCalledWith( 1, - '/api/resources?type=agent%2Cdocker-host%2Cpbs%2Cpmg%2Ck8s-cluster%2Ck8s-node&page=1&limit=100', + '/api/resources?type=agent%2Cdocker-host%2Cpbs%2Cpmg%2Ck8s-cluster%2Ck8s-node%2Cnetwork-endpoint&page=1&limit=100', { cache: 'no-store' }, ); await flushAsync(); diff --git a/frontend-modern/src/hooks/useUnifiedResources.ts b/frontend-modern/src/hooks/useUnifiedResources.ts index 67487b1a8..62f73f6b4 100644 --- a/frontend-modern/src/hooks/useUnifiedResources.ts +++ b/frontend-modern/src/hooks/useUnifiedResources.ts @@ -12,6 +12,7 @@ import type { ResourceFacetCounts, ResourceDiscoveryTarget, ResourceMetricsTarget, + ResourceAvailabilityMeta, ResourcePBSMeta, ResourcePolicyPostureSummary, ResourceStatus, @@ -33,7 +34,8 @@ import { } from '@/utils/sourcePlatforms'; const UNIFIED_RESOURCES_BASE_URL = '/api/resources'; -const DEFAULT_UNIFIED_RESOURCES_QUERY = 'type=agent,docker-host,pbs,pmg,k8s-cluster,k8s-node'; +const DEFAULT_UNIFIED_RESOURCES_QUERY = + 'type=agent,docker-host,pbs,pmg,k8s-cluster,k8s-node,network-endpoint'; const STORAGE_RECOVERY_UNIFIED_RESOURCES_QUERY = 'type=storage,pbs,pmg,vm,system-container,pod,agent,k8s-cluster,k8s-node,physical_disk,ceph'; const UNIFIED_RESOURCES_PAGE_LIMIT = 100; @@ -350,6 +352,24 @@ type APIResource = { recentTaskSummary?: string; snapshotCount?: number; }; + availability?: { + targetId?: string; + name?: string; + address?: string; + protocol?: string; + port?: number; + path?: string; + enabled?: boolean; + available?: boolean; + lastChecked?: string; + lastSuccess?: string; + latencyMillis?: number; + consecutiveFailures?: number; + lastError?: string; + failureThreshold?: number; + pollIntervalSeconds?: number; + timeoutMillis?: number; + }; recentChanges?: ResourceChange[]; facetCounts?: ResourceFacetCounts; physicalDisk?: { @@ -503,6 +523,7 @@ const resolveType = (value?: string): ResourceType => { case 'pbs': case 'pmg': case 'ceph': + case 'network-endpoint': return canonicalFrontendType; case 'disk': return 'physical_disk'; @@ -530,6 +551,10 @@ const resolveType = (value?: string): ResourceType => { case 'physical_disk': case 'physical-disk': return 'physical_disk'; + case 'network-endpoint': + case 'network_endpoint': + case 'availability': + return 'network-endpoint'; default: return 'agent'; } @@ -634,6 +659,7 @@ const toResource = (v2: APIResource): Resource => { kubernetes: v2.kubernetes, vmware: v2.vmware as ResourceVMwareMeta | undefined, pbs: v2.pbs as ResourcePBSMeta | undefined, + availability: v2.availability as ResourceAvailabilityMeta | undefined, physicalDisk: v2.physicalDisk, storage: v2.storage as ResourceStorageMeta | undefined, proxmox: v2.proxmox @@ -703,6 +729,7 @@ const toResource = (v2: APIResource): Resource => { pmg: v2.pmg, kubernetes: v2.kubernetes, vmware: v2.vmware, + availability: v2.availability, physicalDisk: v2.physicalDisk, ceph: v2.ceph, metrics: v2.metrics, diff --git a/frontend-modern/src/types/__tests__/resource.test.ts b/frontend-modern/src/types/__tests__/resource.test.ts index 4276acf84..03018cac2 100644 --- a/frontend-modern/src/types/__tests__/resource.test.ts +++ b/frontend-modern/src/types/__tests__/resource.test.ts @@ -48,7 +48,13 @@ describe('Resource Type Guards', () => { }); describe('isInfrastructure', () => { - const infrastructureTypes: ResourceType[] = ['agent', 'docker-host', 'k8s-node', 'k8s-cluster']; + const infrastructureTypes: ResourceType[] = [ + 'agent', + 'docker-host', + 'k8s-node', + 'k8s-cluster', + 'network-endpoint', + ]; const nonInfrastructureTypes: ResourceType[] = [ 'vm', 'system-container', @@ -77,7 +83,13 @@ describe('Resource Type Guards', () => { 'pod', 'jail', ]; - const nonWorkloadTypes: ResourceType[] = ['agent', 'docker-host', 'storage', 'pbs']; + const nonWorkloadTypes: ResourceType[] = [ + 'agent', + 'docker-host', + 'network-endpoint', + 'storage', + 'pbs', + ]; it.each(workloadTypes)('returns true for %s', (type) => { const resource = createResource({ type }); @@ -92,7 +104,13 @@ describe('Resource Type Guards', () => { describe('isStorage', () => { const storageTypes: ResourceType[] = ['storage', 'datastore', 'pool', 'dataset']; - const nonStorageTypes: ResourceType[] = ['vm', 'agent', 'system-container', 'docker-host']; + const nonStorageTypes: ResourceType[] = [ + 'vm', + 'agent', + 'system-container', + 'docker-host', + 'network-endpoint', + ]; it.each(storageTypes)('returns true for %s', (type) => { const resource = createResource({ type }); diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index dfd1ab4d7..d4f1319db 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -32,7 +32,7 @@ export interface State { export interface ConnectedInfrastructureSurface { id: string; - kind: 'agent' | 'docker' | 'kubernetes' | 'proxmox' | 'pbs' | 'pmg' | 'truenas'; + kind: 'agent' | 'availability' | 'docker' | 'kubernetes' | 'proxmox' | 'pbs' | 'pmg' | 'truenas'; label: string; detail?: string; controlId?: string; diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index d5cc4756b..61c3b6502 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -46,11 +46,12 @@ export type ResourceType = | 'pbs' // Proxmox Backup Server | 'pmg' // Proxmox Mail Gateway | 'physical_disk' // Physical disk - | 'ceph'; // Ceph cluster + | 'ceph' // Ceph cluster + | 'network-endpoint'; // Agentless availability endpoint // Platform types - which system the resource comes from export const PLATFORM_TYPES = GENERATED_PLATFORM_TYPE_KEYS; -export type PlatformType = GeneratedPlatformType; +export type PlatformType = GeneratedPlatformType | 'generic'; // Source types - how data is collected export type SourceType = @@ -505,6 +506,25 @@ export interface ResourceVMwareMeta { snapshotCount?: number; } +export interface ResourceAvailabilityMeta { + targetId?: string; + name?: string; + address?: string; + protocol?: string; + port?: number; + path?: string; + enabled?: boolean; + available?: boolean; + lastChecked?: string; + lastSuccess?: string; + latencyMillis?: number; + consecutiveFailures?: number; + lastError?: string; + failureThreshold?: number; + pollIntervalSeconds?: number; + timeoutMillis?: number; +} + /** * The core unified Resource type. * This is what the frontend receives from WebSocket state.resources[]. @@ -578,6 +598,7 @@ export interface Resource { vmware?: ResourceVMwareMeta; proxmox?: ResourceProxmoxMeta; pbs?: ResourcePBSMeta; + availability?: ResourceAvailabilityMeta; physicalDisk?: ResourcePhysicalDiskMeta; storage?: ResourceStorageMeta; @@ -589,7 +610,7 @@ export interface Resource { * Helper type guards */ export function isInfrastructure(r: Resource): boolean { - return ['agent', 'docker-host', 'k8s-cluster', 'k8s-node'].includes(r.type); + return ['agent', 'docker-host', 'k8s-cluster', 'k8s-node', 'network-endpoint'].includes(r.type); } export function isWorkload(r: Resource): boolean { diff --git a/frontend-modern/src/utils/__tests__/agentCapabilityPresentation.test.ts b/frontend-modern/src/utils/__tests__/agentCapabilityPresentation.test.ts index 509419e35..d3a6b0da7 100644 --- a/frontend-modern/src/utils/__tests__/agentCapabilityPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/agentCapabilityPresentation.test.ts @@ -11,6 +11,7 @@ describe('agentCapabilityPresentation', () => { expect(getAgentCapabilityLabel('kubernetes')).toBe('Kubernetes'); expect(getAgentCapabilityLabel('proxmox')).toBe('Proxmox'); expect(getAgentCapabilityLabel('truenas')).toBe('TrueNAS'); + expect(getAgentCapabilityLabel('availability')).toBe('Availability'); }); it('uses canonical capability badge tones', () => { @@ -18,5 +19,6 @@ describe('agentCapabilityPresentation', () => { expect(getAgentCapabilityBadgeClass('kubernetes')).toContain('emerald-100'); expect(getAgentCapabilityBadgeClass('agent')).toContain('blue-100'); expect(getAgentCapabilityBadgeClass('truenas')).toContain('cyan-100'); + expect(getAgentCapabilityBadgeClass('availability')).toContain('sky-100'); }); }); diff --git a/frontend-modern/src/utils/__tests__/canonicalResourceTypes.test.ts b/frontend-modern/src/utils/__tests__/canonicalResourceTypes.test.ts index 63e5eda3a..c3d4a9bdf 100644 --- a/frontend-modern/src/utils/__tests__/canonicalResourceTypes.test.ts +++ b/frontend-modern/src/utils/__tests__/canonicalResourceTypes.test.ts @@ -11,6 +11,7 @@ describe('canonicalResourceTypes', () => { expect(CANONICAL_RESOURCE_TYPES).toContain('agent'); expect(CANONICAL_RESOURCE_TYPES).toContain('physical_disk'); expect(CANONICAL_RESOURCE_TYPES).toContain('ceph'); + expect(CANONICAL_RESOURCE_TYPES).toContain('network-endpoint'); }); it('normalizes manual input consistently', () => { @@ -21,6 +22,7 @@ describe('canonicalResourceTypes', () => { it('validates only canonical v6 resource types', () => { expect(isCanonicalResourceType('vm')).toBe(true); expect(isCanonicalResourceType('physical_disk')).toBe(true); + expect(isCanonicalResourceType('network-endpoint')).toBe(true); expect(isCanonicalResourceType('host')).toBe(false); expect(isCanonicalResourceType('lxc')).toBe(false); }); diff --git a/frontend-modern/src/utils/__tests__/infrastructureOnboardingPresentation.test.ts b/frontend-modern/src/utils/__tests__/infrastructureOnboardingPresentation.test.ts index f996f9cf3..7290d9879 100644 --- a/frontend-modern/src/utils/__tests__/infrastructureOnboardingPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/infrastructureOnboardingPresentation.test.ts @@ -60,15 +60,31 @@ describe('infrastructureOnboardingPresentation', () => { label: 'API first', summary: 'Platform API, agent optional', }); + expect(getInfrastructureSourceStrategyPresentation('probe')).toMatchObject({ + label: 'Availability probe', + summary: 'Agentless probe', + }); expect(getInfrastructureOnboardingProductPresentation('vmware').sourceStrategy).toBe('api'); + expect(getInfrastructureOnboardingProductPresentation('truenas').sourceStrategy).toBe('api'); expect(getInfrastructureOnboardingProductPresentation('pbs').sourceStrategy).toBe('api-agent'); + expect(getInfrastructureOnboardingProductPresentation('availability')).toMatchObject({ + sourceStrategy: 'probe', + primaryMode: 'api-backed', + canonicalProjections: ['network-endpoint'], + }); }); it('keeps supported API products separate from the admitted VMware path', () => { expect( getInfrastructureApiProductsByGovernanceState('supported').map((product) => product.label), - ).toEqual(['TrueNAS SCALE', 'Proxmox VE', 'Proxmox Backup Server', 'Proxmox Mail Gateway']); + ).toEqual([ + 'TrueNAS SCALE', + 'Proxmox VE', + 'Proxmox Backup Server', + 'Proxmox Mail Gateway', + 'Network endpoint', + ]); expect( getInfrastructureApiProductsByGovernanceState('admitted').map((product) => product.label), @@ -102,6 +118,11 @@ describe('infrastructureOnboardingPresentation', () => { label: 'Proxmox Mail Gateway', actionLabel: 'Add Proxmox Mail Gateway', }), + expect.objectContaining({ + type: 'availability', + label: 'Network endpoint', + actionLabel: 'Add Network endpoint', + }), expect.objectContaining({ type: 'agent', label: 'Standalone hosts', @@ -140,9 +161,12 @@ describe('infrastructureOnboardingPresentation', () => { { id: 'host-monitoring', label: 'Host monitoring', - description: 'Low-overhead machine telemetry and local service discovery.', - types: ['agent'], - products: [expect.objectContaining({ type: 'agent', label: 'Pulse Agent' })], + description: 'Low-overhead machine telemetry and simple availability checks.', + types: ['agent', 'availability'], + products: [ + expect.objectContaining({ type: 'agent', label: 'Pulse Agent' }), + expect.objectContaining({ type: 'availability', label: 'Network endpoint' }), + ], }, ]); @@ -160,6 +184,7 @@ describe('infrastructureOnboardingPresentation', () => { 'Proxmox VE', 'Proxmox Backup Server', 'Proxmox Mail Gateway', + 'Network endpoint', 'Pulse Agent hosts', 'Docker', 'Kubernetes', diff --git a/frontend-modern/src/utils/__tests__/reportingResourceTypes.test.ts b/frontend-modern/src/utils/__tests__/reportingResourceTypes.test.ts index 0a89f765f..ecf8f3f45 100644 --- a/frontend-modern/src/utils/__tests__/reportingResourceTypes.test.ts +++ b/frontend-modern/src/utils/__tests__/reportingResourceTypes.test.ts @@ -8,6 +8,7 @@ describe('toReportingResourceType', () => { expect(toReportingResourceType('system-container')).toBe('system-container'); expect(toReportingResourceType('app-container')).toBe('app-container'); expect(toReportingResourceType('docker-host')).toBe('docker-host'); + expect(toReportingResourceType('network-endpoint')).toBe('network-endpoint'); expect(toReportingResourceType('storage')).toBe('storage'); }); diff --git a/frontend-modern/src/utils/__tests__/resourceTypeCompat.test.ts b/frontend-modern/src/utils/__tests__/resourceTypeCompat.test.ts index bfaeb1954..97fa4c02f 100644 --- a/frontend-modern/src/utils/__tests__/resourceTypeCompat.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceTypeCompat.test.ts @@ -12,6 +12,8 @@ describe('resourceTypeCompat', () => { expect(canonicalizeFrontendResourceType('kubernetes-node')).toBe('k8s-node'); expect(canonicalizeFrontendResourceType('ceph')).toBe('ceph'); expect(canonicalizeFrontendResourceType('truenas')).toBe('agent'); + expect(canonicalizeFrontendResourceType('availability')).toBe('network-endpoint'); + expect(canonicalizeFrontendResourceType('network_endpoint')).toBe('network-endpoint'); }); it('does not silently canonicalize removed non-canonical workload aliases', () => { diff --git a/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts b/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts index 5a1774661..a9503c04f 100644 --- a/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts +++ b/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts @@ -149,6 +149,7 @@ describe('sourcePlatforms', () => { expect(resolvePlatformTypeFromSources(['agent', 'vmware'])).toBe('vmware-vsphere'); expect(resolvePlatformTypeFromSources(['agent', 'truenas'])).toBe('truenas'); expect(resolvePlatformTypeFromSources(['agent'])).toBe('agent'); + expect(resolvePlatformTypeFromSources(['availability'])).toBe('generic'); expect(resolvePlatformTypeFromSources(['custom-source'])).toBeUndefined(); }); }); @@ -160,6 +161,7 @@ describe('sourcePlatforms', () => { expect(resolveSourceTypeFromSources(['agent', 'proxmox'])).toBe('hybrid'); expect(resolveSourceTypeFromSources(['agent', 'vmware'])).toBe('hybrid'); expect(resolveSourceTypeFromSources(['agent', 'truenas'])).toBe('hybrid'); + expect(resolveSourceTypeFromSources(['availability'])).toBe('api'); expect(resolveSourceTypeFromSources(['custom-source'])).toBe('api'); }); }); diff --git a/frontend-modern/src/utils/agentCapabilityPresentation.ts b/frontend-modern/src/utils/agentCapabilityPresentation.ts index dcecdab44..c817cf284 100644 --- a/frontend-modern/src/utils/agentCapabilityPresentation.ts +++ b/frontend-modern/src/utils/agentCapabilityPresentation.ts @@ -5,7 +5,8 @@ export type AgentCapability = | 'proxmox' | 'pbs' | 'pmg' - | 'truenas'; + | 'truenas' + | 'availability'; export function getAgentCapabilityLabel(capability: AgentCapability): string { switch (capability) { @@ -23,6 +24,8 @@ export function getAgentCapabilityLabel(capability: AgentCapability): string { return 'PMG'; case 'truenas': return 'TrueNAS'; + case 'availability': + return 'Availability'; } } @@ -36,6 +39,8 @@ export function getAgentCapabilityBadgeClass(capability: AgentCapability): strin return 'bg-rose-100 text-rose-800 dark:bg-rose-900 dark:text-rose-300'; case 'truenas': return 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-300'; + case 'availability': + return 'bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-300'; case 'kubernetes': return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-300'; default: diff --git a/frontend-modern/src/utils/canonicalResourceTypes.ts b/frontend-modern/src/utils/canonicalResourceTypes.ts index 5f1a3d4b4..4370bb77e 100644 --- a/frontend-modern/src/utils/canonicalResourceTypes.ts +++ b/frontend-modern/src/utils/canonicalResourceTypes.ts @@ -22,6 +22,7 @@ export const CANONICAL_RESOURCE_TYPES = [ 'pmg', 'physical_disk', 'ceph', + 'network-endpoint', ] as const satisfies readonly ResourceType[]; export const INVALID_RESOURCE_TYPE_ERROR = `Invalid resource type. Valid types: ${CANONICAL_RESOURCE_TYPES.join(', ')}`; diff --git a/frontend-modern/src/utils/infrastructureOnboardingPresentation.ts b/frontend-modern/src/utils/infrastructureOnboardingPresentation.ts index 6d93f782c..967eaaf03 100644 --- a/frontend-modern/src/utils/infrastructureOnboardingPresentation.ts +++ b/frontend-modern/src/utils/infrastructureOnboardingPresentation.ts @@ -9,7 +9,7 @@ import { export type InfrastructureOnboardingConnectionType = Extract< ConnectionType, - 'agent' | 'pve' | 'pbs' | 'pmg' | 'truenas' | 'vmware' + 'agent' | 'availability' | 'pve' | 'pbs' | 'pmg' | 'truenas' | 'vmware' >; export interface InfrastructureOnboardingProductPresentation { @@ -47,10 +47,12 @@ interface BaseProductPresentation { sourceStrategy: InfrastructureSourceStrategy; autoDetect: boolean; sourcePlatformId?: string; + primaryMode?: PlatformPrimaryMode; + canonicalProjections?: readonly string[]; defaultSurfaceKeys: readonly string[]; } -export type InfrastructureSourceStrategy = 'api' | 'agent' | 'api-agent'; +export type InfrastructureSourceStrategy = 'api' | 'agent' | 'api-agent' | 'probe'; export interface InfrastructureSourceStrategyPresentation { label: string; @@ -90,6 +92,11 @@ const SOURCE_STRATEGY_PRESENTATION: Record< detail: 'Starts with platform API inventory and adds Pulse Agent only where node-local telemetry is needed.', }, + probe: { + label: 'Availability probe', + summary: 'Agentless probe', + detail: 'Uses ICMP, TCP, or HTTP checks for devices that cannot run Pulse Agent.', + }, }; const PRODUCT_PRESENTATION: Record< @@ -165,6 +172,17 @@ const PRODUCT_PRESENTATION: Record< sourcePlatformId: 'proxmox-pmg', defaultSurfaceKeys: ['mailStats', 'queues', 'quarantine', 'domainStats'], }, + availability: { + label: 'Network endpoint', + bestFor: 'Devices that expose ICMP, TCP, or HTTP but cannot run Pulse Agent', + coverage: 'Agentless availability checks and downtime alerts', + catalogDescription: 'Ping, TCP port, and HTTP availability checks', + sourceStrategy: 'probe', + autoDetect: false, + primaryMode: 'api-backed', + canonicalProjections: ['network-endpoint'], + defaultSurfaceKeys: ['availability'], + }, }; const governanceStateForType = ( @@ -186,6 +204,7 @@ const API_PRODUCT_ORDER: InfrastructureOnboardingConnectionType[] = [ 'pve', 'pbs', 'pmg', + 'availability', ]; const SOURCE_MANAGER_PRODUCT_ORDER: InfrastructureOnboardingConnectionType[] = [ @@ -266,8 +285,8 @@ const SOURCE_PICKER_GROUPS: InfrastructureSourcePickerGroupPresentation[] = [ { id: 'host-monitoring', label: 'Host monitoring', - description: 'Low-overhead machine telemetry and local service discovery.', - types: ['agent'], + description: 'Low-overhead machine telemetry and simple availability checks.', + types: ['agent', 'availability'], }, ]; @@ -275,13 +294,15 @@ export const getInfrastructureOnboardingProductPresentation = ( type: InfrastructureOnboardingConnectionType, ): InfrastructureOnboardingProductPresentation => { const manifestEntry = manifestEntryForType(type); + const presentation = PRODUCT_PRESENTATION[type]; return { type, - ...PRODUCT_PRESENTATION[type], + ...presentation, governanceState: manifestEntry?.governanceState ?? governanceStateForType(type), readinessStage: manifestEntry?.readinessStage ?? 'supported', - primaryMode: manifestEntry?.primaryMode ?? 'agent-backed', - canonicalProjections: manifestEntry?.canonicalProjections ?? ['agent'], + primaryMode: manifestEntry?.primaryMode ?? presentation.primaryMode ?? 'agent-backed', + canonicalProjections: manifestEntry?.canonicalProjections ?? + presentation.canonicalProjections ?? ['agent'], supportFloor: manifestEntry?.supportFloor ?? ({ @@ -358,7 +379,7 @@ export const getInfrastructureEmptyStateSummary = (): string => 'Choose an infrastructure source to start monitoring your environment.'; export const getInfrastructureEmptyStateDetail = (): string => - 'Supported source types include VMware vCenter, TrueNAS SCALE, Proxmox VE, Proxmox Backup Server, Proxmox Mail Gateway, and standalone hosts through Pulse Agent. Docker and Kubernetes are discovered from supported agent hosts.'; + 'Supported source types include VMware vCenter, TrueNAS SCALE, Proxmox VE, Proxmox Backup Server, Proxmox Mail Gateway, network endpoints, and standalone hosts through Pulse Agent. Docker and Kubernetes are discovered from supported agent hosts.'; export const getInfrastructureCoverageCompleteActionPresentation = (): InfrastructureCoverageCompleteActionPresentation => ({ diff --git a/frontend-modern/src/utils/reportingResourceTypes.ts b/frontend-modern/src/utils/reportingResourceTypes.ts index 547ef0364..d798549d3 100644 --- a/frontend-modern/src/utils/reportingResourceTypes.ts +++ b/frontend-modern/src/utils/reportingResourceTypes.ts @@ -12,6 +12,7 @@ export type ReportingResourceType = | 'datastore' | 'pool' | 'dataset' + | 'network-endpoint' | 'pbs' | 'pmg' | 'pod' diff --git a/frontend-modern/src/utils/resourceTypeCompat.ts b/frontend-modern/src/utils/resourceTypeCompat.ts index bba08551b..5352d7c30 100644 --- a/frontend-modern/src/utils/resourceTypeCompat.ts +++ b/frontend-modern/src/utils/resourceTypeCompat.ts @@ -14,7 +14,8 @@ export type CanonicalFrontendResourceType = | 'k8s-node' | 'k8s-cluster' | 'k8s-deployment' - | 'k8s-service'; + | 'k8s-service' + | 'network-endpoint'; const asNormalizedString = (value: unknown): string | undefined => { if (typeof value !== 'string') return undefined; @@ -33,6 +34,10 @@ export const canonicalizeFrontendResourceType = ( case 'hosts': case 'truenas': return 'agent'; + case 'availability': + case 'endpoint': + case 'network_endpoint': + return 'network-endpoint'; case 'docker': return 'app-container'; case 'dockerhost': @@ -67,6 +72,7 @@ export const canonicalizeFrontendResourceType = ( case 'k8s-cluster': case 'k8s-deployment': case 'k8s-service': + case 'network-endpoint': return normalized; default: return undefined; diff --git a/frontend-modern/src/utils/sourcePlatforms.ts b/frontend-modern/src/utils/sourcePlatforms.ts index a8dd7ef83..a40f949cf 100644 --- a/frontend-modern/src/utils/sourcePlatforms.ts +++ b/frontend-modern/src/utils/sourcePlatforms.ts @@ -129,6 +129,14 @@ export const readSourcePlatformFlags = (sources?: string[]): SourcePlatformFlags return flags; }; +const hasGenericSource = (sources?: string[]): boolean => + Boolean( + sources?.some((source) => { + const normalized = normalizeSourcePlatformKey(source) || source.toLowerCase(); + return normalized === 'availability' || normalized === 'generic'; + }), + ); + export const resolvePlatformTypeFromSources = (sources?: string[]): PlatformType | undefined => { const flags = readSourcePlatformFlags(sources); if (flags.hasProxmox) return 'proxmox-pve'; @@ -139,6 +147,7 @@ export const resolvePlatformTypeFromSources = (sources?: string[]): PlatformType if (flags.hasKubernetes) return 'kubernetes'; if (flags.hasDocker) return 'docker'; if (flags.hasAgent) return 'agent'; + if (hasGenericSource(sources)) return 'generic'; return undefined; }; @@ -151,7 +160,8 @@ export const resolveSourceTypeFromSources = (sources?: string[]): SourceType => flags.hasPbs || flags.hasPmg || flags.hasTrueNAS || - flags.hasVMware; + flags.hasVMware || + hasGenericSource(sources); if (flags.hasAgent && hasOther) return 'hybrid'; if (flags.hasAgent) return 'agent'; return 'api'; diff --git a/internal/alerts/unified_incidents.go b/internal/alerts/unified_incidents.go index baf8c09ec..688396db5 100644 --- a/internal/alerts/unified_incidents.go +++ b/internal/alerts/unified_incidents.go @@ -232,6 +232,8 @@ func resourceSupportsUnifiedIncidentAlerts(resource unifiedresources.Resource) b return len(resource.Incidents) > 0 case unifiedresources.ResourceTypePBS: return len(resource.Incidents) > 0 + case unifiedresources.ResourceTypeNetworkEndpoint: + return len(resource.Incidents) > 0 default: return false } @@ -324,6 +326,8 @@ func unifiedIncidentInstance(resource unifiedresources.Resource) string { return "PBS" case resource.VMware != nil: return "VMware" + case resource.Availability != nil: + return "Availability" case resource.Storage != nil && strings.TrimSpace(resource.Storage.Platform) != "": platform := strings.TrimSpace(resource.Storage.Platform) if platform == "" { diff --git a/internal/alerts/unified_incidents_test.go b/internal/alerts/unified_incidents_test.go index 64ecec03c..21d645b8b 100644 --- a/internal/alerts/unified_incidents_test.go +++ b/internal/alerts/unified_incidents_test.go @@ -73,6 +73,60 @@ func TestSyncUnifiedResourceIncidentsCreatesAndClearsAlerts(t *testing.T) { assertAlertMissing(t, m, alertID) } +func TestSyncUnifiedResourceIncidentsSupportsAvailabilityEndpoints(t *testing.T) { + m := newTestManager(t) + configureUnifiedEvalManager(t, m, unifiedEvalBaseConfig()) + + resource := unifiedresources.Resource{ + ID: "availability:energy-meter", + Type: unifiedresources.ResourceTypeNetworkEndpoint, + Name: "Energy meter", + Sources: []unifiedresources.DataSource{unifiedresources.SourceAvailability}, + Availability: &unifiedresources.AvailabilityData{ + TargetID: "energy-meter", + Address: "192.0.2.44", + Protocol: "icmp", + Enabled: true, + Available: false, + ConsecutiveFailures: 2, + FailureThreshold: 2, + }, + Incidents: []unifiedresources.ResourceIncident{{ + Provider: "availability", + NativeID: "energy-meter", + Code: "availability_unreachable", + Severity: storagehealth.RiskCritical, + Source: "availability", + Summary: "Energy meter is unreachable by ICMP probe", + }}, + } + + m.SyncUnifiedResourceIncidents([]unifiedresources.Resource{resource}) + + alertID := unifiedIncidentAlertID(resource, resource.Incidents[0]) + assertAlertPresent(t, m, alertID) + + m.mu.RLock() + alert := testRequireActiveAlert(t, m, alertID) + m.mu.RUnlock() + + if alert.Type != "resource-incident" { + t.Fatalf("alert type = %q, want resource-incident", alert.Type) + } + if alert.ResourceID != resource.ID { + t.Fatalf("resource id = %q, want %q", alert.ResourceID, resource.ID) + } + if alert.Instance != "Availability" { + t.Fatalf("instance = %q, want Availability", alert.Instance) + } + if got := alert.Metadata["incidentCategory"]; got != unifiedresources.IncidentCategoryAvailability { + t.Fatalf("incidentCategory = %v, want %q", got, unifiedresources.IncidentCategoryAvailability) + } + if got := alert.Metadata["incidentProvider"]; got != "availability" { + t.Fatalf("incidentProvider = %v, want availability", got) + } +} + func TestSyncUnifiedResourceIncidentsKeepsInstanceScopedNodeDisplayNames(t *testing.T) { m := newTestManager(t) configureUnifiedEvalManager(t, m, unifiedEvalBaseConfig()) diff --git a/internal/api/availability_handlers.go b/internal/api/availability_handlers.go new file mode 100644 index 000000000..d2e84fc2f --- /dev/null +++ b/internal/api/availability_handlers.go @@ -0,0 +1,294 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/mock" + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" +) + +const availabilityTargetsPathPrefix = "/api/availability-targets/" + +type AvailabilityHandlers struct { + getPersistence func(ctx context.Context) *config.ConfigPersistence + getMonitor func(ctx context.Context) *monitoring.Monitor +} + +type availabilityTargetResponse struct { + config.AvailabilityTarget + Status *monitoring.AvailabilityProbeStatus `json:"status,omitempty"` +} + +type availabilityTestResponse struct { + Success bool `json:"success"` + LatencyMillis int64 `json:"latencyMillis"` + Error string `json:"error,omitempty"` +} + +func NewAvailabilityHandlers( + getPersistence func(ctx context.Context) *config.ConfigPersistence, + getMonitor func(ctx context.Context) *monitoring.Monitor, +) *AvailabilityHandlers { + return &AvailabilityHandlers{ + getPersistence: getPersistence, + getMonitor: getMonitor, + } +} + +func (h *AvailabilityHandlers) HandleList(w http.ResponseWriter, r *http.Request) { + persistence := h.persistenceForRequest(w, r.Context()) + if persistence == nil { + return + } + targets, err := persistence.LoadAvailabilityTargets() + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "availability_load_failed", "Failed to load availability targets", map[string]string{"error": err.Error()}) + return + } + + statuses := map[string]monitoring.AvailabilityProbeStatus{} + if monitor := h.monitorForRequest(r.Context()); monitor != nil { + statuses = monitor.AvailabilityStatusSnapshot() + } + responses := make([]availabilityTargetResponse, 0, len(targets)) + for _, target := range targets { + response := availabilityTargetResponse{AvailabilityTarget: config.NormalizeAvailabilityTarget(target)} + if status, ok := statuses[target.ID]; ok { + statusCopy := status + response.Status = &statusCopy + } + responses = append(responses, response) + } + writeJSON(w, http.StatusOK, responses) +} + +func (h *AvailabilityHandlers) HandleAdd(w http.ResponseWriter, r *http.Request) { + if mock.IsMockEnabled() { + writeErrorResponse(w, http.StatusForbidden, "mock_mode_enabled", "Cannot modify connections in mock mode", nil) + return + } + target, ok := decodeAvailabilityTargetRequest(w, r, config.NewAvailabilityTarget()) + if !ok { + return + } + target = config.NormalizeAvailabilityTarget(target) + if err := target.Validate(); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil) + return + } + + persistence := h.persistenceForRequest(w, r.Context()) + if persistence == nil { + return + } + targets, err := persistence.LoadAvailabilityTargets() + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "availability_load_failed", "Failed to load availability targets", map[string]string{"error": err.Error()}) + return + } + for _, existing := range targets { + if strings.TrimSpace(existing.ID) == target.ID { + writeErrorResponse(w, http.StatusConflict, "availability_duplicate", "Availability target ID already exists", nil) + return + } + } + targets = append(targets, target) + if err := persistence.SaveAvailabilityTargets(targets); err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "availability_save_failed", "Failed to save availability targets", map[string]string{"error": err.Error()}) + return + } + h.refreshMonitor(r.Context()) + writeJSON(w, http.StatusCreated, target) +} + +func (h *AvailabilityHandlers) HandleUpdate(w http.ResponseWriter, r *http.Request) { + if mock.IsMockEnabled() { + writeErrorResponse(w, http.StatusForbidden, "mock_mode_enabled", "Cannot modify connections in mock mode", nil) + return + } + targetID, ok := availabilityTargetIDFromPath(r.URL.Path) + if !ok { + writeErrorResponse(w, http.StatusBadRequest, "missing_target_id", "Availability target ID is required", nil) + return + } + + persistence := h.persistenceForRequest(w, r.Context()) + if persistence == nil { + return + } + targets, err := persistence.LoadAvailabilityTargets() + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "availability_load_failed", "Failed to load availability targets", map[string]string{"error": err.Error()}) + return + } + index := -1 + for i := range targets { + if strings.TrimSpace(targets[i].ID) == targetID { + index = i + break + } + } + if index < 0 { + writeErrorResponse(w, http.StatusNotFound, "availability_not_found", "Availability target not found", nil) + return + } + + target, ok := decodeAvailabilityTargetRequest(w, r, targets[index]) + if !ok { + return + } + target.ID = targetID + target = config.NormalizeAvailabilityTarget(target) + if err := target.Validate(); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil) + return + } + targets[index] = target + if err := persistence.SaveAvailabilityTargets(targets); err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "availability_save_failed", "Failed to save availability targets", map[string]string{"error": err.Error()}) + return + } + h.refreshMonitor(r.Context()) + writeJSON(w, http.StatusOK, target) +} + +func (h *AvailabilityHandlers) HandleDelete(w http.ResponseWriter, r *http.Request) { + if mock.IsMockEnabled() { + writeErrorResponse(w, http.StatusForbidden, "mock_mode_enabled", "Cannot modify connections in mock mode", nil) + return + } + targetID, ok := availabilityTargetIDFromPath(r.URL.Path) + if !ok { + writeErrorResponse(w, http.StatusBadRequest, "missing_target_id", "Availability target ID is required", nil) + return + } + + persistence := h.persistenceForRequest(w, r.Context()) + if persistence == nil { + return + } + targets, err := persistence.LoadAvailabilityTargets() + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "availability_load_failed", "Failed to load availability targets", map[string]string{"error": err.Error()}) + return + } + index := -1 + for i := range targets { + if strings.TrimSpace(targets[i].ID) == targetID { + index = i + break + } + } + if index < 0 { + writeErrorResponse(w, http.StatusNotFound, "availability_not_found", "Availability target not found", nil) + return + } + targets = append(targets[:index], targets[index+1:]...) + if err := persistence.SaveAvailabilityTargets(targets); err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "availability_save_failed", "Failed to save availability targets", map[string]string{"error": err.Error()}) + return + } + h.refreshMonitor(r.Context()) + writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": targetID}) +} + +func (h *AvailabilityHandlers) HandleTestConnection(w http.ResponseWriter, r *http.Request) { + target, ok := decodeAvailabilityTargetRequest(w, r, config.NewAvailabilityTarget()) + if !ok { + return + } + h.testTarget(w, r, target) +} + +func (h *AvailabilityHandlers) HandleTestSavedConnection(w http.ResponseWriter, r *http.Request) { + targetID, ok := availabilityTargetIDFromPath(strings.TrimSuffix(r.URL.Path, "/test")) + if !ok { + writeErrorResponse(w, http.StatusBadRequest, "missing_target_id", "Availability target ID is required", nil) + return + } + persistence := h.persistenceForRequest(w, r.Context()) + if persistence == nil { + return + } + targets, err := persistence.LoadAvailabilityTargets() + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "availability_load_failed", "Failed to load availability targets", map[string]string{"error": err.Error()}) + return + } + for _, target := range targets { + if strings.TrimSpace(target.ID) == targetID { + h.testTarget(w, r, target) + return + } + } + writeErrorResponse(w, http.StatusNotFound, "availability_not_found", "Availability target not found", nil) +} + +func (h *AvailabilityHandlers) testTarget(w http.ResponseWriter, r *http.Request, target config.AvailabilityTarget) { + target = config.NormalizeAvailabilityTarget(target) + if err := target.Validate(); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil) + return + } + start := time.Now() + err := monitoring.ProbeAvailabilityTarget(r.Context(), target) + response := availabilityTestResponse{ + Success: err == nil, + LatencyMillis: time.Since(start).Milliseconds(), + } + if err != nil { + response.Error = err.Error() + } + writeJSON(w, http.StatusOK, response) +} + +func decodeAvailabilityTargetRequest(w http.ResponseWriter, r *http.Request, base config.AvailabilityTarget) (config.AvailabilityTarget, bool) { + r.Body = http.MaxBytesReader(w, r.Body, 16*1024) + defer r.Body.Close() + target := base + if err := json.NewDecoder(r.Body).Decode(&target); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid JSON body", nil) + return config.AvailabilityTarget{}, false + } + return target, true +} + +func availabilityTargetIDFromPath(path string) (string, bool) { + id := strings.TrimPrefix(path, availabilityTargetsPathPrefix) + id = strings.Trim(id, "/") + if id == "" || strings.Contains(id, "/") { + return "", false + } + return id, true +} + +func (h *AvailabilityHandlers) persistenceForRequest(w http.ResponseWriter, ctx context.Context) *config.ConfigPersistence { + if h == nil || h.getPersistence == nil { + writeErrorResponse(w, http.StatusInternalServerError, "availability_unavailable", "Availability target persistence is unavailable", nil) + return nil + } + persistence := h.getPersistence(ctx) + if persistence == nil { + writeErrorResponse(w, http.StatusInternalServerError, "availability_unavailable", "Availability target persistence is unavailable", nil) + return nil + } + return persistence +} + +func (h *AvailabilityHandlers) monitorForRequest(ctx context.Context) *monitoring.Monitor { + if h == nil || h.getMonitor == nil { + return nil + } + return h.getMonitor(ctx) +} + +func (h *AvailabilityHandlers) refreshMonitor(ctx context.Context) { + if monitor := h.monitorForRequest(ctx); monitor != nil { + monitor.RefreshAvailabilityTargets() + } +} diff --git a/internal/api/availability_handlers_test.go b/internal/api/availability_handlers_test.go new file mode 100644 index 000000000..fd96aac6f --- /dev/null +++ b/internal/api/availability_handlers_test.go @@ -0,0 +1,140 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +func TestAvailabilityHandlersCRUDPersistsTargets(t *testing.T) { + persistence := config.NewConfigPersistence(t.TempDir()) + handler := NewAvailabilityHandlers( + func(_ context.Context) *config.ConfigPersistence { return persistence }, + nil, + ) + + createBody := availabilityRequestBody(t, config.AvailabilityTarget{ + Name: "Energy monitor", + Address: "device.local", + Protocol: config.AvailabilityProbeICMP, + 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.ID == "" { + t.Fatal("created ID is empty") + } + + updated := created + updated.Enabled = false + updateBody := availabilityRequestBody(t, updated) + updateReq := httptest.NewRequest(http.MethodPut, "/api/availability-targets/"+created.ID, updateBody) + updateRec := httptest.NewRecorder() + handler.HandleUpdate(updateRec, updateReq) + if updateRec.Code != http.StatusOK { + t.Fatalf("HandleUpdate status = %d, body=%s", updateRec.Code, updateRec.Body.String()) + } + + loaded, err := persistence.LoadAvailabilityTargets() + if err != nil { + t.Fatalf("LoadAvailabilityTargets() error = %v", err) + } + if len(loaded) != 1 || loaded[0].Enabled { + t.Fatalf("loaded targets = %+v, want one paused target", loaded) + } + + listReq := httptest.NewRequest(http.MethodGet, "/api/availability-targets", nil) + listRec := httptest.NewRecorder() + handler.HandleList(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("HandleList status = %d, body=%s", listRec.Code, listRec.Body.String()) + } + + var listed []availabilityTargetResponse + if err := json.NewDecoder(listRec.Body).Decode(&listed); err != nil { + t.Fatalf("decode listed targets: %v", err) + } + if len(listed) != 1 || listed[0].ID != created.ID { + t.Fatalf("listed targets = %+v, want created target", listed) + } + + deleteReq := httptest.NewRequest(http.MethodDelete, "/api/availability-targets/"+created.ID, nil) + deleteRec := httptest.NewRecorder() + handler.HandleDelete(deleteRec, deleteReq) + if deleteRec.Code != http.StatusOK { + t.Fatalf("HandleDelete status = %d, body=%s", deleteRec.Code, deleteRec.Body.String()) + } + loaded, err = persistence.LoadAvailabilityTargets() + if err != nil { + t.Fatalf("LoadAvailabilityTargets() after delete error = %v", err) + } + if len(loaded) != 0 { + t.Fatalf("loaded targets after delete = %+v, want none", loaded) + } +} + +func TestAvailabilityHandlersTestSavedTarget(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + persistence := config.NewConfigPersistence(t.TempDir()) + target := config.NormalizeAvailabilityTarget(config.AvailabilityTarget{ + ID: "status-page", + Name: "Status page", + Address: server.URL, + Protocol: config.AvailabilityProbeHTTP, + Enabled: true, + TimeoutMillis: 1000, + }) + if err := persistence.SaveAvailabilityTargets([]config.AvailabilityTarget{target}); err != nil { + t.Fatalf("SaveAvailabilityTargets() error = %v", err) + } + + handler := NewAvailabilityHandlers( + func(_ context.Context) *config.ConfigPersistence { return persistence }, + nil, + ) + + req := httptest.NewRequest(http.MethodPost, "/api/availability-targets/status-page/test", nil) + rec := httptest.NewRecorder() + handler.HandleTestSavedConnection(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("HandleTestSavedConnection status = %d, body=%s", rec.Code, rec.Body.String()) + } + + var response availabilityTestResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("decode test response: %v", err) + } + if !response.Success { + t.Fatalf("response = %+v, want success", response) + } +} + +func availabilityRequestBody(t *testing.T, target config.AvailabilityTarget) *bytes.Reader { + t.Helper() + payload, err := json.Marshal(target) + if err != nil { + t.Fatalf("marshal target: %v", err) + } + return bytes.NewReader(payload) +} diff --git a/internal/api/connections_aggregator.go b/internal/api/connections_aggregator.go index c93a752d6..aa866c149 100644 --- a/internal/api/connections_aggregator.go +++ b/internal/api/connections_aggregator.go @@ -55,6 +55,8 @@ type aggregatorInputs struct { pmgInstances []config.PMGInstance vmwareInstances []config.VMwareVCenterInstance truenasInstances []config.TrueNASInstance + availabilityTargets []config.AvailabilityTarget + availabilityStatuses map[string]monitoring.AvailabilityProbeStatus hosts []models.Host instanceHealth map[string]monitoring.InstanceHealth expectedAgentVersion string @@ -72,7 +74,7 @@ func buildConnections(in aggregatorInputs) []Connection { out := make([]Connection, 0, len(in.pveInstances)+len(in.pbsInstances)+len(in.pmgInstances)+ - len(in.vmwareInstances)+len(in.truenasInstances)+len(in.hosts)) + len(in.vmwareInstances)+len(in.truenasInstances)+len(in.availabilityTargets)+len(in.hosts)) for _, pve := range in.pveInstances { out = append(out, buildPVEConnection(pve, in.instanceHealth, now)) @@ -89,6 +91,9 @@ func buildConnections(in aggregatorInputs) []Connection { for _, tn := range in.truenasInstances { out = append(out, buildTrueNASConnection(tn, in.instanceHealth, now)) } + for _, target := range in.availabilityTargets { + out = append(out, buildAvailabilityConnection(target, in.availabilityStatuses[target.ID], now)) + } for _, host := range in.hosts { out = append(out, buildAgentConnection(host, in.expectedAgentVersion, now)) } @@ -264,6 +269,27 @@ func buildTrueNASConnection(inst config.TrueNASInstance, health map[string]monit }) } +func buildAvailabilityConnection(target config.AvailabilityTarget, status monitoring.AvailabilityProbeStatus, now time.Time) Connection { + target = config.NormalizeAvailabilityTarget(target) + state, reason, lastSeen, lastError := deriveAvailabilityConnectionState(target, status, now) + return withFleetGovernance(Connection{ + ID: "availability:" + target.ID, + Type: ConnectionTypeAvailability, + Name: target.DisplayName(), + Address: target.Address, + HostAliases: appendNormalizedHosts(nil, target.Address, target.ProbeAddress()), + State: state, + StateReason: reason, + Enabled: target.Enabled, + Surfaces: []string{"availability"}, + Scope: map[string]bool{"availability": true}, + LastSeen: lastSeen, + LastError: lastError, + Source: ConnectionSourceManual, + Capabilities: ConnectionCapabilities{SupportsPause: true, SupportsScope: false, SupportsTest: true}, + }) +} + // buildAgentConnection derives a connection row from an agent Host record. // Agents have no pause toggle and no scope — reports are all-or-nothing — // so capability flags are off. @@ -513,6 +539,48 @@ func deriveConnectionState(enabled bool, h monitoring.InstanceHealth, now time.T return ConnectionStateActive, "", lastSeen, lastError } +func deriveAvailabilityConnectionState(target config.AvailabilityTarget, status monitoring.AvailabilityProbeStatus, now time.Time) (ConnectionState, string, *time.Time, *ConnectionError) { + var lastSeen *time.Time + if !status.LastSuccess.IsZero() { + t := status.LastSuccess + lastSeen = &t + } + + var lastError *ConnectionError + if strings.TrimSpace(status.LastError) != "" && !status.LastChecked.IsZero() { + lastError = &ConnectionError{ + At: status.LastChecked, + Message: status.LastError, + Category: "availability", + } + } + + if !target.Enabled { + return ConnectionStatePaused, "paused by user", lastSeen, lastError + } + if status.LastChecked.IsZero() { + return ConnectionStatePending, "awaiting first probe", nil, nil + } + if !status.Available { + threshold := target.EffectiveFailureThreshold() + reason := fmt.Sprintf("probe failed %d/%d times", status.ConsecutiveFailures, threshold) + if status.LastError != "" { + reason = status.LastError + } + return ConnectionStateUnreachable, reason, lastSeen, lastError + } + if lastSeen != nil { + staleThreshold := time.Duration(target.EffectivePollIntervalSecs()*2) * time.Second + if staleThreshold < connectionStaleThreshold { + staleThreshold = connectionStaleThreshold + } + if now.Sub(*lastSeen) > staleThreshold { + return ConnectionStateStale, fmt.Sprintf("no successful probe in %s", now.Sub(*lastSeen).Round(time.Second)), lastSeen, lastError + } + } + return ConnectionStateActive, "", lastSeen, nil +} + func sourceFromString(s string) ConnectionSource { switch strings.ToLower(strings.TrimSpace(s)) { case "agent": diff --git a/internal/api/connections_aggregator_test.go b/internal/api/connections_aggregator_test.go index 47921dba9..58cb12561 100644 --- a/internal/api/connections_aggregator_test.go +++ b/internal/api/connections_aggregator_test.go @@ -165,6 +165,59 @@ func TestBuildConnections_AgentStateFromLastSeen(t *testing.T) { } } +func TestBuildConnections_AvailabilityTargetStateFromProbeStatus(t *testing.T) { + checkedAt := time.Date(2026, 5, 6, 10, 0, 0, 0, time.UTC) + in := aggregatorInputs{ + availabilityTargets: []config.AvailabilityTarget{ + { + ID: "sensor-1", + Name: "Energy monitor", + Address: "192.0.2.10", + Protocol: config.AvailabilityProbeICMP, + Enabled: true, + FailureThreshold: 2, + }, + }, + availabilityStatuses: map[string]monitoring.AvailabilityProbeStatus{ + "sensor-1": { + TargetID: "sensor-1", + Available: false, + LastChecked: checkedAt, + ConsecutiveFailures: 1, + LastError: "timeout", + }, + }, + now: checkedAt.Add(5 * time.Second), + } + + got := buildConnections(in) + if len(got) != 1 { + t.Fatalf("expected 1 connection, got %d", len(got)) + } + connection := got[0] + if connection.ID != "availability:sensor-1" { + t.Fatalf("ID = %q, want availability:sensor-1", connection.ID) + } + if connection.Type != ConnectionTypeAvailability { + t.Fatalf("Type = %q, want availability", connection.Type) + } + if connection.State != ConnectionStateUnreachable { + t.Fatalf("State = %q, want unreachable", connection.State) + } + if connection.LastError == nil || connection.LastError.Category != "availability" { + t.Fatalf("LastError = %+v, want availability category", connection.LastError) + } + if !reflect.DeepEqual(connection.Surfaces, []string{"availability"}) { + t.Fatalf("Surfaces = %+v, want [availability]", connection.Surfaces) + } + if !connection.Capabilities.SupportsPause || connection.Capabilities.SupportsScope || !connection.Capabilities.SupportsTest { + t.Fatalf("Capabilities = %+v, want pause/test without scope", connection.Capabilities) + } + if !reflect.DeepEqual(connection.HostAliases, []string{"192.0.2.10"}) { + t.Fatalf("HostAliases = %+v, want endpoint address", connection.HostAliases) + } +} + func TestBuildConnections_AgentHostAliasesIncludeReportedIdentityHints(t *testing.T) { now := time.Now() in := aggregatorInputs{ diff --git a/internal/api/connections_grouping.go b/internal/api/connections_grouping.go index f5655763d..116d3b4e2 100644 --- a/internal/api/connections_grouping.go +++ b/internal/api/connections_grouping.go @@ -791,6 +791,8 @@ func connectionTypePriority(typ ConnectionType) int { return 3 case ConnectionTypeAgent: return 4 + case ConnectionTypeAvailability: + return 5 case ConnectionTypePBS: return 10 case ConnectionTypePMG: diff --git a/internal/api/connections_handlers.go b/internal/api/connections_handlers.go index 4d7ba4521..a1505c257 100644 --- a/internal/api/connections_handlers.go +++ b/internal/api/connections_handlers.go @@ -63,14 +63,19 @@ func (h *ConnectionsHandlers) HandleList(w http.ResponseWriter, r *http.Request) if tn, err := persistence.LoadTrueNASConfig(); err == nil { inputs.truenasInstances = tn } + if availability, err := persistence.LoadAvailabilityTargets(); err == nil { + inputs.availabilityTargets = availability + } } if monitor != nil { inputs.hosts = monitor.HostsSnapshot() inputs.instanceHealth = instanceHealthByKey(monitor.SchedulerHealth()) + inputs.availabilityStatuses = monitor.AvailabilityStatusSnapshot() } else { inputs.hosts = []models.Host{} inputs.instanceHealth = map[string]monitoring.InstanceHealth{} + inputs.availabilityStatuses = map[string]monitoring.AvailabilityProbeStatus{} } inputs.expectedAgentVersion = currentAgentTargetVersion() diff --git a/internal/api/connections_types.go b/internal/api/connections_types.go index 19f26d4a8..64745fd05 100644 --- a/internal/api/connections_types.go +++ b/internal/api/connections_types.go @@ -22,14 +22,15 @@ const ( type ConnectionType string const ( - ConnectionTypePVE ConnectionType = "pve" - ConnectionTypePBS ConnectionType = "pbs" - ConnectionTypePMG ConnectionType = "pmg" - ConnectionTypeVMware ConnectionType = "vmware" - ConnectionTypeTrueNAS ConnectionType = "truenas" - ConnectionTypeAgent ConnectionType = "agent" - ConnectionTypeDocker ConnectionType = "docker" - ConnectionTypeKubernetes ConnectionType = "kubernetes" + ConnectionTypePVE ConnectionType = "pve" + ConnectionTypePBS ConnectionType = "pbs" + ConnectionTypePMG ConnectionType = "pmg" + ConnectionTypeVMware ConnectionType = "vmware" + ConnectionTypeTrueNAS ConnectionType = "truenas" + ConnectionTypeAgent ConnectionType = "agent" + ConnectionTypeDocker ConnectionType = "docker" + ConnectionTypeKubernetes ConnectionType = "kubernetes" + ConnectionTypeAvailability ConnectionType = "availability" ) // ConnectionSource records how a connection entered Pulse. diff --git a/internal/api/monitored_system_ledger.go b/internal/api/monitored_system_ledger.go index 341b6182a..86fb78fa8 100644 --- a/internal/api/monitored_system_ledger.go +++ b/internal/api/monitored_system_ledger.go @@ -445,7 +445,7 @@ func normalizeMonitoredSystemLedgerReasonStatus(status string) string { func normalizeMonitoredSystemLedgerSource(source string) string { switch source { - case "agent", "docker", "kubernetes", "pbs", "pmg", "proxmox", "truenas", "vmware": + case "agent", "availability", "docker", "kubernetes", "pbs", "pmg", "proxmox", "truenas", "vmware": return source default: return "" diff --git a/internal/api/resources.go b/internal/api/resources.go index bed50f0ef..92d53f70d 100644 --- a/internal/api/resources.go +++ b/internal/api/resources.go @@ -1003,6 +1003,8 @@ func unifiedSeedSources(resources []unified.Resource) map[unified.DataSource]str sources[unified.SourceTrueNAS] = struct{}{} case resource.VMware != nil: sources[unified.SourceVMware] = struct{}{} + case resource.Availability != nil: + sources[unified.SourceAvailability] = struct{}{} } } return sources @@ -1800,6 +1802,8 @@ func resourceTypeFilterAdapter(token string) []unified.ResourceType { return []unified.ResourceType{unified.ResourceTypeCeph} case "physical_disk", "physical-disk", "physicaldisk", "disk": return []unified.ResourceType{unified.ResourceTypePhysicalDisk} + case "network-endpoint", "network-endpoints", "endpoint", "endpoints", "availability": + return []unified.ResourceType{unified.ResourceTypeNetworkEndpoint} default: return nil } @@ -1825,6 +1829,8 @@ func parseSources(raw string) map[unified.DataSource]struct{} { result[unified.SourceTrueNAS] = struct{}{} case "vmware", "vmware-vsphere": result[unified.SourceVMware] = struct{}{} + case "availability": + result[unified.SourceAvailability] = struct{}{} } } return result diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go index cd00ecd9e..c46ee0996 100644 --- a/internal/api/route_inventory_test.go +++ b/internal/api/route_inventory_test.go @@ -306,6 +306,9 @@ var bareRouteAllowlist = []string{ "/api/config/system", "/api/connections", "/api/connections/probe", + "/api/availability-targets", + "/api/availability-targets/test", + "/api/availability-targets/", "/api/truenas/connections", "/api/truenas/connections/preview", "/api/truenas/connections/test", @@ -442,6 +445,9 @@ var allRouteAllowlist = []string{ "/api/config/nodes/", "/api/connections", "/api/connections/probe", + "/api/availability-targets", + "/api/availability-targets/test", + "/api/availability-targets/", "/api/truenas/connections", "/api/truenas/connections/preview", "/api/truenas/connections/test", diff --git a/internal/api/router.go b/internal/api/router.go index dd4774b5b..9cdd3f317 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -74,6 +74,7 @@ type Router struct { trueNASHandlers *TrueNASHandlers vmwareHandlers *VMwareHandlers connectionsHandlers *ConnectionsHandlers + availabilityHandlers *AvailabilityHandlers notificationHandlers *NotificationHandlers notificationQueueHandlers *NotificationQueueHandlers dockerAgentHandlers *DockerAgentHandlers @@ -384,6 +385,10 @@ func (r *Router) setupRoutes() { r.configHandlers.getPersistence, r.configHandlers.getMonitor, ) + r.availabilityHandlers = NewAvailabilityHandlers( + r.configHandlers.getPersistence, + r.configHandlers.getMonitor, + ) recoveryManager := recoverymanager.New(r.multiTenant) r.recoveryHandlers = NewRecoveryHandlers(recoveryManager) if r.mtMonitor != nil { diff --git a/internal/api/router_routes_registration.go b/internal/api/router_routes_registration.go index f8b64ffa1..373d1e34d 100644 --- a/internal/api/router_routes_registration.go +++ b/internal/api/router_routes_registration.go @@ -202,6 +202,50 @@ func (r *Router) registerConfigSystemRoutes(updateHandlers *UpdateHandlers) { RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.connectionsHandlers.HandleProbe))(w, req) }) + // Agentless availability target management + r.mux.HandleFunc("/api/availability-targets", func(w http.ResponseWriter, req *http.Request) { + if r.availabilityHandlers == nil { + writeErrorResponse(w, http.StatusServiceUnavailable, "availability_unavailable", "Availability target service unavailable", nil) + return + } + switch req.Method { + case http.MethodGet: + RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.availabilityHandlers.HandleList))(w, req) + case http.MethodPost: + RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.availabilityHandlers.HandleAdd))(w, req) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + r.mux.HandleFunc("/api/availability-targets/test", func(w http.ResponseWriter, req *http.Request) { + if r.availabilityHandlers == nil { + writeErrorResponse(w, http.StatusServiceUnavailable, "availability_unavailable", "Availability target service unavailable", nil) + return + } + if req.Method == http.MethodPost { + RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.availabilityHandlers.HandleTestConnection))(w, req) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + r.mux.HandleFunc("/api/availability-targets/", func(w http.ResponseWriter, req *http.Request) { + if r.availabilityHandlers == nil { + writeErrorResponse(w, http.StatusServiceUnavailable, "availability_unavailable", "Availability target service unavailable", nil) + return + } + if req.Method == http.MethodPost && strings.HasSuffix(strings.Trim(req.URL.Path, "/"), "/test") { + RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.availabilityHandlers.HandleTestSavedConnection))(w, req) + } else if req.Method == http.MethodDelete { + RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.availabilityHandlers.HandleDelete))(w, req) + } else if req.Method == http.MethodPut { + RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.availabilityHandlers.HandleUpdate))(w, req) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + // TrueNAS connection management r.mux.HandleFunc("/api/truenas/connections", func(w http.ResponseWriter, req *http.Request) { if r.trueNASHandlers == nil { diff --git a/internal/config/availability.go b/internal/config/availability.go new file mode 100644 index 000000000..34c0b25a6 --- /dev/null +++ b/internal/config/availability.go @@ -0,0 +1,209 @@ +package config + +import ( + "fmt" + "net" + "net/url" + "strings" + + "github.com/google/uuid" +) + +const ( + DefaultAvailabilityPollIntervalSecs = 60 + DefaultAvailabilityTimeoutMillis = 2000 + DefaultAvailabilityFailureThreshold = 2 +) + +type AvailabilityProbeProtocol string + +const ( + AvailabilityProbeICMP AvailabilityProbeProtocol = "icmp" + AvailabilityProbeTCP AvailabilityProbeProtocol = "tcp" + AvailabilityProbeHTTP AvailabilityProbeProtocol = "http" +) + +// AvailabilityTarget represents an agentless endpoint monitored through a +// lightweight availability probe. +type AvailabilityTarget struct { + ID string `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + Protocol AvailabilityProbeProtocol `json:"protocol"` + Port int `json:"port,omitempty"` + Path string `json:"path,omitempty"` + Enabled bool `json:"enabled"` + PollIntervalSecs int `json:"pollIntervalSeconds,omitempty"` + TimeoutMillis int `json:"timeoutMillis,omitempty"` + FailureThreshold int `json:"failureThreshold,omitempty"` +} + +// NewAvailabilityTarget returns a new target with generated ID and defaults. +func NewAvailabilityTarget() AvailabilityTarget { + return AvailabilityTarget{ + ID: uuid.NewString(), + Protocol: AvailabilityProbeICMP, + Enabled: true, + PollIntervalSecs: DefaultAvailabilityPollIntervalSecs, + TimeoutMillis: DefaultAvailabilityTimeoutMillis, + FailureThreshold: DefaultAvailabilityFailureThreshold, + } +} + +func (t *AvailabilityTarget) ApplyDefaults() { + if t == nil { + return + } + if strings.TrimSpace(t.ID) == "" { + t.ID = uuid.NewString() + } + if strings.TrimSpace(string(t.Protocol)) == "" { + t.Protocol = AvailabilityProbeICMP + } + if t.PollIntervalSecs <= 0 { + t.PollIntervalSecs = DefaultAvailabilityPollIntervalSecs + } + if t.TimeoutMillis <= 0 { + t.TimeoutMillis = DefaultAvailabilityTimeoutMillis + } + if t.FailureThreshold <= 0 { + t.FailureThreshold = DefaultAvailabilityFailureThreshold + } +} + +func (t AvailabilityTarget) EffectivePollIntervalSecs() int { + if t.PollIntervalSecs > 0 { + return t.PollIntervalSecs + } + return DefaultAvailabilityPollIntervalSecs +} + +func (t AvailabilityTarget) EffectiveTimeoutMillis() int { + if t.TimeoutMillis > 0 { + return t.TimeoutMillis + } + return DefaultAvailabilityTimeoutMillis +} + +func (t AvailabilityTarget) EffectiveFailureThreshold() int { + if t.FailureThreshold > 0 { + return t.FailureThreshold + } + return DefaultAvailabilityFailureThreshold +} + +func (t AvailabilityTarget) DisplayName() string { + if name := strings.TrimSpace(t.Name); name != "" { + return name + } + return strings.TrimSpace(t.Address) +} + +func (t AvailabilityTarget) ProbeAddress() string { + return normalizeAvailabilityAddress(t.Address) +} + +func (t AvailabilityTarget) Validate() error { + if strings.TrimSpace(t.Address) == "" { + return fmt.Errorf("availability target address is required") + } + switch t.Protocol { + case AvailabilityProbeICMP: + if t.Port != 0 { + return fmt.Errorf("icmp availability targets must not set a port") + } + case AvailabilityProbeTCP: + if t.Port <= 0 || t.Port > 65535 { + return fmt.Errorf("tcp availability targets require a valid port") + } + case AvailabilityProbeHTTP: + if t.Port < 0 || t.Port > 65535 { + return fmt.Errorf("http availability target port must be valid") + } + default: + return fmt.Errorf("unsupported availability protocol %q", t.Protocol) + } + if t.PollIntervalSecs > 0 && t.PollIntervalSecs < 10 { + return fmt.Errorf("availability poll interval must be at least 10 seconds") + } + if t.TimeoutMillis > 0 && t.TimeoutMillis < 250 { + return fmt.Errorf("availability timeout must be at least 250 milliseconds") + } + if t.FailureThreshold > 0 && t.FailureThreshold > 10 { + return fmt.Errorf("availability failure threshold must be 10 or less") + } + if t.Protocol == AvailabilityProbeHTTP { + if _, err := t.HTTPURL(); err != nil { + return err + } + return nil + } + host := t.ProbeAddress() + if host == "" { + return fmt.Errorf("availability target address is required") + } + if strings.ContainsAny(host, " \t\r\n") { + return fmt.Errorf("availability target address must not contain whitespace") + } + return nil +} + +func (t AvailabilityTarget) HTTPURL() (*url.URL, error) { + raw := strings.TrimSpace(t.Address) + if raw == "" { + return nil, fmt.Errorf("availability target address is required") + } + if !strings.Contains(raw, "://") { + raw = "http://" + raw + } + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("invalid http availability address: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return nil, fmt.Errorf("http availability targets require http or https scheme") + } + if strings.TrimSpace(u.Hostname()) == "" { + return nil, fmt.Errorf("http availability target host is required") + } + if t.Port > 0 { + u.Host = net.JoinHostPort(u.Hostname(), fmt.Sprintf("%d", t.Port)) + } + if path := strings.TrimSpace(t.Path); path != "" { + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + u.Path = path + } + return u, nil +} + +func NormalizeAvailabilityTarget(target AvailabilityTarget) AvailabilityTarget { + target.ID = strings.TrimSpace(target.ID) + target.Name = strings.TrimSpace(target.Name) + target.Protocol = AvailabilityProbeProtocol(strings.ToLower(strings.TrimSpace(string(target.Protocol)))) + if target.Protocol == AvailabilityProbeHTTP { + target.Address = strings.TrimSpace(target.Address) + } else { + target.Address = normalizeAvailabilityAddress(target.Address) + } + target.Path = strings.TrimSpace(target.Path) + target.ApplyDefaults() + return target +} + +func normalizeAvailabilityAddress(raw string) string { + value := strings.TrimSpace(raw) + if value == "" { + return "" + } + if host, _, err := net.SplitHostPort(value); err == nil && strings.TrimSpace(host) != "" { + return strings.Trim(strings.TrimSpace(host), "[]") + } + if strings.Contains(value, "://") { + if u, err := url.Parse(value); err == nil && strings.TrimSpace(u.Hostname()) != "" { + return strings.TrimSpace(u.Hostname()) + } + } + return strings.Trim(value, "[]") +} diff --git a/internal/config/availability_test.go b/internal/config/availability_test.go new file mode 100644 index 000000000..dbb345b91 --- /dev/null +++ b/internal/config/availability_test.go @@ -0,0 +1,103 @@ +package config + +import "testing" + +func TestNormalizeAvailabilityTargetPreservesHTTPAddress(t *testing.T) { + target := NormalizeAvailabilityTarget(AvailabilityTarget{ + ID: " target-1 ", + Name: " Status page ", + Address: " https://device.local/status?ready=1 ", + Protocol: AvailabilityProbeHTTP, + Path: " health ", + Enabled: true, + }) + + if target.ID != "target-1" { + t.Fatalf("ID = %q, want target-1", target.ID) + } + if target.Name != "Status page" { + t.Fatalf("Name = %q, want Status page", target.Name) + } + if target.Address != "https://device.local/status?ready=1" { + t.Fatalf("Address = %q, want preserved HTTP URL", target.Address) + } + if target.Path != "health" { + t.Fatalf("Path = %q, want health", target.Path) + } +} + +func TestNormalizeAvailabilityTargetReducesICMPAddressToHost(t *testing.T) { + target := NormalizeAvailabilityTarget(AvailabilityTarget{ + Address: " https://device.local:8443/status ", + Protocol: AvailabilityProbeICMP, + Enabled: true, + }) + + if target.Address != "device.local" { + t.Fatalf("Address = %q, want device.local", target.Address) + } + if target.Port != 0 { + t.Fatalf("Port = %d, want 0", target.Port) + } +} + +func TestAvailabilityTargetHTTPURLAppliesPortAndPath(t *testing.T) { + target := NormalizeAvailabilityTarget(AvailabilityTarget{ + Address: "device.local/status", + Protocol: AvailabilityProbeHTTP, + Port: 8080, + Path: "health", + Enabled: true, + }) + + u, err := target.HTTPURL() + if err != nil { + t.Fatalf("HTTPURL() error = %v", err) + } + if got := u.String(); got != "http://device.local:8080/health" { + t.Fatalf("HTTPURL() = %q, want http://device.local:8080/health", got) + } +} + +func TestAvailabilityTargetValidateRejectsTCPWithoutPort(t *testing.T) { + target := NormalizeAvailabilityTarget(AvailabilityTarget{ + Address: "device.local", + Protocol: AvailabilityProbeTCP, + Enabled: true, + }) + + if err := target.Validate(); err == nil { + t.Fatal("Validate() error = nil, want TCP port error") + } +} + +func TestAvailabilityTargetsRoundTripThroughPersistence(t *testing.T) { + persistence := NewConfigPersistence(t.TempDir()) + targets := []AvailabilityTarget{ + { + ID: "endpoint-1", + Name: "Energy monitor", + Address: "device.local", + Protocol: AvailabilityProbeICMP, + Enabled: true, + }, + } + + if err := persistence.SaveAvailabilityTargets(targets); err != nil { + t.Fatalf("SaveAvailabilityTargets() error = %v", err) + } + + 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].Name != "Energy monitor" { + t.Fatalf("loaded name = %q, want Energy monitor", loaded[0].Name) + } + if loaded[0].PollIntervalSecs != DefaultAvailabilityPollIntervalSecs { + t.Fatalf("poll interval = %d, want default", loaded[0].PollIntervalSecs) + } +} diff --git a/internal/config/persistence.go b/internal/config/persistence.go index 7f03874ec..d6a31ee71 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -34,6 +34,7 @@ type ConfigPersistence struct { nodesFile string trueNASFile string vmwareFile string + availabilityFile string systemFile string ssoFile string apiTokensFile string @@ -97,6 +98,7 @@ type resolvedConfigPersistencePaths struct { nodesFile string trueNASFile string vmwareFile string + availabilityFile string systemFile string ssoFile string apiTokensFile string @@ -149,6 +151,10 @@ func resolveConfigPersistencePaths(configDir string) (string, resolvedConfigPers if err != nil { return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve vmware.enc: %w", err) } + availabilityFile, err := resolveLeaf("availability_targets.enc") + if err != nil { + return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve availability_targets.enc: %w", err) + } systemFile, err := resolveLeaf("system.json") if err != nil { return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve system.json: %w", err) @@ -206,6 +212,7 @@ func resolveConfigPersistencePaths(configDir string) (string, resolvedConfigPers nodesFile: nodesFile, trueNASFile: trueNASFile, vmwareFile: vmwareFile, + availabilityFile: availabilityFile, systemFile: systemFile, ssoFile: ssoFile, apiTokensFile: apiTokensFile, @@ -256,6 +263,7 @@ func newConfigPersistence(configDir string) (*ConfigPersistence, error) { nodesFile: resolvedPaths.nodesFile, trueNASFile: resolvedPaths.trueNASFile, vmwareFile: resolvedPaths.vmwareFile, + availabilityFile: resolvedPaths.availabilityFile, systemFile: resolvedPaths.systemFile, ssoFile: resolvedPaths.ssoFile, apiTokensFile: resolvedPaths.apiTokensFile, @@ -638,6 +646,29 @@ func (c *ConfigPersistence) LoadVMwareConfig() ([]VMwareVCenterInstance, error) return loadSlice[VMwareVCenterInstance](c, c.vmwareFile, true) } +// SaveAvailabilityTargets persists agentless availability target configuration +// to encrypted storage. +func (c *ConfigPersistence) SaveAvailabilityTargets(targets []AvailabilityTarget) error { + normalized := make([]AvailabilityTarget, 0, len(targets)) + for _, target := range targets { + normalized = append(normalized, NormalizeAvailabilityTarget(target)) + } + return saveJSON(c, c.availabilityFile, normalized, true) +} + +// LoadAvailabilityTargets loads agentless availability target configuration +// from encrypted storage. +func (c *ConfigPersistence) LoadAvailabilityTargets() ([]AvailabilityTarget, error) { + targets, err := loadSlice[AvailabilityTarget](c, c.availabilityFile, true) + if err != nil { + return nil, err + } + for i := range targets { + targets[i] = NormalizeAvailabilityTarget(targets[i]) + } + return targets, nil +} + // SaveAPITokens persists API token metadata to disk. func (c *ConfigPersistence) SaveAPITokens(tokens []APITokenRecord) error { c.mu.Lock() diff --git a/internal/monitoring/availability_poller.go b/internal/monitoring/availability_poller.go new file mode 100644 index 000000000..be32568ab --- /dev/null +++ b/internal/monitoring/availability_poller.go @@ -0,0 +1,537 @@ +package monitoring + +import ( + "context" + "fmt" + "net" + "net/http" + "os/exec" + "runtime" + "sort" + "strconv" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/storagehealth" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +// AvailabilityProbeStatus captures the last observed state of an agentless +// endpoint probe. +type AvailabilityProbeStatus struct { + TargetID string `json:"targetId"` + Name string `json:"name"` + Address string `json:"address"` + Protocol string `json:"protocol"` + Enabled bool `json:"enabled"` + Available bool `json:"available"` + LastChecked time.Time `json:"lastChecked,omitempty"` + LastSuccess time.Time `json:"lastSuccess,omitempty"` + LatencyMillis int64 `json:"latencyMillis,omitempty"` + ConsecutiveFailures int `json:"consecutiveFailures,omitempty"` + LastError string `json:"lastError,omitempty"` + FailureThreshold int `json:"failureThreshold,omitempty"` +} + +type availabilityPollProvider struct{} + +func newAvailabilityPollProvider() PollProvider { + return availabilityPollProvider{} +} + +func (availabilityPollProvider) Type() InstanceType { + return InstanceTypeAvailability +} + +func (availabilityPollProvider) ListInstances(m *Monitor) []string { + targets := m.availabilityTargets() + names := make([]string, 0, len(targets)) + for _, target := range targets { + if !target.Enabled { + continue + } + names = append(names, target.ID) + } + sort.Strings(names) + return names +} + +func (availabilityPollProvider) BaseInterval(m *Monitor) time.Duration { + targets := m.availabilityTargets() + minInterval := time.Duration(config.DefaultAvailabilityPollIntervalSecs) * time.Second + for _, target := range targets { + if !target.Enabled { + continue + } + interval := time.Duration(target.EffectivePollIntervalSecs()) * time.Second + if interval > 0 && interval < minInterval { + minInterval = interval + } + } + return clampInterval(minInterval, 10*time.Second, time.Hour) +} + +func (availabilityPollProvider) BuildPollTask(m *Monitor, instanceName string) (PollTask, error) { + target, ok := m.availabilityTargetByID(instanceName) + if !ok || !target.Enabled { + return PollTask{}, fmt.Errorf("availability target %q is not enabled", instanceName) + } + return PollTask{ + InstanceName: target.ID, + InstanceType: string(InstanceTypeAvailability), + Run: func(ctx context.Context) { + m.pollAvailabilityTarget(ctx, target) + }, + }, nil +} + +func (availabilityPollProvider) DescribeInstances(m *Monitor) []PollProviderInstanceInfo { + targets := m.availabilityTargets() + infos := make([]PollProviderInstanceInfo, 0, len(targets)) + for _, target := range targets { + if !target.Enabled { + continue + } + infos = append(infos, PollProviderInstanceInfo{ + Name: target.ID, + DisplayName: target.DisplayName(), + Connection: availabilityConnectionKey(target.ID), + Metadata: map[string]string{ + "address": target.Address, + "protocol": string(target.Protocol), + }, + }) + } + return infos +} + +func (availabilityPollProvider) ConnectionStatuses(m *Monitor) map[string]bool { + statuses := m.AvailabilityStatusSnapshot() + out := make(map[string]bool, len(statuses)) + for targetID, status := range statuses { + out[availabilityConnectionKey(targetID)] = status.Enabled && status.Available + } + return out +} + +func (availabilityPollProvider) ConnectionHealthKey(_ *Monitor, instanceName string) string { + return availabilityConnectionKey(instanceName) +} + +func (availabilityPollProvider) SupplementalSource() unifiedresources.DataSource { + return unifiedresources.SourceAvailability +} + +func (availabilityPollProvider) SupplementalRecords(m *Monitor, orgID string) []unifiedresources.IngestRecord { + targets := m.availabilityTargets() + statuses := m.AvailabilityStatusSnapshot() + records := make([]unifiedresources.IngestRecord, 0, len(targets)) + now := time.Now().UTC() + for _, target := range targets { + status := statuses[target.ID] + if status.TargetID == "" { + status = availabilityStatusFromTarget(target) + } + resource, identity := availabilityResourceFromTarget(target, status, orgID, now) + records = append(records, unifiedresources.IngestRecord{ + SourceID: target.ID, + Resource: resource, + Identity: identity, + }) + } + return records +} + +func (m *Monitor) availabilityTargets() []config.AvailabilityTarget { + if m == nil || m.configPersist == nil { + return nil + } + targets, err := m.configPersist.LoadAvailabilityTargets() + if err != nil { + return nil + } + out := make([]config.AvailabilityTarget, 0, len(targets)) + for _, target := range targets { + target = config.NormalizeAvailabilityTarget(target) + if strings.TrimSpace(target.ID) == "" { + continue + } + out = append(out, target) + } + sort.Slice(out, func(i, j int) bool { + left := strings.ToLower(out[i].DisplayName()) + right := strings.ToLower(out[j].DisplayName()) + if left == right { + return out[i].ID < out[j].ID + } + return left < right + }) + return out +} + +func (m *Monitor) availabilityTargetByID(id string) (config.AvailabilityTarget, bool) { + id = strings.TrimSpace(id) + if id == "" { + return config.AvailabilityTarget{}, false + } + for _, target := range m.availabilityTargets() { + if target.ID == id { + return target, true + } + } + return config.AvailabilityTarget{}, false +} + +func (m *Monitor) AvailabilityStatusSnapshot() map[string]AvailabilityProbeStatus { + if m == nil { + return nil + } + m.mu.RLock() + defer m.mu.RUnlock() + out := make(map[string]AvailabilityProbeStatus, len(m.availabilityStatuses)) + for id, status := range m.availabilityStatuses { + out[id] = status + } + return out +} + +func (m *Monitor) RefreshAvailabilityTargets() { + if m == nil { + return + } + targets := m.availabilityTargets() + activeIDs := make(map[string]struct{}, len(targets)) + now := time.Now() + for _, target := range targets { + activeIDs[target.ID] = struct{}{} + if m.taskQueue == nil { + continue + } + task := ScheduledTask{ + InstanceName: target.ID, + InstanceType: InstanceTypeAvailability, + NextRun: now, + Interval: clampInterval(time.Duration(target.EffectivePollIntervalSecs())*time.Second, 10*time.Second, time.Hour), + } + if target.Enabled { + m.taskQueue.Upsert(task) + } else { + m.taskQueue.Remove(InstanceTypeAvailability, target.ID) + m.removeProviderConnectionHealth(InstanceTypeAvailability, target.ID) + } + } + + removedIDs := make([]string, 0) + m.mu.Lock() + for id := range m.availabilityStatuses { + if _, ok := activeIDs[id]; !ok { + delete(m.availabilityStatuses, id) + removedIDs = append(removedIDs, id) + } + } + m.mu.Unlock() + for _, id := range removedIDs { + m.removeProviderConnectionHealth(InstanceTypeAvailability, id) + } + + m.refreshInstanceInfoCacheFromProviders() + m.updateResourceStore(m.GetState()) +} + +func (m *Monitor) pollAvailabilityTarget(ctx context.Context, target config.AvailabilityTarget) { + target = config.NormalizeAvailabilityTarget(target) + start := time.Now() + err := ProbeAvailabilityTarget(ctx, target) + latency := time.Since(start) + checkedAt := time.Now().UTC() + m.setAvailabilityStatus(target, checkedAt, latency, err) + + if err == nil { + if m.stalenessTracker != nil { + m.stalenessTracker.UpdateSuccess(InstanceTypeAvailability, target.ID, nil) + } + m.setProviderConnectionHealth(InstanceTypeAvailability, target.ID, true) + } else { + if m.stalenessTracker != nil { + m.stalenessTracker.UpdateSuccess(InstanceTypeAvailability, target.ID, []byte(err.Error())) + } + m.setProviderConnectionHealth(InstanceTypeAvailability, target.ID, false) + } + m.recordTaskResult(InstanceTypeAvailability, target.ID, nil) + m.updateResourceStore(m.GetState()) +} + +func (m *Monitor) setAvailabilityStatus(target config.AvailabilityTarget, checkedAt time.Time, latency time.Duration, probeErr error) { + if m == nil { + return + } + status := availabilityStatusFromTarget(target) + status.LastChecked = checkedAt + status.LatencyMillis = latency.Milliseconds() + if probeErr == nil { + status.Available = true + status.LastSuccess = checkedAt + } else { + status.Available = false + status.LastError = probeErr.Error() + } + + m.mu.Lock() + if m.availabilityStatuses == nil { + m.availabilityStatuses = make(map[string]AvailabilityProbeStatus) + } + if previous, ok := m.availabilityStatuses[target.ID]; ok { + status.LastSuccess = previous.LastSuccess + if probeErr == nil { + status.ConsecutiveFailures = 0 + status.LastError = "" + status.LastSuccess = checkedAt + } else { + status.ConsecutiveFailures = previous.ConsecutiveFailures + 1 + } + } else if probeErr != nil { + status.ConsecutiveFailures = 1 + } + m.availabilityStatuses[target.ID] = status + m.mu.Unlock() +} + +// ProbeAvailabilityTarget executes one agentless availability check. +func ProbeAvailabilityTarget(ctx context.Context, target config.AvailabilityTarget) error { + target = config.NormalizeAvailabilityTarget(target) + if err := target.Validate(); err != nil { + return err + } + + timeout := time.Duration(target.EffectiveTimeoutMillis()) * time.Millisecond + if timeout <= 0 { + timeout = time.Duration(config.DefaultAvailabilityTimeoutMillis) * time.Millisecond + } + probeCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + switch target.Protocol { + case config.AvailabilityProbeICMP: + return probeICMP(probeCtx, target) + case config.AvailabilityProbeTCP: + return probeTCP(probeCtx, target) + case config.AvailabilityProbeHTTP: + return probeHTTP(probeCtx, target, timeout) + default: + return fmt.Errorf("unsupported availability protocol %q", target.Protocol) + } +} + +func probeICMP(ctx context.Context, target config.AvailabilityTarget) error { + host := target.ProbeAddress() + if host == "" { + return fmt.Errorf("icmp availability target host is required") + } + args := pingArgs(host, target.EffectiveTimeoutMillis()) + cmd := exec.CommandContext(ctx, "ping", args...) + output, err := cmd.CombinedOutput() + if err == nil { + return nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + details := strings.TrimSpace(string(output)) + if details == "" { + return fmt.Errorf("icmp probe failed: %w", err) + } + if len(details) > 240 { + details = details[:240] + } + return fmt.Errorf("icmp probe failed: %s", details) +} + +func pingArgs(host string, timeoutMillis int) []string { + if timeoutMillis <= 0 { + timeoutMillis = config.DefaultAvailabilityTimeoutMillis + } + switch runtime.GOOS { + case "windows": + return []string{"-n", "1", "-w", strconv.Itoa(timeoutMillis), host} + case "darwin", "freebsd", "openbsd", "netbsd": + return []string{"-n", "-c", "1", "-W", strconv.Itoa(timeoutMillis), host} + default: + timeoutSeconds := (timeoutMillis + 999) / 1000 + if timeoutSeconds <= 0 { + timeoutSeconds = 1 + } + return []string{"-n", "-c", "1", "-W", strconv.Itoa(timeoutSeconds), host} + } +} + +func probeTCP(ctx context.Context, target config.AvailabilityTarget) error { + host := target.ProbeAddress() + if host == "" { + return fmt.Errorf("tcp availability target host is required") + } + dialer := net.Dialer{} + conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(target.Port))) + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + return fmt.Errorf("tcp probe failed: %w", err) + } + _ = conn.Close() + return nil +} + +func probeHTTP(ctx context.Context, target config.AvailabilityTarget, timeout time.Duration) error { + u, err := target.HTTPURL() + if err != nil { + return err + } + client := http.Client{Timeout: timeout} + req, err := http.NewRequestWithContext(ctx, http.MethodHead, u.String(), nil) + if err != nil { + return fmt.Errorf("build http availability request: %w", err) + } + req.Header.Set("User-Agent", "Pulse availability probe") + resp, err := client.Do(req) + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + return fmt.Errorf("http probe failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusMethodNotAllowed { + return probeHTTPGet(ctx, client, u.String()) + } + if resp.StatusCode >= http.StatusInternalServerError { + return fmt.Errorf("http probe returned %s", resp.Status) + } + return nil +} + +func probeHTTPGet(ctx context.Context, client http.Client, url string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("build http availability fallback request: %w", err) + } + req.Header.Set("User-Agent", "Pulse availability probe") + resp, err := client.Do(req) + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + return fmt.Errorf("http probe failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= http.StatusInternalServerError { + return fmt.Errorf("http probe returned %s", resp.Status) + } + return nil +} + +func availabilityStatusFromTarget(target config.AvailabilityTarget) AvailabilityProbeStatus { + return AvailabilityProbeStatus{ + TargetID: target.ID, + Name: target.DisplayName(), + Address: target.Address, + Protocol: string(target.Protocol), + Enabled: target.Enabled, + FailureThreshold: target.EffectiveFailureThreshold(), + } +} + +func availabilityResourceFromTarget(target config.AvailabilityTarget, status AvailabilityProbeStatus, _ string, now time.Time) (unifiedresources.Resource, unifiedresources.ResourceIdentity) { + lastSeen := status.LastChecked + if lastSeen.IsZero() { + lastSeen = now + } + resourceStatus := availabilityResourceStatus(target, status) + data := &unifiedresources.AvailabilityData{ + TargetID: target.ID, + Name: target.DisplayName(), + Address: target.Address, + Protocol: string(target.Protocol), + Port: target.Port, + Path: target.Path, + Enabled: target.Enabled, + Available: status.Available, + LastChecked: status.LastChecked, + LastSuccess: status.LastSuccess, + LatencyMillis: status.LatencyMillis, + ConsecutiveFailures: status.ConsecutiveFailures, + LastError: status.LastError, + FailureThreshold: target.EffectiveFailureThreshold(), + PollIntervalSeconds: target.EffectivePollIntervalSecs(), + TimeoutMillis: target.EffectiveTimeoutMillis(), + } + resource := unifiedresources.Resource{ + Type: unifiedresources.ResourceTypeNetworkEndpoint, + Technology: string(target.Protocol), + Name: target.DisplayName(), + Status: resourceStatus, + LastSeen: lastSeen, + UpdatedAt: now, + Sources: []unifiedresources.DataSource{unifiedresources.SourceAvailability}, + Tags: []string{"agentless"}, + Availability: data, + } + if incident := availabilityIncident(target, status, lastSeen); incident != nil { + resource.Incidents = []unifiedresources.ResourceIncident{*incident} + } + + identity := unifiedresources.ResourceIdentity{} + if ip := net.ParseIP(target.ProbeAddress()); ip != nil { + identity.IPAddresses = []string{ip.String()} + } else if host := target.ProbeAddress(); host != "" { + identity.Hostnames = []string{host} + } + return resource, identity +} + +func availabilityResourceStatus(target config.AvailabilityTarget, status AvailabilityProbeStatus) unifiedresources.ResourceStatus { + if !target.Enabled { + return unifiedresources.StatusUnknown + } + if status.LastChecked.IsZero() { + return unifiedresources.StatusUnknown + } + if status.Available { + return unifiedresources.StatusOnline + } + if status.ConsecutiveFailures >= target.EffectiveFailureThreshold() { + return unifiedresources.StatusOffline + } + return unifiedresources.StatusWarning +} + +func availabilityIncident(target config.AvailabilityTarget, status AvailabilityProbeStatus, startedAt time.Time) *unifiedresources.ResourceIncident { + if !target.Enabled || status.Available || status.LastChecked.IsZero() { + return nil + } + if status.ConsecutiveFailures < target.EffectiveFailureThreshold() { + return nil + } + summary := fmt.Sprintf("%s is unreachable by %s probe", target.DisplayName(), strings.ToUpper(string(target.Protocol))) + if status.LastError != "" { + summary = summary + ": " + status.LastError + } + return &unifiedresources.ResourceIncident{ + Provider: string(unifiedresources.SourceAvailability), + NativeID: target.ID, + Code: "availability_unreachable", + Severity: storagehealth.RiskCritical, + Source: string(unifiedresources.SourceAvailability), + Summary: summary, + StartedAt: startedAt, + } +} + +func availabilityConnectionKey(targetID string) string { + targetID = strings.TrimSpace(targetID) + if targetID == "" { + return "" + } + return "availability-" + targetID +} diff --git a/internal/monitoring/availability_poller_test.go b/internal/monitoring/availability_poller_test.go new file mode 100644 index 000000000..da8654e05 --- /dev/null +++ b/internal/monitoring/availability_poller_test.go @@ -0,0 +1,128 @@ +package monitoring + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +func TestProbeAvailabilityTargetHTTPFallsBackToGETWhenHeadNotAllowed(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodHead: + w.WriteHeader(http.StatusMethodNotAllowed) + case http.MethodGet: + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer server.Close() + + target := config.NormalizeAvailabilityTarget(config.AvailabilityTarget{ + Address: server.URL, + Protocol: config.AvailabilityProbeHTTP, + Enabled: true, + TimeoutMillis: 1000, + }) + + if err := ProbeAvailabilityTarget(context.Background(), target); err != nil { + t.Fatalf("ProbeAvailabilityTarget() error = %v", err) + } +} + +func TestProbeAvailabilityTargetHTTPTreatsServerErrorsAsUnavailable(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer server.Close() + + target := config.NormalizeAvailabilityTarget(config.AvailabilityTarget{ + Address: server.URL, + Protocol: config.AvailabilityProbeHTTP, + Enabled: true, + TimeoutMillis: 1000, + }) + + if err := ProbeAvailabilityTarget(context.Background(), target); err == nil { + t.Fatal("ProbeAvailabilityTarget() error = nil, want HTTP 5xx error") + } +} + +func TestAvailabilityPollProviderSupplementalRecordsProjectNetworkEndpointIncident(t *testing.T) { + persistence := config.NewConfigPersistence(t.TempDir()) + target := config.NormalizeAvailabilityTarget(config.AvailabilityTarget{ + ID: "sensor-1", + Name: "Energy monitor", + Address: "192.0.2.10", + Protocol: config.AvailabilityProbeICMP, + Enabled: true, + FailureThreshold: 2, + }) + if err := persistence.SaveAvailabilityTargets([]config.AvailabilityTarget{target}); err != nil { + t.Fatalf("SaveAvailabilityTargets() error = %v", err) + } + + checkedAt := time.Date(2026, 5, 6, 10, 0, 0, 0, time.UTC) + monitor := &Monitor{ + configPersist: persistence, + availabilityStatuses: map[string]AvailabilityProbeStatus{ + target.ID: { + TargetID: target.ID, + Name: target.DisplayName(), + Address: target.Address, + Protocol: string(target.Protocol), + Enabled: true, + Available: false, + LastChecked: checkedAt, + ConsecutiveFailures: 2, + LastError: "timeout", + FailureThreshold: 2, + }, + }, + } + + records := availabilityPollProvider{}.SupplementalRecords(monitor, "org-a") + if len(records) != 1 { + t.Fatalf("SupplementalRecords() length = %d, want 1", len(records)) + } + + resource := records[0].Resource + if resource.Type != unifiedresources.ResourceTypeNetworkEndpoint { + t.Fatalf("resource type = %q, want network-endpoint", resource.Type) + } + if resource.Status != unifiedresources.StatusOffline { + t.Fatalf("resource status = %q, want offline", resource.Status) + } + if resource.Availability == nil || resource.Availability.TargetID != target.ID { + t.Fatalf("availability payload = %+v, want target %q", resource.Availability, target.ID) + } + if len(resource.Incidents) != 1 || resource.Incidents[0].Code != "availability_unreachable" { + t.Fatalf("incidents = %+v, want availability_unreachable", resource.Incidents) + } + if len(records[0].Identity.IPAddresses) != 1 || records[0].Identity.IPAddresses[0] != "192.0.2.10" { + t.Fatalf("identity IPs = %+v, want 192.0.2.10", records[0].Identity.IPAddresses) + } +} + +func TestAvailabilityPollProviderListsOnlyEnabledTargets(t *testing.T) { + persistence := config.NewConfigPersistence(t.TempDir()) + targets := []config.AvailabilityTarget{ + {ID: "enabled", Name: "Enabled", Address: "enabled.local", Protocol: config.AvailabilityProbeICMP, Enabled: true}, + {ID: "paused", Name: "Paused", Address: "paused.local", Protocol: config.AvailabilityProbeICMP, Enabled: false}, + } + if err := persistence.SaveAvailabilityTargets(targets); err != nil { + t.Fatalf("SaveAvailabilityTargets() error = %v", err) + } + + monitor := &Monitor{configPersist: persistence} + got := availabilityPollProvider{}.ListInstances(monitor) + if len(got) != 1 || got[0] != "enabled" { + t.Fatalf("ListInstances() = %+v, want [enabled]", got) + } +} diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index 5a46cc21b..c366c56c1 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -193,6 +193,47 @@ func TestEscalationDeliveryDefersToCanonicalAlertSuppression(t *testing.T) { } } +func TestAvailabilityProviderStaysOnCanonicalMonitoringPath(t *testing.T) { + requiredSnippets := map[string][]string{ + "scheduler.go": { + `InstanceTypeAvailability InstanceType = "availability"`, + }, + "monitor.go": { + "availabilityStatuses map[string]AvailabilityProbeStatus", + "availabilityStatuses: make(map[string]AvailabilityProbeStatus)", + }, + "poll_providers.go": { + "_ = m.RegisterPollProvider(newAvailabilityPollProvider())", + "newAvailabilityPollProvider(),", + "case InstanceTypeAvailability:", + "return newAvailabilityPollProvider()", + }, + "availability_poller.go": { + "func newAvailabilityPollProvider() PollProvider {", + "func (availabilityPollProvider) SupplementalSource() unifiedresources.DataSource {", + "return unifiedresources.SourceAvailability", + "func (availabilityPollProvider) SupplementalRecords(m *Monitor, orgID string) []unifiedresources.IngestRecord {", + "Type: unifiedresources.ResourceTypeNetworkEndpoint,", + "Sources: []unifiedresources.DataSource{unifiedresources.SourceAvailability},", + "Availability: data,", + "m.recordTaskResult(InstanceTypeAvailability, target.ID, nil)", + }, + } + + for file, snippets := range requiredSnippets { + data, err := os.ReadFile(file) + if err != nil { + t.Fatalf("failed to read %s: %v", file, err) + } + source := string(data) + for _, snippet := range snippets { + if !strings.Contains(source, snippet) { + t.Fatalf("%s must contain %q", file, snippet) + } + } + } +} + func TestMonitoredSystemUsageReadinessGuardrailsRemainCanonical(t *testing.T) { requiredSnippets := map[string][]string{ "monitor.go": { diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 2d4244480..efea9c289 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -904,6 +904,7 @@ type Monitor struct { pveClients map[string]PVEClientInterface pbsClients map[string]*pbs.Client pmgClients map[string]*pmg.Client + availabilityStatuses map[string]AvailabilityProbeStatus pollProviders map[InstanceType]PollProvider pollMetrics *PollMetrics scheduler *AdaptiveScheduler @@ -1462,6 +1463,7 @@ func New(cfg *config.Config) (*Monitor, error) { pveClients: make(map[string]PVEClientInterface), pbsClients: make(map[string]*pbs.Client), pmgClients: make(map[string]*pmg.Client), + availabilityStatuses: make(map[string]AvailabilityProbeStatus), pollProviders: make(map[InstanceType]PollProvider), pollMetrics: getPollMetrics(), scheduler: scheduler, @@ -4946,6 +4948,9 @@ func monitorPlatformType(resource unifiedresources.Resource, resourceType string if resource.TrueNAS != nil { return "truenas" } + if resource.Availability != nil { + return "generic" + } switch resourceType { case "vm", "system-container", "storage", "pool": return "proxmox-pve" @@ -5020,6 +5025,15 @@ func monitorPlatformID(resource unifiedresources.Resource, resourceType string) if resource.PMG != nil && strings.TrimSpace(resource.PMG.Hostname) != "" { return strings.TrimSpace(resource.PMG.Hostname) } + case "network-endpoint": + if resource.Availability != nil { + if targetID := strings.TrimSpace(resource.Availability.TargetID); targetID != "" { + return targetID + } + if address := strings.TrimSpace(resource.Availability.Address); address != "" { + return address + } + } } return resource.ID } @@ -5212,6 +5226,9 @@ func monitorIdentity(resource unifiedresources.Resource, fallbackName string) *m if hostname == "" && resource.Proxmox != nil { hostname = strings.TrimSpace(resource.Proxmox.NodeName) } + if hostname == "" && resource.Availability != nil { + hostname = strings.TrimSpace(resource.Availability.Address) + } if hostname == "" { for _, candidate := range resource.Identity.Hostnames { if trimmed := strings.TrimSpace(candidate); trimmed != "" { @@ -5422,6 +5439,26 @@ func monitorPlatformData(resource unifiedresources.Resource, resourceType string "enabled": true, "active": resource.Status == unifiedresources.StatusOnline, } + case "network-endpoint": + if resource.Availability != nil { + payload = map[string]interface{}{ + "targetId": resource.Availability.TargetID, + "address": resource.Availability.Address, + "protocol": resource.Availability.Protocol, + "port": resource.Availability.Port, + "path": resource.Availability.Path, + "enabled": resource.Availability.Enabled, + "available": resource.Availability.Available, + "lastChecked": resource.Availability.LastChecked, + "lastSuccess": resource.Availability.LastSuccess, + "latencyMillis": resource.Availability.LatencyMillis, + "consecutiveFailures": resource.Availability.ConsecutiveFailures, + "lastError": resource.Availability.LastError, + "failureThreshold": resource.Availability.FailureThreshold, + "pollIntervalSeconds": resource.Availability.PollIntervalSeconds, + "timeoutMillis": resource.Availability.TimeoutMillis, + } + } } if payload == nil { diff --git a/internal/monitoring/monitor_polling_test.go b/internal/monitoring/monitor_polling_test.go index df49da06a..60cd09a84 100644 --- a/internal/monitoring/monitor_polling_test.go +++ b/internal/monitoring/monitor_polling_test.go @@ -1908,6 +1908,84 @@ func TestCustomPollProviderIntegration(t *testing.T) { } } +func TestAvailabilityPollProviderPublishesSupplementalNetworkEndpoints(t *testing.T) { + persistence := config.NewConfigPersistence(t.TempDir()) + target := config.NormalizeAvailabilityTarget(config.AvailabilityTarget{ + ID: "energy-meter", + Name: "Energy meter", + Address: "192.0.2.44", + Protocol: config.AvailabilityProbeICMP, + Enabled: true, + PollIntervalSecs: 30, + TimeoutMillis: 750, + FailureThreshold: 2, + }) + if err := persistence.SaveAvailabilityTargets([]config.AvailabilityTarget{target}); err != nil { + t.Fatalf("SaveAvailabilityTargets() error = %v", err) + } + + monitor := &Monitor{ + configPersist: persistence, + availabilityStatuses: map[string]AvailabilityProbeStatus{ + target.ID: { + TargetID: target.ID, + Name: target.Name, + Address: target.Address, + Protocol: string(target.Protocol), + Enabled: true, + Available: false, + LastChecked: time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC), + ConsecutiveFailures: 2, + FailureThreshold: 2, + LastError: "icmp probe failed", + }, + }, + } + + provider := monitor.getPollProvider(InstanceTypeAvailability) + if provider == nil { + t.Fatal("expected built-in availability poll provider") + } + if got := provider.Type(); got != InstanceTypeAvailability { + t.Fatalf("provider type = %q, want %q", got, InstanceTypeAvailability) + } + if got := provider.ListInstances(monitor); len(got) != 1 || got[0] != target.ID { + t.Fatalf("instances = %+v, want [%s]", got, target.ID) + } + + supplemental, ok := provider.(SupplementalRecordsPollProvider) + if !ok { + t.Fatalf("availability provider must publish supplemental records") + } + if source := supplemental.SupplementalSource(); source != unifiedresources.SourceAvailability { + t.Fatalf("supplemental source = %q, want %q", source, unifiedresources.SourceAvailability) + } + + records := supplemental.SupplementalRecords(monitor, "default") + if len(records) != 1 { + t.Fatalf("records len = %d, want 1", len(records)) + } + record := records[0] + if record.SourceID != target.ID { + t.Fatalf("record source id = %q, want %q", record.SourceID, target.ID) + } + if record.Resource.Type != unifiedresources.ResourceTypeNetworkEndpoint { + t.Fatalf("resource type = %q, want %q", record.Resource.Type, unifiedresources.ResourceTypeNetworkEndpoint) + } + if record.Resource.Availability == nil { + t.Fatal("expected availability metadata") + } + if record.Resource.Availability.TargetID != target.ID { + t.Fatalf("availability target id = %q, want %q", record.Resource.Availability.TargetID, target.ID) + } + if record.Resource.Status != unifiedresources.StatusOffline { + t.Fatalf("resource status = %q, want %q", record.Resource.Status, unifiedresources.StatusOffline) + } + if len(record.Resource.Incidents) != 1 || record.Resource.Incidents[0].Code != "availability_unreachable" { + t.Fatalf("expected availability incident, got %+v", record.Resource.Incidents) + } +} + func TestUpdateResourceStore_IngestsSupplementalRecords(t *testing.T) { store := &testSupplementalResourceStore{} provider := &testSupplementalPollProvider{ diff --git a/internal/monitoring/poll_providers.go b/internal/monitoring/poll_providers.go index 88efe6b52..92c36fedf 100644 --- a/internal/monitoring/poll_providers.go +++ b/internal/monitoring/poll_providers.go @@ -557,6 +557,7 @@ func (m *Monitor) registerBuiltInPollProviders() { _ = m.RegisterPollProvider(newPVEPollProvider()) _ = m.RegisterPollProvider(newPBSPollProvider()) _ = m.RegisterPollProvider(newPMGPollProvider()) + _ = m.RegisterPollProvider(newAvailabilityPollProvider()) } func (m *Monitor) pollProviderSnapshot() []PollProvider { @@ -619,6 +620,7 @@ func (m *Monitor) pollProviderSnapshotWithBuiltins() []PollProvider { newPVEPollProvider(), newPBSPollProvider(), newPMGPollProvider(), + newAvailabilityPollProvider(), } for _, provider := range builtins { if provider == nil { @@ -670,6 +672,8 @@ func (m *Monitor) getPollProvider(instanceType InstanceType) PollProvider { return newPBSPollProvider() case InstanceTypePMG: return newPMGPollProvider() + case InstanceTypeAvailability: + return newAvailabilityPollProvider() default: return nil } diff --git a/internal/monitoring/scheduler.go b/internal/monitoring/scheduler.go index 2dff23f05..d21833f26 100644 --- a/internal/monitoring/scheduler.go +++ b/internal/monitoring/scheduler.go @@ -14,9 +14,10 @@ import ( type InstanceType string const ( - InstanceTypePVE InstanceType = "pve" - InstanceTypePBS InstanceType = "pbs" - InstanceTypePMG InstanceType = "pmg" + InstanceTypePVE InstanceType = "pve" + InstanceTypePBS InstanceType = "pbs" + InstanceTypePMG InstanceType = "pmg" + InstanceTypeAvailability InstanceType = "availability" ) // StalenessSource provides normalized freshness hints for an instance. diff --git a/internal/unifiedresources/canonical_identity.go b/internal/unifiedresources/canonical_identity.go index 4c97adc02..3a1c14cdb 100644 --- a/internal/unifiedresources/canonical_identity.go +++ b/internal/unifiedresources/canonical_identity.go @@ -58,6 +58,9 @@ func canonicalPrimaryID(resource Resource) string { if identity := canonicalVMwarePrimaryID(resource); identity != "" { return identity } + if targetID := strings.TrimSpace(canonicalAvailabilityTargetID(resource)); targetID != "" { + return "availability:" + targetID + } return strings.TrimSpace(resource.ID) } @@ -85,6 +88,8 @@ func canonicalAliases(resource Resource, primaryID, platformID, hostname string) canonicalPMGInstanceID(resource), canonicalVMwareManagedObjectID(resource), canonicalVMwareHostUUID(resource), + canonicalAvailabilityTargetID(resource), + canonicalAvailabilityAddress(resource), platformID, hostname, strings.TrimSpace(resource.Identity.MachineID), @@ -102,6 +107,7 @@ func canonicalPlatformID(resource Resource) string { canonicalPBSHostname(resource), canonicalPMGHostname(resource), canonicalTrueNASHostname(resource), + canonicalAvailabilityAddress(resource), canonicalKubernetesPlatformID(resource), resource.Name, resource.ID, @@ -116,6 +122,7 @@ func canonicalHostname(resource Resource) string { canonicalPBSHostname(resource), canonicalPMGHostname(resource), canonicalTrueNASHostname(resource), + canonicalAvailabilityAddress(resource), ) } @@ -161,6 +168,20 @@ func canonicalTrueNASHostname(resource Resource) string { return strings.TrimSpace(resource.TrueNAS.Hostname) } +func canonicalAvailabilityTargetID(resource Resource) string { + if resource.Availability == nil { + return "" + } + return strings.TrimSpace(resource.Availability.TargetID) +} + +func canonicalAvailabilityAddress(resource Resource) string { + if resource.Availability == nil { + return "" + } + return strings.TrimSpace(resource.Availability.Address) +} + func canonicalKubernetesPlatformID(resource Resource) string { if resource.Kubernetes == nil { return "" diff --git a/internal/unifiedresources/canonical_identity_test.go b/internal/unifiedresources/canonical_identity_test.go index d67ef8935..743769218 100644 --- a/internal/unifiedresources/canonical_identity_test.go +++ b/internal/unifiedresources/canonical_identity_test.go @@ -100,6 +100,51 @@ func TestRefreshCanonicalIdentityFallsBackWithoutTargets(t *testing.T) { } } +func TestRefreshCanonicalIdentityUsesAvailabilityTargetIdentity(t *testing.T) { + resource := Resource{ + ID: "availability:energy-meter", + Type: ResourceTypeNetworkEndpoint, + Name: "Energy meter", + Availability: &AvailabilityData{ + TargetID: "energy-meter", + Address: "192.0.2.44", + Protocol: "icmp", + }, + } + + RefreshCanonicalIdentity(&resource) + + if resource.Canonical == nil { + t.Fatalf("expected canonical identity") + } + if got := resource.Canonical.DisplayName; got != "Energy meter" { + t.Fatalf("displayName = %q, want Energy meter", got) + } + if got := resource.Canonical.Hostname; got != "192.0.2.44" { + t.Fatalf("hostname = %q, want 192.0.2.44", got) + } + if got := resource.Canonical.PlatformID; got != "192.0.2.44" { + t.Fatalf("platformId = %q, want 192.0.2.44", got) + } + if got := resource.Canonical.PrimaryID; got != "availability:energy-meter" { + t.Fatalf("primaryId = %q, want availability:energy-meter", got) + } + + wantAliases := []string{ + "availability:energy-meter", + "energy-meter", + "192.0.2.44", + } + if len(resource.Canonical.Aliases) != len(wantAliases) { + t.Fatalf("aliases len = %d, want %d (%v)", len(resource.Canonical.Aliases), len(wantAliases), resource.Canonical.Aliases) + } + for i, want := range wantAliases { + if got := resource.Canonical.Aliases[i]; got != want { + t.Fatalf("alias[%d] = %q, want %q", i, got, want) + } + } +} + func TestRefreshCanonicalIdentityPrefersProxmoxNodePrimaryIDForAgentResources(t *testing.T) { resource := Resource{ ID: "agent-1", diff --git a/internal/unifiedresources/clone.go b/internal/unifiedresources/clone.go index 8b74dad12..1fd3c329f 100644 --- a/internal/unifiedresources/clone.go +++ b/internal/unifiedresources/clone.go @@ -41,6 +41,7 @@ func cloneResource(in *Resource) Resource { out.Ceph = cloneCephMeta(in.Ceph) out.TrueNAS = cloneTrueNASData(in.TrueNAS) out.VMware = cloneVMwareData(in.VMware) + out.Availability = cloneAvailabilityData(in.Availability) out.FacetCounts = resourceFacetCounts(out) RefreshCanonicalMetadata(&out) return out @@ -245,6 +246,14 @@ func clonePMGData(in *PMGData) *PMGData { return &out } +func cloneAvailabilityData(in *AvailabilityData) *AvailabilityData { + if in == nil { + return nil + } + out := *in + return &out +} + func cloneVMwareData(in *VMwareData) *VMwareData { if in == nil { return nil diff --git a/internal/unifiedresources/incident_categories.go b/internal/unifiedresources/incident_categories.go index db6e6a62d..47ddbf6a0 100644 --- a/internal/unifiedresources/incident_categories.go +++ b/internal/unifiedresources/incident_categories.go @@ -32,8 +32,13 @@ func IncidentCategoryForResource(resource *Resource, incident ResourceIncident) return IncidentCategoryRecoverability case "disk_failed", "disk_unavailable", "disk_smart_failed", "disk_wearout", "disk_health": return IncidentCategoryDiskHealth + case "availability_unreachable": + return IncidentCategoryAvailability } + if resource.Type == ResourceTypeNetworkEndpoint || resource.Availability != nil { + return IncidentCategoryAvailability + } if resource.Type == ResourceTypePhysicalDisk { return IncidentCategoryDiskHealth } diff --git a/internal/unifiedresources/registry.go b/internal/unifiedresources/registry.go index 43ec178b3..3446e0756 100644 --- a/internal/unifiedresources/registry.go +++ b/internal/unifiedresources/registry.go @@ -17,14 +17,15 @@ import ( const autoMergeThreshold = 0.85 var defaultStaleThresholds = map[DataSource]time.Duration{ - SourceProxmox: 60 * time.Second, - SourceAgent: 60 * time.Second, - SourceDocker: 120 * time.Second, - SourcePBS: 120 * time.Second, - SourcePMG: 120 * time.Second, - SourceK8s: 120 * time.Second, - SourceTrueNAS: 120 * time.Second, - SourceVMware: 120 * time.Second, + SourceProxmox: 60 * time.Second, + SourceAgent: 60 * time.Second, + SourceDocker: 120 * time.Second, + SourcePBS: 120 * time.Second, + SourcePMG: 120 * time.Second, + SourceK8s: 120 * time.Second, + SourceTrueNAS: 120 * time.Second, + SourceVMware: 120 * time.Second, + SourceAvailability: 120 * time.Second, } // IngestRecord is a source-native resource entry normalized for registry ingestion. @@ -86,6 +87,7 @@ func NewRegistry(store ResourceStore) *ResourceRegistry { rr.bySource[SourceK8s] = make(map[string]string) rr.bySource[SourceTrueNAS] = make(map[string]string) rr.bySource[SourceVMware] = make(map[string]string) + rr.bySource[SourceAvailability] = make(map[string]string) rr.loadOverrides() return rr @@ -483,6 +485,10 @@ func (rr *ResourceRegistry) seedSourceIDForResourceLocked(resource *Resource, so } case SourceVMware: return seededVMwareSourceID(resource) + case SourceAvailability: + if resource.Availability != nil { + return strings.TrimSpace(resource.Availability.TargetID) + } } return "" @@ -1269,6 +1275,8 @@ func (rr *ResourceRegistry) mergeInto(existing *Resource, incoming Resource, sou existing.PMG = incoming.PMG case SourceVMware: existing.VMware = mergeVMwareData(existing.VMware, incoming.VMware) + case SourceAvailability: + existing.Availability = incoming.Availability } existing.Sources = addSource(existing.Sources, source) @@ -2121,6 +2129,9 @@ func chooseStatus(existing ResourceStatus, incoming ResourceStatus, source DataS if existing == "" || existing == StatusUnknown { return incoming } + if source == SourceAvailability { + return incoming + } if sourcePriority(source) >= sourcePriority(SourceAgent) { return incoming } diff --git a/internal/unifiedresources/registry_test.go b/internal/unifiedresources/registry_test.go index 5d28af918..d1feeb8a2 100644 --- a/internal/unifiedresources/registry_test.go +++ b/internal/unifiedresources/registry_test.go @@ -101,6 +101,63 @@ func TestResourceRegistry_ListByType_Empty(t *testing.T) { } } +func TestResourceRegistry_IngestRecordsPreservesAvailabilityEndpoints(t *testing.T) { + rr := NewRegistry(nil) + now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC) + + rr.IngestRecords(SourceAvailability, []IngestRecord{ + { + SourceID: "energy-meter", + Resource: Resource{ + Type: ResourceTypeNetworkEndpoint, + Name: "Energy meter", + Status: StatusOffline, + LastSeen: now, + Sources: []DataSource{SourceAvailability}, + Availability: &AvailabilityData{ + TargetID: "energy-meter", + Address: "192.0.2.44", + Protocol: "icmp", + Enabled: true, + Available: false, + ConsecutiveFailures: 2, + FailureThreshold: 2, + }, + Incidents: []ResourceIncident{{ + Provider: "availability", + NativeID: "energy-meter", + Code: "availability_unreachable", + Severity: storagehealth.RiskCritical, + Source: "availability", + Summary: "Energy meter is unreachable by ICMP probe", + }}, + }, + Identity: ResourceIdentity{IPAddresses: []string{"192.0.2.44"}}, + }, + }) + + got := rr.ListByType(ResourceTypeNetworkEndpoint) + if len(got) != 1 { + t.Fatalf("expected 1 network endpoint, got %d", len(got)) + } + resource := got[0] + if resource.Availability == nil { + t.Fatal("expected availability metadata") + } + if resource.Availability.TargetID != "energy-meter" { + t.Fatalf("target id = %q, want energy-meter", resource.Availability.TargetID) + } + if resource.Status != StatusOffline { + t.Fatalf("status = %q, want %q", resource.Status, StatusOffline) + } + if len(resource.Incidents) != 1 || resource.Incidents[0].Code != "availability_unreachable" { + t.Fatalf("expected availability incident, got %+v", resource.Incidents) + } + if resource.Canonical == nil || resource.Canonical.PrimaryID != "availability:energy-meter" { + t.Fatalf("canonical identity = %+v, want primary availability:energy-meter", resource.Canonical) + } +} + func TestResourceRegistry_ListUsesDeterministicNameTieBreakers(t *testing.T) { rr := NewRegistry(nil) now := time.Date(2026, 4, 11, 0, 0, 0, 0, time.UTC) diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index 3bd548da2..95c9fb33f 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -65,6 +65,7 @@ type Resource struct { Ceph *CephMeta `json:"ceph,omitempty"` TrueNAS *TrueNASData `json:"truenas,omitempty"` VMware *VMwareData `json:"vmware,omitempty"` + Availability *AvailabilityData `json:"availability,omitempty"` } // ResourceFacetCounts captures the total count of each resource facet that @@ -122,6 +123,7 @@ const ( ResourceTypePMG ResourceType = "pmg" ResourceTypeCeph ResourceType = "ceph" ResourceTypePhysicalDisk ResourceType = "physical_disk" + ResourceTypeNetworkEndpoint ResourceType = "network-endpoint" ) // CanonicalResourceType normalizes resource type spellings into the internal @@ -166,14 +168,15 @@ const ( type DataSource string const ( - SourceProxmox DataSource = "proxmox" - SourceAgent DataSource = "agent" - SourceDocker DataSource = "docker" - SourcePBS DataSource = "pbs" - SourcePMG DataSource = "pmg" - SourceK8s DataSource = "kubernetes" - SourceTrueNAS DataSource = "truenas" - SourceVMware DataSource = "vmware" + SourceProxmox DataSource = "proxmox" + SourceAgent DataSource = "agent" + SourceDocker DataSource = "docker" + SourcePBS DataSource = "pbs" + SourcePMG DataSource = "pmg" + SourceK8s DataSource = "kubernetes" + SourceTrueNAS DataSource = "truenas" + SourceVMware DataSource = "vmware" + SourceAvailability DataSource = "availability" ) // SourceStatus describes the freshness of data from a source. @@ -1011,6 +1014,26 @@ type TrueNASData struct { RebuildSummary string `json:"rebuildSummary,omitempty"` } +// AvailabilityData contains agentless endpoint probe metadata for a resource. +type AvailabilityData struct { + TargetID string `json:"targetId,omitempty"` + Name string `json:"name,omitempty"` + Address string `json:"address,omitempty"` + Protocol string `json:"protocol,omitempty"` + Port int `json:"port,omitempty"` + Path string `json:"path,omitempty"` + Enabled bool `json:"enabled"` + Available bool `json:"available"` + LastChecked time.Time `json:"lastChecked,omitempty"` + LastSuccess time.Time `json:"lastSuccess,omitempty"` + LatencyMillis int64 `json:"latencyMillis,omitempty"` + ConsecutiveFailures int `json:"consecutiveFailures,omitempty"` + LastError string `json:"lastError,omitempty"` + FailureThreshold int `json:"failureThreshold,omitempty"` + PollIntervalSeconds int `json:"pollIntervalSeconds,omitempty"` + TimeoutMillis int `json:"timeoutMillis,omitempty"` +} + // K8sMetricCapabilities describes which Kubernetes metric families are available // for this cluster right now based on active collection paths. type K8sMetricCapabilities struct { diff --git a/pkg/reporting/engine.go b/pkg/reporting/engine.go index 0b65ccbce..868939877 100644 --- a/pkg/reporting/engine.go +++ b/pkg/reporting/engine.go @@ -87,6 +87,8 @@ func CanonicalResourceType(resourceType string) string { return "pool" case "dataset": return "dataset" + case "network-endpoint": + return "network-endpoint" default: return "" } diff --git a/pkg/reporting/engine_test.go b/pkg/reporting/engine_test.go index 8e6a23adc..ce8675384 100644 --- a/pkg/reporting/engine_test.go +++ b/pkg/reporting/engine_test.go @@ -48,6 +48,12 @@ func TestCSVGenerator_Generate(t *testing.T) { } } +func TestCanonicalResourceTypeIncludesAgentlessAvailabilityEndpoints(t *testing.T) { + if got := CanonicalResourceType("network-endpoint"); got != "network-endpoint" { + t.Fatalf("CanonicalResourceType() = %q, want network-endpoint", got) + } +} + func TestPDFGenerator_Generate(t *testing.T) { data := createTestReportData() diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index c76304c38..504bea929 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -3625,8 +3625,8 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 163, - "heading_line": 98, + "line": 175, + "heading_line": 101, } ], )