From 2f5aa201229dd4c1b1078640d8cfca96b3681de2 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 6 May 2026 14:08:03 +0100 Subject: [PATCH] Add mock availability endpoint fixtures Refs #1460 --- .../v6/internal/subsystems/agent-lifecycle.md | 5 + .../v6/internal/subsystems/api-contracts.md | 7 + .../subsystems/frontend-primitives.md | 10 + .../v6/internal/subsystems/monitoring.md | 8 + .../internal/subsystems/storage-recovery.md | 17 +- .../internal/subsystems/unified-resources.md | 9 + .../AvailabilityTargetSlot.tsx | 58 ++- .../__tests__/AvailabilityTargetSlot.test.tsx | 66 +++ .../availabilityTargetPresets.test.ts | 43 ++ .../availabilityTargetPresets.ts | 77 ++++ .../__tests__/useUnifiedResources.test.ts | 42 ++ .../src/hooks/useUnifiedResources.ts | 8 +- frontend-modern/src/types/resource.ts | 2 +- .../resourceBadgePresentation.test.ts | 25 ++ .../__tests__/resourceStateAdapters.test.ts | 37 ++ .../resourceTypePresentation.test.ts | 1 + .../utils/__tests__/sourcePlatforms.test.ts | 3 +- .../src/utils/resourceBadgePresentation.ts | 42 +- .../src/utils/resourceStateAdapters.ts | 100 ++++- .../src/utils/resourceTypePresentation.ts | 4 + frontend-modern/src/utils/sourcePlatforms.ts | 9 +- internal/api/availability_handlers.go | 14 + internal/api/availability_handlers_test.go | 76 ++++ internal/api/connections_handlers.go | 6 + .../api/connections_handlers_mock_test.go | 62 +++ internal/api/contract_test.go | 43 ++ internal/api/platform_mock_connections.go | 126 ++++++ internal/config/availability.go | 6 +- internal/config/availability_test.go | 12 + internal/mock/availability_fixtures.go | 394 ++++++++++++++++++ internal/mock/fixture_graph.go | 24 +- internal/mock/platform_fixtures.go | 36 +- internal/mock/platform_fixtures_test.go | 51 +++ .../release_control/subsystem_lookup_test.py | 2 +- 34 files changed, 1371 insertions(+), 54 deletions(-) create mode 100644 frontend-modern/src/components/Settings/ConnectionEditor/__tests__/AvailabilityTargetSlot.test.tsx create mode 100644 frontend-modern/src/components/Settings/ConnectionEditor/__tests__/availabilityTargetPresets.test.ts create mode 100644 frontend-modern/src/components/Settings/ConnectionEditor/availabilityTargetPresets.ts create mode 100644 internal/api/connections_handlers_mock_test.go create mode 100644 internal/mock/availability_fixtures.go diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 1ac1d4af7..d1335fcdd 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -206,6 +206,11 @@ profile and assignment columns, but embedded table framing must route through lifecycle surfaces may display contact email when supplied by the shared auth boundary, but they must not reinterpret SSO or Stripe email as the canonical user identifier for setup, install, or fleet-management actions. + Availability-target API changes are adjacent but not lifecycle-owned: + agentless ping/TCP/HTTP targets may appear in shared `internal/api/` + handlers and connection ledgers, but they must remain settings/API + availability resources and must not create install commands, agent tokens, or + host uninstall/stop-monitoring lifecycle actions. Hosted handoff subjects consumed through the shared API auth boundary must already be stable, non-email principals; lifecycle-adjacent routes must not recover authority from a blank handoff subject by falling back to contact diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 46e5c34fb..dbf845496 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -142,6 +142,9 @@ product API routes free of maintainer commercial analytics. 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. + Mock mode must expose authored availability targets through those same + list, saved-test, and connections-ledger payloads so demo endpoints exercise + the canonical API contract rather than a frontend-only fixture. 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. @@ -3410,6 +3413,10 @@ 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. +Mock availability fixtures must still behave like saved targets: `/api/connections` +reports them as availability rows, `/api/availability-targets` lists them with +probe status, and saved-test calls return the synthetic probe result instead of +attempting live network I/O against demo-only addresses. 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 cc397c7d7..34c4e7ca4 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -179,6 +179,11 @@ work extends shared components instead of creating new local variants. 8. `frontend-modern/src/stores/aiChat.ts` shared with `ai-runtime`: the assistant drawer and session store is both an AI runtime control surface and a canonical app-shell presentation boundary. 9. `frontend-modern/src/utils/platformSupportManifest.generated.ts` shared with `unified-resources`: the generated platform support projection is both a canonical unified-resource platform union boundary and a shared frontend source/platform vocabulary boundary. 10. `frontend-modern/src/utils/sourcePlatforms.ts` shared with `unified-resources`: the source platform normalizer is both a canonical unified-resource source adapter boundary and a shared frontend source/platform vocabulary boundary. + That shared boundary must preserve `availability` as the agentless + infrastructure source for `network-endpoint` resources and settings + presets, so source badges and platform/source type resolution do not fall + back to `generic` when an endpoint is represented by ping, TCP, or HTTP + probe data rather than an installed agent or provider API. ## Extension Points @@ -2798,3 +2803,8 @@ 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. +Availability setup presets for pingable devices, MQTT, ESPHome, or similar +agentless endpoints must also stay on the shared settings form vocabulary: +presets may fill protocol, port, and path defaults, but display badges and +drawers still derive `Availability` and `Network Endpoint` labels from the +shared resource presentation helpers rather than from preset-local copy. diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 4e125f8d3..7ae2ca2da 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -130,6 +130,10 @@ load from the config persistence boundary, schedule through 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. +Mock-mode availability targets must use that same provider vocabulary. The +mock fixture graph may author ping/TCP/HTTP endpoint examples, but monitoring +and API consumers must receive them through `SourceAvailability` supplemental +records and probe-status projections, not through a mock-only monitoring type. 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 @@ -766,6 +770,10 @@ from standalone defaults, consume partial legacy helper exports, or mix snapshot state with separate provider fixtures when seeding read-state or metrics history. The graph, its platform projections, and its curated demo scenario layer are the canonical mock runtime API. +Availability mock fixtures belong to that same graph authority: UPS network +cards, MQTT meters, HTTP panels, and controller ping targets must be authored +once in `internal/mock/` and then projected into availability status, unified +resources, and connections payloads from that shared graph. That same boundary now also owns native disk-history fallback when Pulse's own history is shallow. `internal/truenas/client.go`, `internal/truenas/provider.go`, `internal/monitoring/truenas_poller.go`, and diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 8f94955c5..61986a391 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -465,8 +465,9 @@ bypass the API fail-closed execution gate. `docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json` through `frontend-modern/src/utils/platformSupportManifest.ts`, not from storage-local hard-coded provider arrays. -32. Keep infrastructure summary chart bucketing presentation-only on the adjacent shared API boundary. When `internal/api/router.go` normalizes mixed-cadence infrastructure history into equal-time summary buckets for operator-facing summary cards, storage and recovery may consume the resulting visual context only; they must not reinterpret those normalized chart samples as recovery freshness windows, backup cadence, or restore evidence. -33. Keep workload chart downsampling presentation-only on that same adjacent shared API boundary. When `internal/api/router.go` caps mixed-cadence workload history into equal-time buckets for operator-facing workload cards, storage and recovery may consume the resulting visual context only; they must not reinterpret those shaped chart samples as recovery freshness windows, backup cadence, or restore evidence. +32. Keep agentless availability endpoints neutral on the shared unified-resource and API contracts. When `internal/api/availability_handlers.go`, `internal/api/connections_handlers.go`, `internal/api/platform_mock_connections.go`, or `frontend-modern/src/hooks/useUnifiedResources.ts` surface `network-endpoint` availability resources, storage and recovery may consume their liveness as infrastructure context only; they must not reinterpret ping/TCP/HTTP endpoints as storage providers, backup targets, recovery repositories, or protected-workload evidence. +33. Keep infrastructure summary chart bucketing presentation-only on the adjacent shared API boundary. When `internal/api/router.go` normalizes mixed-cadence infrastructure history into equal-time summary buckets for operator-facing summary cards, storage and recovery may consume the resulting visual context only; they must not reinterpret those normalized chart samples as recovery freshness windows, backup cadence, or restore evidence. +34. Keep workload chart downsampling presentation-only on that same adjacent shared API boundary. When `internal/api/router.go` caps mixed-cadence workload history into equal-time buckets for operator-facing workload cards, storage and recovery may consume the resulting visual context only; they must not reinterpret those shaped chart samples as recovery freshness windows, backup cadence, or restore evidence. The same adjacent chart boundary now covers compact storage capacity transport. `internal/api/router.go` may batch only the canonical `used` and `avail` storage series for `/api/charts/storage-summary`, but storage @@ -481,14 +482,14 @@ bypass the API fail-closed execution gate. In mock mode, that same compact route must stay aggregate-only and sampler-prewarmed; storage and recovery must not trigger per-pool chart reconstruction on the first dashboard request after each mock refresh. -34. Keep storage and recovery websocket reads on the neutral app-runtime boundary. `frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx`, `frontend-modern/src/components/Storage/useStoragePageResources.ts`, and adjacent summary/detail composition may consume live websocket state only through `frontend-modern/src/contexts/appRuntime.ts`, not by importing `frontend-modern/src/App.tsx` or rebuilding shell-local providers. +35. Keep storage and recovery websocket reads on the neutral app-runtime boundary. `frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx`, `frontend-modern/src/components/Storage/useStoragePageResources.ts`, and adjacent summary/detail composition may consume live websocket state only through `frontend-modern/src/contexts/appRuntime.ts`, not by importing `frontend-modern/src/App.tsx` or rebuilding shell-local providers. That same dashboard composition boundary is now a retired negative space. Storage and recovery-adjacent summaries may reuse `/api/charts/storage-summary` through their owning summary components, but they must not restore `/api/resources/dashboard-summary`, paginated `useUnifiedResources()` transport, or per-pool `/api/metrics-store/history` fan-out under a dashboard hot path. -35. Keep shared `frontend-modern/src/App.tsx` public-route ownership explicit by +36. Keep shared `frontend-modern/src/App.tsx` public-route ownership explicit by surface. Storage/recovery preview entrypoints such as `/preview/setup-complete` may remain public app-shell routes, but unrelated commercial compatibility handoffs like `/pricing` must stay separate thin @@ -528,7 +529,7 @@ bypass the API fail-closed execution gate. top-level tabs are warm after authentication, but it must not fetch storage summary charts, recovery history, provider state, or preview data from `frontend-modern/src/App.tsx` itself. -36. Keep public self-hosted purchase handoff and activation routes on the +37. Keep public self-hosted purchase handoff and activation routes on the adjacent commercial/auth boundary. When `internal/api/router.go`, `internal/api/router_routes_cloud.go`, `internal/api/licensing_handlers.go`, or `internal/api/demo_mode_commercial.go` evolve @@ -576,7 +577,7 @@ bypass the API fail-closed execution gate. runtime as populated mock inventory, but they must not expose `demo_fixtures`, billing identity, or alternate entitlement semantics as recovery-local transport or operator-facing storage metadata. -37. Keep storage summary fetches scope-owned on the shared summary caches. +38. Keep storage summary fetches scope-owned on the shared summary caches. `frontend-modern/src/components/Storage/StorageSummary.tsx`, `frontend-modern/src/utils/storageSummaryCache.ts`, and `frontend-modern/src/utils/storageSummaryTrendCache.ts` may reuse @@ -584,14 +585,14 @@ bypass the API fail-closed execution gate. refetch the full `/api/storage-charts` payload once per adjacent summary card or invent a dashboard-only storage summary transport path outside the canonical cache owners. -38. Keep storage and recovery route framing additive and owner-neutral. +39. Keep storage and recovery route framing additive and owner-neutral. `frontend-modern/src/components/Storage/Storage.tsx` and storage/recovery- adjacent route composition may use the shared `PageHeader` shell for top-level route framing, but that header must stay additive on top of the canonical storage page model, recovery presenters, and shared summary caches. Header chrome must not become a second owner for storage filters, recovery posture, commercial purchase state, or transport selection. -39. Keep the unified connections ledger owner-neutral toward storage and +40. Keep the unified connections ledger owner-neutral toward storage and recovery. Shared `internal/api/router.go` may mount the `/api/connections` and `/api/connections/probe` routes alongside the existing storage/recovery-adjacent API surfaces, and diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index fa776f804..17347d74f 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -522,6 +522,10 @@ 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. +Frontend resource adapters must preserve that same availability identity on +both REST and realtime paths: a thin `network-endpoint` update with +availability data is still `platformType=availability`, `sourceType=api`, and +must not regress to a generic platform badge in infrastructure rows or drawers. 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 @@ -711,6 +715,11 @@ one graph instead of combining a legacy snapshot read with standalone provider defaults. The shared resource graph must therefore see one coherent mock platform set regardless of whether a platform is snapshot-backed or supplemental-provider-backed. +Agentless availability fixtures join that same graph-owned seed contract: +mock UPS, MQTT, HTTP, and controller endpoints must project as +`SourceAvailability` `network-endpoint` resources with real availability +payloads, incidents, and source status rather than as generic host rows or +settings-only sample data. Callers should therefore consume `CurrentFixtureGraph()` and graph-owned projections rather than reintroducing platform-only or state-only mock helper exports. diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx index 322316fee..a6a65ba73 100644 --- a/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx +++ b/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx @@ -13,6 +13,13 @@ import { type AvailabilityTarget, type AvailabilityTestResponse, } from '@/api/availabilityTargets'; +import { + AVAILABILITY_TARGET_PRESETS, + CUSTOM_AVAILABILITY_PRESET_ID, + applyAvailabilityTargetPreset, + availabilityPresetById, + type AvailabilityTargetPresetID, +} from '../availabilityTargetPresets'; 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'; @@ -97,20 +104,52 @@ const testToneClass = (result: AvailabilityTestResponse) => ? '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'; +const presetSensitiveFormKeys: ReadonlySet = new Set([ + 'path', + 'port', + 'protocol', +]); + export const AvailabilityTargetSlot: Component = (props) => { const [form, setForm] = createSignal(newAvailabilityForm()); + const [selectedPreset, setSelectedPreset] = createSignal( + CUSTOM_AVAILABILITY_PRESET_ID, + ); 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) => { + const updateForm = (patch: Partial, preservePreset = false) => { setForm((current) => ({ ...current, ...patch })); + if ( + !preservePreset && + Object.keys(patch).some((key) => presetSensitiveFormKeys.has(key as keyof AvailabilityForm)) + ) { + setSelectedPreset(CUSTOM_AVAILABILITY_PRESET_ID); + } setError(null); setTestResult(null); }; + const selectedPresetConfig = () => availabilityPresetById(selectedPreset()); + + const addressPlaceholder = () => + selectedPresetConfig()?.addressPlaceholder ?? + (form().protocol === 'http' ? 'http://device.local/status' : 'device.local'); + + const portPlaceholder = () => + selectedPresetConfig()?.portPlaceholder ?? (form().protocol === 'http' ? 'Optional' : '1883'); + + const handlePresetChange = (presetId: AvailabilityTargetPresetID) => { + setSelectedPreset(presetId); + setError(null); + setTestResult(null); + if (presetId === CUSTOM_AVAILABILITY_PRESET_ID) return; + setForm((current) => applyAvailabilityTargetPreset(current, presetId)); + }; + onMount(async () => { const targetId = props.editingTargetId?.trim(); if (!targetId) return; @@ -177,6 +216,19 @@ export const AvailabilityTargetSlot: Component = (p
+ + handlePresetChange(event.currentTarget.value as AvailabilityTargetPresetID) + } + fieldClass="sm:col-span-2" + > + + {AVAILABILITY_TARGET_PRESETS.map((preset) => ( + + ))} + diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/AvailabilityTargetSlot.test.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/AvailabilityTargetSlot.test.tsx new file mode 100644 index 000000000..cb696c834 --- /dev/null +++ b/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/AvailabilityTargetSlot.test.tsx @@ -0,0 +1,66 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AvailabilityTargetsAPI } from '@/api/availabilityTargets'; +import { AvailabilityTargetSlot } from '../CredentialSlots/AvailabilityTargetSlot'; + +vi.mock('@/api/availabilityTargets', () => ({ + AvailabilityTargetsAPI: { + create: vi.fn(), + list: vi.fn(), + remove: vi.fn(), + test: vi.fn(), + testSaved: vi.fn(), + update: vi.fn(), + }, +})); + +const mockedCreate = vi.mocked(AvailabilityTargetsAPI.create); + +describe('AvailabilityTargetSlot', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedCreate.mockResolvedValue({ + id: 'target-1', + name: 'Rack sensor', + address: 'rack-sensor.local', + protocol: 'tcp', + port: 6053, + enabled: true, + }); + }); + + afterEach(() => cleanup()); + + it('prefills ESPHome devices as TCP availability targets', async () => { + const onSaved = vi.fn(); + render(() => ); + + fireEvent.change(screen.getByLabelText('Preset'), { + target: { value: 'esphome-device' }, + }); + + await waitFor(() => expect(screen.getByLabelText('Probe')).toHaveValue('tcp')); + expect(screen.getByLabelText('Port')).toHaveValue('6053'); + + fireEvent.input(screen.getByLabelText('Name'), { + target: { value: 'Rack sensor' }, + }); + fireEvent.input(screen.getByPlaceholderText('sensor.local'), { + target: { value: 'rack-sensor.local' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Add target' })); + + await waitFor(() => + expect(mockedCreate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Rack sensor', + address: 'rack-sensor.local', + protocol: 'tcp', + port: 6053, + enabled: true, + }), + ), + ); + expect(onSaved).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/availabilityTargetPresets.test.ts b/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/availabilityTargetPresets.test.ts new file mode 100644 index 000000000..8917146b0 --- /dev/null +++ b/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/availabilityTargetPresets.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { + AVAILABILITY_TARGET_PRESETS, + applyAvailabilityTargetPreset, + availabilityPresetById, +} from '../availabilityTargetPresets'; + +describe('availabilityTargetPresets', () => { + it('includes ESPHome as a TCP availability preset', () => { + expect(availabilityPresetById('esphome-device')).toEqual( + expect.objectContaining({ + label: 'ESPHome device', + protocol: 'tcp', + port: '6053', + }), + ); + }); + + it('applies preset probe defaults without changing endpoint identity fields', () => { + const form = { + id: '', + name: 'Rack sensor', + address: 'rack-sensor.local', + protocol: 'icmp' as const, + port: '', + path: '/health', + enabled: true, + }; + + expect(applyAvailabilityTargetPreset(form, 'mqtt-broker')).toEqual({ + ...form, + protocol: 'tcp', + port: '1883', + path: '', + }); + expect(applyAvailabilityTargetPreset(form, 'custom')).toBe(form); + }); + + it('keeps preset ids unique', () => { + const ids = AVAILABILITY_TARGET_PRESETS.map((preset) => preset.id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/availabilityTargetPresets.ts b/frontend-modern/src/components/Settings/ConnectionEditor/availabilityTargetPresets.ts new file mode 100644 index 000000000..a04f5325a --- /dev/null +++ b/frontend-modern/src/components/Settings/ConnectionEditor/availabilityTargetPresets.ts @@ -0,0 +1,77 @@ +import type { AvailabilityProbeProtocol } from '@/api/availabilityTargets'; + +export const CUSTOM_AVAILABILITY_PRESET_ID = 'custom'; + +export type AvailabilityTargetPresetID = + | typeof CUSTOM_AVAILABILITY_PRESET_ID + | 'ping-device' + | 'mqtt-broker' + | 'esphome-device' + | 'esphome-dashboard'; + +export interface AvailabilityTargetPreset { + id: AvailabilityTargetPresetID; + label: string; + protocol: AvailabilityProbeProtocol; + port?: string; + path?: string; + addressPlaceholder: string; + portPlaceholder?: string; +} + +export interface AvailabilityPresetFields { + protocol: AvailabilityProbeProtocol; + port: string; + path: string; +} + +export const AVAILABILITY_TARGET_PRESETS: readonly AvailabilityTargetPreset[] = [ + { + id: 'ping-device', + label: 'Pingable device', + protocol: 'icmp', + addressPlaceholder: 'device.local', + }, + { + id: 'mqtt-broker', + label: 'MQTT broker', + protocol: 'tcp', + port: '1883', + addressPlaceholder: 'mqtt.local', + portPlaceholder: '1883', + }, + { + id: 'esphome-device', + label: 'ESPHome device', + protocol: 'tcp', + port: '6053', + addressPlaceholder: 'sensor.local', + portPlaceholder: '6053', + }, + { + id: 'esphome-dashboard', + label: 'ESPHome dashboard', + protocol: 'http', + port: '6052', + addressPlaceholder: 'http://esphome.local', + portPlaceholder: '6052', + }, +] as const; + +export const availabilityPresetById = (presetId: string): AvailabilityTargetPreset | undefined => + AVAILABILITY_TARGET_PRESETS.find((preset) => preset.id === presetId); + +export const applyAvailabilityTargetPreset = ( + form: T, + presetId: string, +): T => { + const preset = availabilityPresetById(presetId); + if (!preset) return form; + + return { + ...form, + protocol: preset.protocol, + port: preset.port ?? '', + path: preset.path ?? '', + }; +}; diff --git a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts index 81d79bb06..0de40b7c4 100644 --- a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts +++ b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts @@ -685,6 +685,48 @@ describe('useUnifiedResources', () => { dispose(); }); + it('projects availability endpoints onto the availability platform model', async () => { + apiFetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [ + { + ...v2Resource, + id: 'endpoint-1', + type: 'network-endpoint', + name: 'MQTT power meter', + sources: ['availability'], + availability: { + targetId: 'mock-availability-mqtt-meter', + protocol: 'tcp', + address: 'power-meter-01.lab.local', + port: 1883, + }, + }, + ], + }), + }); + + let dispose = () => {}; + let result: ReturnType | undefined; + createRoot((d) => { + dispose = d; + result = useUnifiedResources(); + }); + + await result!.refetch(); + expect(result!.resources()[0].type).toBe('network-endpoint'); + expect(result!.resources()[0].platformType).toBe('availability'); + expect(result!.resources()[0].sourceType).toBe('api'); + expect(result!.resources()[0].platformData?.availability).toMatchObject({ + protocol: 'tcp', + address: 'power-meter-01.lab.local', + port: 1883, + }); + + dispose(); + }); + it('maps discoveryTarget.agentId into canonical discovery agentId', async () => { apiFetchMock.mockResolvedValueOnce({ ok: true, diff --git a/frontend-modern/src/hooks/useUnifiedResources.ts b/frontend-modern/src/hooks/useUnifiedResources.ts index 62f73f6b4..0fa74a08e 100644 --- a/frontend-modern/src/hooks/useUnifiedResources.ts +++ b/frontend-modern/src/hooks/useUnifiedResources.ts @@ -619,6 +619,10 @@ const toResource = (v2: APIResource): Resource => { const canonical = v2.canonicalIdentity; const name = asTrimmedString(canonical?.displayName) || v2.name || v2.id; const platformId = asTrimmedString(canonical?.platformId) || getPreferredNormalizedPlatformId(v2); + const resourceType = resolveType(v2.type); + const platformType = + resolvePlatformTypeFromSources(sources) || + (resourceType === 'network-endpoint' ? 'availability' : 'agent'); const discoveryResourceType = resolveDiscoveryResourceType(v2.discoveryTarget?.resourceType); const discoveryAgentId = v2.discoveryTarget?.agentId; @@ -635,11 +639,11 @@ const toResource = (v2: APIResource): Resource => { const metricsTarget = resolveMetricsTarget(v2.type, v2.metricsTarget); return { id: v2.id, - type: resolveType(v2.type), + type: resourceType, name, displayName: name, platformId, - platformType: resolvePlatformTypeFromSources(sources) || 'agent', + platformType, sourceType: resolveSourceTypeFromSources(sources), parentId: v2.parentId, parentName: v2.parentName, diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index 61c3b6502..499d6940c 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -51,7 +51,7 @@ export type ResourceType = // Platform types - which system the resource comes from export const PLATFORM_TYPES = GENERATED_PLATFORM_TYPE_KEYS; -export type PlatformType = GeneratedPlatformType | 'generic'; +export type PlatformType = GeneratedPlatformType | 'availability' | 'generic'; // Source types - how data is collected export type SourceType = diff --git a/frontend-modern/src/utils/__tests__/resourceBadgePresentation.test.ts b/frontend-modern/src/utils/__tests__/resourceBadgePresentation.test.ts index 649c0f152..0deba89d4 100644 --- a/frontend-modern/src/utils/__tests__/resourceBadgePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceBadgePresentation.test.ts @@ -29,6 +29,7 @@ describe('resourceBadgePresentation', () => { expect(getPlatformBadge('proxmox-pve')?.label).toBe('PVE'); expect(getPlatformBadge('proxmox-pbs')?.label).toBe('PBS'); expect(getPlatformBadge('docker')?.label).toBe('Docker / Podman'); + expect(getPlatformBadge('availability')?.label).toBe('Availability'); }); it('returns source badges for infrastructure source types', () => { @@ -110,6 +111,30 @@ describe('resourceBadgePresentation', () => { expect(getInfrastructureSystemIdentitySortLabel(resource)).toBe('PVE'); }); + it('uses availability identity for agentless network endpoints', () => { + const resource = makeResource({ + type: 'network-endpoint', + platformType: 'generic', + sourceType: 'api', + platformData: { + sources: ['availability'], + availability: { + protocol: 'tcp', + address: 'power-meter-01.lab.local', + port: 1883, + }, + }, + }); + + expect(getInfrastructureSystemIdentityBadges(resource).map((badge) => badge.label)).toEqual([ + 'Availability', + ]); + expect(getInfrastructureSystemIdentityBadges(resource)[0]?.title).toBe( + 'TCP power-meter-01.lab.local:1883', + ); + expect(getInfrastructureSystemIdentitySortLabel(resource)).toBe('Availability'); + }); + it('falls back to reported OS identity for agent-only systems', () => { expect( getInfrastructureSystemIdentityBadges( diff --git a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts index c875b0bef..0c56c52c6 100644 --- a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts @@ -267,6 +267,43 @@ describe('resourceStateAdapters nodeFromResource', () => { }); }); + it('canonicalizes agentless availability realtime resources as availability endpoints', () => { + const [resource] = mergeCanonicalResourceSnapshot( + [ + { + id: 'network-endpoint-1', + type: 'network-endpoint', + name: 'MQTT power meter', + displayName: 'MQTT power meter', + platformId: 'mock-availability-mqtt-meter', + platformType: 'generic', + sourceType: 'api', + status: 'online', + lastSeen: Date.now(), + platformData: { + availability: { + targetId: 'mock-availability-mqtt-meter', + protocol: 'tcp', + address: 'power-meter-01.lab.local', + port: 1883, + }, + }, + } as Resource, + ], + [], + ); + + expect(resource.platformType).toBe('availability'); + expect(resource.sourceType).toBe('api'); + expect(resource.availability).toMatchObject({ + targetId: 'mock-availability-mqtt-meter', + protocol: 'tcp', + address: 'power-meter-01.lab.local', + port: 1883, + }); + expect((resource.platformData as Record).sources).toEqual(['availability']); + }); + it('preserves richer existing resource details when realtime updates are thinner', () => { const existing: Resource = { id: 'node-1', diff --git a/frontend-modern/src/utils/__tests__/resourceTypePresentation.test.ts b/frontend-modern/src/utils/__tests__/resourceTypePresentation.test.ts index 8feef688a..19d096d5e 100644 --- a/frontend-modern/src/utils/__tests__/resourceTypePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceTypePresentation.test.ts @@ -8,6 +8,7 @@ describe('resourceTypePresentation', () => { it('returns canonical labels for unified resource types', () => { expect(getResourceTypeLabel('docker-host')).toBe('Container Runtime'); expect(getResourceTypeLabel('k8s-cluster')).toBe('K8s Cluster'); + expect(getResourceTypeLabel('network-endpoint')).toBe('Network Endpoint'); expect(getResourceTypeLabel('truenas')).toBe('Agent'); }); diff --git a/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts b/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts index a9503c04f..f02e3584b 100644 --- a/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts +++ b/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts @@ -149,7 +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(['availability'])).toBe('availability'); expect(resolvePlatformTypeFromSources(['custom-source'])).toBeUndefined(); }); }); @@ -161,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(['agent', 'availability'])).toBe('hybrid'); expect(resolveSourceTypeFromSources(['availability'])).toBe('api'); expect(resolveSourceTypeFromSources(['custom-source'])).toBe('api'); }); diff --git a/frontend-modern/src/utils/resourceBadgePresentation.ts b/frontend-modern/src/utils/resourceBadgePresentation.ts index 32a58ad29..3bbf5ff2a 100644 --- a/frontend-modern/src/utils/resourceBadgePresentation.ts +++ b/frontend-modern/src/utils/resourceBadgePresentation.ts @@ -18,6 +18,7 @@ const baseBadge = 'inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap'; const typeClasses = 'bg-surface-alt text-base-content'; +const availabilityBadgeClasses = 'bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300'; const PRIMARY_SYSTEM_SOURCE_PRIORITY: KnownSourcePlatform[] = [ 'proxmox-pve', @@ -91,6 +92,13 @@ const buildUnifiedSourceBadges = (sources: KnownSourcePlatform[]): ResourceBadge export function getPlatformBadge(platformType?: PlatformType): ResourceBadge | null { if (!platformType) return null; + if (platformType === 'availability') { + return { + label: 'Availability', + classes: `${baseBadge} ${availabilityBadgeClasses}`, + title: 'Availability', + }; + } const sharedBadge = getSourcePlatformBadge(platformType); if (!sharedBadge) return null; return { @@ -207,9 +215,41 @@ const getAgentSystemIdentityBadge = (resource: Resource): ResourceBadge | null = return null; }; +const getAvailabilitySystemIdentityBadge = ( + resource: Resource, + platformData: Record | undefined, +): ResourceBadge | null => { + const availability = platformData?.availability as + | { address?: string; protocol?: string; port?: number } + | undefined; + const sources = (platformData?.sources as string[] | undefined) ?? []; + const isAvailabilityEndpoint = + resource.type === 'network-endpoint' || + Boolean(availability) || + sources.some((source) => source.trim().toLowerCase() === 'availability'); + + if (!isAvailabilityEndpoint) return null; + + const protocol = trimString(availability?.protocol).toUpperCase(); + const address = trimString(availability?.address); + const port = Number.isFinite(availability?.port) && availability?.port ? `:${availability.port}` : ''; + return { + label: 'Availability', + classes: `${baseBadge} ${availabilityBadgeClasses}`, + title: titleFromParts(protocol || 'Availability', address ? `${address}${port}` : undefined), + }; +}; + export function getInfrastructureSystemIdentityBadges(resource: Resource): ResourceBadge[] { - const platformData = getPlatformDataRecord(resource) as { sources?: string[] } | undefined; + const platformData = getPlatformDataRecord(resource) as + | (Record & { sources?: string[] }) + | undefined; const sources = normalizeUnifiedSourceKeys(platformData?.sources); + const availabilityIdentityBadge = getAvailabilitySystemIdentityBadge(resource, platformData); + if (availabilityIdentityBadge) { + return [availabilityIdentityBadge]; + } + const systemSource = firstSystemSource(sources, resource.platformType); if (systemSource) { return buildUnifiedSourceBadges([systemSource]); diff --git a/frontend-modern/src/utils/resourceStateAdapters.ts b/frontend-modern/src/utils/resourceStateAdapters.ts index 0f5f8a934..45deb0c91 100644 --- a/frontend-modern/src/utils/resourceStateAdapters.ts +++ b/frontend-modern/src/utils/resourceStateAdapters.ts @@ -30,6 +30,10 @@ import { getPreferredResourceClusterName, getPreferredResourceHostname, } from '@/utils/resourceIdentity'; +import { + resolvePlatformTypeFromSources, + resolveSourceTypeFromSources, +} from '@/utils/sourcePlatforms'; type JsonRecord = Record; @@ -66,10 +70,7 @@ const getCanonicalPlatformId = (resource: Resource): string | undefined => { export const resourcePlatformData = (resource: Resource): Record | undefined => asRecord(resource.platformData); -const mergeStringArrays = ( - incoming?: string[], - existing?: string[], -): string[] | undefined => { +const mergeStringArrays = (incoming?: string[], existing?: string[]): string[] | undefined => { const merged = [...(incoming ?? []), ...(existing ?? [])] .map((value) => asString(value)) .filter((value): value is string => Boolean(value)); @@ -101,6 +102,7 @@ const mergePlatformData = ( 'kubernetes', 'vmware', 'storage', + 'availability', 'physicalDisk', 'ceph', 'metrics', @@ -132,6 +134,15 @@ const mergePlatformData = ( }; const deriveLegacySourceList = (resource: Resource): string[] | undefined => { + if ( + resource.type === 'network-endpoint' || + resource.platformType === 'availability' || + Boolean(resource.availability) || + Boolean(asRecord(resource.platformData)?.availability) + ) { + return ['availability']; + } + switch (resource.platformType) { case 'proxmox-pve': return resource.sourceType === 'hybrid' ? ['proxmox', 'agent'] : ['proxmox']; @@ -185,7 +196,8 @@ const canonicalizeLegacyPlatformData = (resource: Resource): Resource['platformD } } if (platformData.memory !== undefined) agentPayload.memory = platformData.memory; - if (platformData.interfaces !== undefined) agentPayload.networkInterfaces = platformData.interfaces; + if (platformData.interfaces !== undefined) + agentPayload.networkInterfaces = platformData.interfaces; if (platformData.disks !== undefined) agentPayload.disks = platformData.disks; if (Object.keys(agentPayload).length > 0) { normalized.agent = agentPayload; @@ -217,7 +229,8 @@ const canonicalizeLegacyPlatformData = (resource: Resource): Resource['platformD } } if (platformData.swarm !== undefined) dockerPayload.swarm = platformData.swarm; - if (platformData.interfaces !== undefined) dockerPayload.networkInterfaces = platformData.interfaces; + if (platformData.interfaces !== undefined) + dockerPayload.networkInterfaces = platformData.interfaces; if (platformData.disks !== undefined) dockerPayload.disks = platformData.disks; if (Object.keys(dockerPayload).length > 0) { normalized.docker = dockerPayload; @@ -309,11 +322,44 @@ const canonicalizeLegacyPlatformData = (resource: Resource): Resource['platformD return normalized; }; +const getCanonicalSourceList = ( + resource: Resource, + platformData?: Resource['platformData'], +): string[] | undefined => { + const platformRecord = asRecord(platformData); + return Array.isArray(platformRecord?.sources) && platformRecord.sources.length > 0 + ? (platformRecord.sources as string[]) + : deriveLegacySourceList({ ...resource, platformData }); +}; + +const hasAvailabilityFacet = ( + resource: Resource, + platformData?: Resource['platformData'], +): boolean => { + const platformRecord = asRecord(platformData); + const sources = getCanonicalSourceList(resource, platformData); + return ( + resource.type === 'network-endpoint' || + resource.platformType === 'availability' || + Boolean(resource.availability) || + Boolean(platformRecord?.availability) || + Boolean(sources?.some((source) => source.trim().toLowerCase() === 'availability')) + ); +}; + export const canonicalizeRealtimeResource = (resource: Resource): Resource => { const platformData = canonicalizeLegacyPlatformData(resource); const platformRecord = asRecord(platformData); + const sources = getCanonicalSourceList(resource, platformData); + const platformType = + resolvePlatformTypeFromSources(sources) || + (hasAvailabilityFacet(resource, platformData) ? 'availability' : resource.platformType); + const sourceType = + sources && sources.length > 0 ? resolveSourceTypeFromSources(sources) : resource.sourceType; const normalizedBase = { ...resource, + platformType, + sourceType, platformData, }; return { @@ -326,6 +372,8 @@ export const canonicalizeRealtimeResource = (resource: Resource): Resource => { kubernetes: resource.kubernetes ?? (platformRecord?.kubernetes as Resource['kubernetes']), vmware: resource.vmware ?? (platformRecord?.vmware as Resource['vmware']), storage: resource.storage ?? (platformRecord?.storage as Resource['storage']), + availability: + resource.availability ?? (platformRecord?.availability as Resource['availability']), physicalDisk: resource.physicalDisk ?? (platformRecord?.physicalDisk as Resource['physicalDisk']), }; @@ -365,14 +413,38 @@ export const mergeCanonicalResource = (incoming: Resource, existing?: Resource): recentChanges: incoming.recentChanges ?? existingCanonical.recentChanges, facetCounts: incoming.facetCounts ?? existingCanonical.facetCounts, diskIO: incoming.diskIO ?? existingCanonical.diskIO, - agent: mergeRecord(incoming.agent as JsonRecord | undefined, existingCanonical.agent as JsonRecord | undefined) as Resource['agent'], - proxmox: mergeRecord(incoming.proxmox as JsonRecord | undefined, existingCanonical.proxmox as JsonRecord | undefined) as Resource['proxmox'], - pbs: mergeRecord(incoming.pbs as JsonRecord | undefined, existingCanonical.pbs as JsonRecord | undefined) as Resource['pbs'], - kubernetes: mergeRecord(incoming.kubernetes as JsonRecord | undefined, existingCanonical.kubernetes as JsonRecord | undefined) as Resource['kubernetes'], - vmware: mergeRecord(incoming.vmware as JsonRecord | undefined, existingCanonical.vmware as JsonRecord | undefined) as Resource['vmware'], - storage: mergeRecord(incoming.storage as JsonRecord | undefined, existingCanonical.storage as JsonRecord | undefined) as Resource['storage'], - physicalDisk: mergeRecord(incoming.physicalDisk as JsonRecord | undefined, existingCanonical.physicalDisk as JsonRecord | undefined) as Resource['physicalDisk'], - identity: mergeRecord(incoming.identity as JsonRecord | undefined, existingCanonical.identity as JsonRecord | undefined) as Resource['identity'], + agent: mergeRecord( + incoming.agent as JsonRecord | undefined, + existingCanonical.agent as JsonRecord | undefined, + ) as Resource['agent'], + proxmox: mergeRecord( + incoming.proxmox as JsonRecord | undefined, + existingCanonical.proxmox as JsonRecord | undefined, + ) as Resource['proxmox'], + pbs: mergeRecord( + incoming.pbs as JsonRecord | undefined, + existingCanonical.pbs as JsonRecord | undefined, + ) as Resource['pbs'], + kubernetes: mergeRecord( + incoming.kubernetes as JsonRecord | undefined, + existingCanonical.kubernetes as JsonRecord | undefined, + ) as Resource['kubernetes'], + vmware: mergeRecord( + incoming.vmware as JsonRecord | undefined, + existingCanonical.vmware as JsonRecord | undefined, + ) as Resource['vmware'], + storage: mergeRecord( + incoming.storage as JsonRecord | undefined, + existingCanonical.storage as JsonRecord | undefined, + ) as Resource['storage'], + physicalDisk: mergeRecord( + incoming.physicalDisk as JsonRecord | undefined, + existingCanonical.physicalDisk as JsonRecord | undefined, + ) as Resource['physicalDisk'], + identity: mergeRecord( + incoming.identity as JsonRecord | undefined, + existingCanonical.identity as JsonRecord | undefined, + ) as Resource['identity'], platformData: mergePlatformData(incoming.platformData, existingCanonical.platformData), tags: incoming.tags && incoming.tags.length > 0 ? incoming.tags : existingCanonical.tags, labels: diff --git a/frontend-modern/src/utils/resourceTypePresentation.ts b/frontend-modern/src/utils/resourceTypePresentation.ts index 6c0908fe9..2ab5bb4e7 100644 --- a/frontend-modern/src/utils/resourceTypePresentation.ts +++ b/frontend-modern/src/utils/resourceTypePresentation.ts @@ -94,6 +94,10 @@ const RESOURCE_TYPE_PRESENTATION: Partial = { diff --git a/frontend-modern/src/utils/sourcePlatforms.ts b/frontend-modern/src/utils/sourcePlatforms.ts index a40f949cf..f2fa848f6 100644 --- a/frontend-modern/src/utils/sourcePlatforms.ts +++ b/frontend-modern/src/utils/sourcePlatforms.ts @@ -133,10 +133,13 @@ const hasGenericSource = (sources?: string[]): boolean => Boolean( sources?.some((source) => { const normalized = normalizeSourcePlatformKey(source) || source.toLowerCase(); - return normalized === 'availability' || normalized === 'generic'; + return normalized === 'generic'; }), ); +const hasAvailabilitySource = (sources?: string[]): boolean => + Boolean(sources?.some((source) => source.trim().toLowerCase() === 'availability')); + export const resolvePlatformTypeFromSources = (sources?: string[]): PlatformType | undefined => { const flags = readSourcePlatformFlags(sources); if (flags.hasProxmox) return 'proxmox-pve'; @@ -147,6 +150,7 @@ export const resolvePlatformTypeFromSources = (sources?: string[]): PlatformType if (flags.hasKubernetes) return 'kubernetes'; if (flags.hasDocker) return 'docker'; if (flags.hasAgent) return 'agent'; + if (hasAvailabilitySource(sources)) return 'availability'; if (hasGenericSource(sources)) return 'generic'; return undefined; }; @@ -161,7 +165,8 @@ export const resolveSourceTypeFromSources = (sources?: string[]): SourceType => flags.hasPmg || flags.hasTrueNAS || flags.hasVMware || - hasGenericSource(sources); + hasGenericSource(sources) || + hasAvailabilitySource(sources); if (flags.hasAgent && hasOther) return 'hybrid'; if (flags.hasAgent) return 'agent'; return 'api'; diff --git a/internal/api/availability_handlers.go b/internal/api/availability_handlers.go index d2e84fc2f..5ff0396c5 100644 --- a/internal/api/availability_handlers.go +++ b/internal/api/availability_handlers.go @@ -41,6 +41,11 @@ func NewAvailabilityHandlers( } func (h *AvailabilityHandlers) HandleList(w http.ResponseWriter, r *http.Request) { + if mock.IsMockEnabled() { + writeJSON(w, http.StatusOK, mockAvailabilityTargetResponses()) + return + } + persistence := h.persistenceForRequest(w, r.Context()) if persistence == nil { return @@ -211,6 +216,15 @@ func (h *AvailabilityHandlers) HandleTestSavedConnection(w http.ResponseWriter, writeErrorResponse(w, http.StatusBadRequest, "missing_target_id", "Availability target ID is required", nil) return } + if mock.IsMockEnabled() { + if response, ok := mockAvailabilityTestResponse(targetID); ok { + writeJSON(w, http.StatusOK, response) + return + } + writeErrorResponse(w, http.StatusNotFound, "availability_not_found", "Availability target not found", nil) + return + } + persistence := h.persistenceForRequest(w, r.Context()) if persistence == nil { return diff --git a/internal/api/availability_handlers_test.go b/internal/api/availability_handlers_test.go index fd96aac6f..b69347e4c 100644 --- a/internal/api/availability_handlers_test.go +++ b/internal/api/availability_handlers_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/mock" ) func TestAvailabilityHandlersCRUDPersistsTargets(t *testing.T) { @@ -130,6 +131,81 @@ func TestAvailabilityHandlersTestSavedTarget(t *testing.T) { } } +func TestAvailabilityHandlersListReturnsMockTargetsInMockMode(t *testing.T) { + previous := mock.IsMockEnabled() + if err := mock.SetEnabled(true); err != nil { + t.Fatalf("enable mock mode: %v", err) + } + t.Cleanup(func() { _ = mock.SetEnabled(previous) }) + + handler := NewAvailabilityHandlers( + func(context.Context) *config.ConfigPersistence { + t.Fatal("mock availability list should not load persistence") + return nil + }, + nil, + ) + + req := httptest.NewRequest(http.MethodGet, "/api/availability-targets", nil) + rec := httptest.NewRecorder() + handler.HandleList(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("HandleList status = %d, body=%s", rec.Code, rec.Body.String()) + } + + var listed []availabilityTargetResponse + if err := json.NewDecoder(rec.Body).Decode(&listed); err != nil { + t.Fatalf("decode listed targets: %v", err) + } + if len(listed) < 4 { + t.Fatalf("expected mock availability targets, got %+v", listed) + } + foundMQTT := false + for _, target := range listed { + if target.ID != "mock-availability-mqtt-meter" { + continue + } + foundMQTT = true + if target.Protocol != config.AvailabilityProbeTCP || target.Port != 1883 { + t.Fatalf("unexpected MQTT target: %+v", target.AvailabilityTarget) + } + if target.Status == nil || !target.Status.Available { + t.Fatalf("expected successful MQTT status, got %+v", target.Status) + } + } + if !foundMQTT { + t.Fatalf("expected MQTT power meter target, got %+v", listed) + } +} + +func TestAvailabilityHandlersTestSavedMockTargetUsesSyntheticStatus(t *testing.T) { + previous := mock.IsMockEnabled() + if err := mock.SetEnabled(true); err != nil { + t.Fatalf("enable mock mode: %v", err) + } + t.Cleanup(func() { _ = mock.SetEnabled(previous) }) + + handler := NewAvailabilityHandlers(nil, nil) + + req := httptest.NewRequest(http.MethodPost, "/api/availability-targets/mock-availability-door-controller/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("expected synthetic failure for offline mock target, got %+v", response) + } + if response.Error != "icmp probe timed out" { + t.Fatalf("unexpected mock test error: %+v", response) + } +} + func availabilityRequestBody(t *testing.T, target config.AvailabilityTarget) *bytes.Reader { t.Helper() payload, err := json.Marshal(target) diff --git a/internal/api/connections_handlers.go b/internal/api/connections_handlers.go index a1505c257..c540fdad3 100644 --- a/internal/api/connections_handlers.go +++ b/internal/api/connections_handlers.go @@ -6,6 +6,7 @@ import ( "net/http" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/mock" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" ) @@ -77,6 +78,11 @@ func (h *ConnectionsHandlers) HandleList(w http.ResponseWriter, r *http.Request) inputs.instanceHealth = map[string]monitoring.InstanceHealth{} inputs.availabilityStatuses = map[string]monitoring.AvailabilityProbeStatus{} } + if mock.IsMockEnabled() { + mockTargets, mockStatuses := mockAvailabilityConnectionInputs() + inputs.availabilityTargets = mergeAvailabilityTargets(inputs.availabilityTargets, mockTargets) + inputs.availabilityStatuses = mergeAvailabilityStatuses(inputs.availabilityStatuses, mockStatuses) + } inputs.expectedAgentVersion = currentAgentTargetVersion() connections := buildConnections(inputs) diff --git a/internal/api/connections_handlers_mock_test.go b/internal/api/connections_handlers_mock_test.go new file mode 100644 index 000000000..37183c821 --- /dev/null +++ b/internal/api/connections_handlers_mock_test.go @@ -0,0 +1,62 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/mock" + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" +) + +func TestConnectionsHandleListIncludesMockAvailabilityTargets(t *testing.T) { + previous := mock.IsMockEnabled() + if err := mock.SetEnabled(true); err != nil { + t.Fatalf("enable mock mode: %v", err) + } + t.Cleanup(func() { _ = mock.SetEnabled(previous) }) + + handler := NewConnectionsHandlers( + func(context.Context) *config.Config { return nil }, + func(context.Context) *config.ConfigPersistence { return nil }, + func(context.Context) *monitoring.Monitor { return nil }, + ) + + req := httptest.NewRequest(http.MethodGet, "/api/connections", nil) + rec := httptest.NewRecorder() + handler.HandleList(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("HandleList status = %d, body=%s", rec.Code, rec.Body.String()) + } + + var response ConnectionsListResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("decode connections response: %v", err) + } + byID := make(map[string]Connection, len(response.Connections)) + for _, connection := range response.Connections { + byID[connection.ID] = connection + } + + mqtt, ok := byID["availability:mock-availability-mqtt-meter"] + if !ok { + t.Fatalf("expected mock MQTT availability connection, got %+v", response.Connections) + } + if mqtt.Type != ConnectionTypeAvailability || mqtt.State != ConnectionStateActive { + t.Fatalf("unexpected MQTT connection state: %+v", mqtt) + } + + door, ok := byID["availability:mock-availability-door-controller"] + if !ok { + t.Fatalf("expected mock door controller availability connection, got %+v", response.Connections) + } + if door.State != ConnectionStateUnreachable { + t.Fatalf("door controller state = %q, want unreachable", door.State) + } + if door.LastError == nil || door.LastError.Category != "availability" { + t.Fatalf("expected availability error metadata, got %+v", door.LastError) + } +} diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 7e1bf6a18..fd8de2fe8 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -57,6 +57,49 @@ type resourceContractSnapshot struct { Type string } +func TestContract_MockAvailabilityTargetsUseSavedTargetPayloads(t *testing.T) { + previousMock := mock.IsMockEnabled() + if err := mock.SetEnabled(true); err != nil { + t.Fatalf("enable mock mode: %v", err) + } + t.Cleanup(func() { _ = mock.SetEnabled(previousMock) }) + + targets := mockAvailabilityTargetResponses() + if len(targets) < 4 { + t.Fatalf("expected mock availability target payloads, got %d", len(targets)) + } + + seen := map[string]availabilityTargetResponse{} + for _, target := range targets { + if target.ID == "" { + t.Fatal("mock availability target payload must keep a saved target id") + } + if target.Status == nil { + t.Fatalf("mock availability target %q must include probe status", target.ID) + } + if target.Status.TargetID != target.ID { + t.Fatalf("mock availability target %q status target id drifted to %q", target.ID, target.Status.TargetID) + } + seen[target.ID] = target + } + + mqtt, ok := seen["mock-availability-mqtt-meter"] + if !ok { + t.Fatal("mock MQTT availability target missing from API payload contract") + } + if mqtt.Protocol != config.AvailabilityProbeTCP || mqtt.Port != 1883 { + t.Fatalf("mock MQTT availability target must remain a saved TCP:1883 target, got protocol=%q port=%v", mqtt.Protocol, mqtt.Port) + } + + response, ok := mockAvailabilityTestResponse("mock-availability-mqtt-meter") + if !ok { + t.Fatal("saved mock availability test response missing") + } + if !response.Success || response.LatencyMillis <= 0 || response.Error != "" { + t.Fatalf("mock MQTT saved-test response must be successful with latency and no error: %+v", response) + } +} + func TestPatrolRemediationCommercialCopyUsesSafeRemediationWording(t *testing.T) { files := []string{"ai_handlers.go", "router_routes_ai_relay.go"} for _, file := range files { diff --git a/internal/api/platform_mock_connections.go b/internal/api/platform_mock_connections.go index 8f0dce91e..e5a7998d5 100644 --- a/internal/api/platform_mock_connections.go +++ b/internal/api/platform_mock_connections.go @@ -77,6 +77,132 @@ func mockVMwareConnectionResponses() []vmwareConnectionResponse { }} } +func mockAvailabilityConnectionInputs() ([]config.AvailabilityTarget, map[string]monitoring.AvailabilityProbeStatus) { + fixtures := mock.AvailabilityFixtures() + targets := make([]config.AvailabilityTarget, 0, len(fixtures)) + statuses := make(map[string]monitoring.AvailabilityProbeStatus, len(fixtures)) + for _, fixture := range fixtures { + target := mockAvailabilityConfigTarget(fixture.Target) + if target.ID == "" { + continue + } + targets = append(targets, target) + statuses[target.ID] = mockAvailabilityProbeStatus(fixture, target) + } + return targets, statuses +} + +func mergeAvailabilityTargets(existing, additions []config.AvailabilityTarget) []config.AvailabilityTarget { + if len(additions) == 0 { + return existing + } + out := make([]config.AvailabilityTarget, 0, len(existing)+len(additions)) + seen := make(map[string]struct{}, len(existing)+len(additions)) + for _, target := range existing { + target = config.NormalizeAvailabilityTarget(target) + if target.ID == "" { + continue + } + seen[target.ID] = struct{}{} + out = append(out, target) + } + for _, target := range additions { + target = config.NormalizeAvailabilityTarget(target) + if target.ID == "" { + continue + } + if _, ok := seen[target.ID]; ok { + continue + } + seen[target.ID] = struct{}{} + out = append(out, target) + } + return out +} + +func mergeAvailabilityStatuses(existing, additions map[string]monitoring.AvailabilityProbeStatus) map[string]monitoring.AvailabilityProbeStatus { + if len(existing) == 0 && len(additions) == 0 { + return map[string]monitoring.AvailabilityProbeStatus{} + } + out := make(map[string]monitoring.AvailabilityProbeStatus, len(existing)+len(additions)) + for id, status := range existing { + if id != "" { + out[id] = status + } + } + for id, status := range additions { + if id != "" { + out[id] = status + } + } + return out +} + +func mockAvailabilityTargetResponses() []availabilityTargetResponse { + fixtures := mock.AvailabilityFixtures() + responses := make([]availabilityTargetResponse, 0, len(fixtures)) + for _, fixture := range fixtures { + target := mockAvailabilityConfigTarget(fixture.Target) + if target.ID == "" { + continue + } + status := mockAvailabilityProbeStatus(fixture, target) + responses = append(responses, availabilityTargetResponse{ + AvailabilityTarget: target, + Status: &status, + }) + } + return responses +} + +func mockAvailabilityTestResponse(targetID string) (availabilityTestResponse, bool) { + for _, fixture := range mock.AvailabilityFixtures() { + target := mockAvailabilityConfigTarget(fixture.Target) + if target.ID != targetID { + continue + } + response := availabilityTestResponse{ + Success: fixture.Available, + LatencyMillis: fixture.LatencyMillis, + Error: fixture.LastError, + } + return response, true + } + return availabilityTestResponse{}, false +} + +func mockAvailabilityConfigTarget(target mock.AvailabilityTargetFixture) config.AvailabilityTarget { + return config.NormalizeAvailabilityTarget(config.AvailabilityTarget{ + ID: target.ID, + Name: target.Name, + Address: target.Address, + Protocol: config.AvailabilityProbeProtocol(target.Protocol), + Port: target.Port, + Path: target.Path, + Enabled: target.Enabled, + PollIntervalSecs: target.PollIntervalSecs, + TimeoutMillis: target.TimeoutMillis, + FailureThreshold: target.FailureThreshold, + }) +} + +func mockAvailabilityProbeStatus(fixture mock.AvailabilityFixture, target config.AvailabilityTarget) monitoring.AvailabilityProbeStatus { + return monitoring.AvailabilityProbeStatus{ + TargetID: target.ID, + Name: target.DisplayName(), + Address: target.Address, + Protocol: string(target.Protocol), + Enabled: target.Enabled, + Available: fixture.Available, + LastChecked: fixture.LastChecked, + LastSuccess: fixture.LastSuccess, + LatencyMillis: fixture.LatencyMillis, + ConsecutiveFailures: fixture.ConsecutiveFailures, + LastError: fixture.LastError, + FailureThreshold: target.EffectiveFailureThreshold(), + } +} + func mockPlatformTimePointer(value time.Time) *time.Time { if value.IsZero() { return nil diff --git a/internal/config/availability.go b/internal/config/availability.go index 34c0b25a6..395c06d46 100644 --- a/internal/config/availability.go +++ b/internal/config/availability.go @@ -197,13 +197,13 @@ func normalizeAvailabilityAddress(raw string) string { 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()) } } + if host, _, err := net.SplitHostPort(value); err == nil && strings.TrimSpace(host) != "" { + return strings.Trim(strings.TrimSpace(host), "[]") + } return strings.Trim(value, "[]") } diff --git a/internal/config/availability_test.go b/internal/config/availability_test.go index dbb345b91..6be2de496 100644 --- a/internal/config/availability_test.go +++ b/internal/config/availability_test.go @@ -41,6 +41,18 @@ func TestNormalizeAvailabilityTargetReducesICMPAddressToHost(t *testing.T) { } } +func TestAvailabilityTargetProbeAddressUsesHTTPHostname(t *testing.T) { + target := NormalizeAvailabilityTarget(AvailabilityTarget{ + Address: "http://solar-inverter.lab.local/status", + Protocol: AvailabilityProbeHTTP, + Enabled: true, + }) + + if got := target.ProbeAddress(); got != "solar-inverter.lab.local" { + t.Fatalf("ProbeAddress() = %q, want solar-inverter.lab.local", got) + } +} + func TestAvailabilityTargetHTTPURLAppliesPortAndPath(t *testing.T) { target := NormalizeAvailabilityTarget(AvailabilityTarget{ Address: "device.local/status", diff --git a/internal/mock/availability_fixtures.go b/internal/mock/availability_fixtures.go new file mode 100644 index 000000000..e771da695 --- /dev/null +++ b/internal/mock/availability_fixtures.go @@ -0,0 +1,394 @@ +package mock + +import ( + "fmt" + "net" + "net/url" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/storagehealth" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +const ( + mockAvailabilityProbeICMP = "icmp" + mockAvailabilityProbeTCP = "tcp" + mockAvailabilityProbeHTTP = "http" + + mockAvailabilityDefaultPollIntervalSecs = 60 + mockAvailabilityDefaultTimeoutMillis = 2000 + mockAvailabilityDefaultFailureThreshold = 2 +) + +// AvailabilityTargetFixture is the config-free fixture contract for an +// agentless availability endpoint. The API layer converts it into +// config.AvailabilityTarget when serving configuration-shaped responses. +type AvailabilityTargetFixture struct { + ID string + Name string + Address string + Protocol string + Port int + Path string + Enabled bool + PollIntervalSecs int + TimeoutMillis int + FailureThreshold int +} + +// AvailabilityFixture describes an agentless mock endpoint that represents +// infrastructure Pulse cannot manage through an API or installed agent. +type AvailabilityFixture struct { + Target AvailabilityTargetFixture + Available bool + LastChecked time.Time + LastSuccess time.Time + LatencyMillis int64 + ConsecutiveFailures int + LastError string +} + +func AvailabilityFixtures() []AvailabilityFixture { + if !IsMockEnabled() { + return nil + } + return CurrentFixtureGraph().AvailabilityFixtures +} + +func AvailabilityTargets() []AvailabilityTargetFixture { + fixtures := AvailabilityFixtures() + out := make([]AvailabilityTargetFixture, 0, len(fixtures)) + for _, fixture := range fixtures { + target := normalizeAvailabilityTargetFixture(fixture.Target) + if strings.TrimSpace(target.ID) == "" { + continue + } + out = append(out, target) + } + return out +} + +func normalizeAvailabilityTargetFixture(target AvailabilityTargetFixture) AvailabilityTargetFixture { + target.ID = strings.TrimSpace(target.ID) + target.Name = strings.TrimSpace(target.Name) + target.Protocol = strings.ToLower(strings.TrimSpace(target.Protocol)) + if target.Protocol == "" { + target.Protocol = mockAvailabilityProbeICMP + } + if target.Protocol == mockAvailabilityProbeHTTP { + target.Address = strings.TrimSpace(target.Address) + } else { + target.Address = normalizeAvailabilityFixtureAddress(target.Address) + } + target.Path = strings.TrimSpace(target.Path) + if target.PollIntervalSecs <= 0 { + target.PollIntervalSecs = mockAvailabilityDefaultPollIntervalSecs + } + if target.TimeoutMillis <= 0 { + target.TimeoutMillis = mockAvailabilityDefaultTimeoutMillis + } + if target.FailureThreshold <= 0 { + target.FailureThreshold = mockAvailabilityDefaultFailureThreshold + } + return target +} + +func (t AvailabilityTargetFixture) displayName() string { + if name := strings.TrimSpace(t.Name); name != "" { + return name + } + return strings.TrimSpace(t.Address) +} + +func (t AvailabilityTargetFixture) probeAddress() string { + return normalizeAvailabilityFixtureAddress(t.Address) +} + +func (t AvailabilityTargetFixture) effectivePollIntervalSecs() int { + if t.PollIntervalSecs > 0 { + return t.PollIntervalSecs + } + return mockAvailabilityDefaultPollIntervalSecs +} + +func (t AvailabilityTargetFixture) effectiveTimeoutMillis() int { + if t.TimeoutMillis > 0 { + return t.TimeoutMillis + } + return mockAvailabilityDefaultTimeoutMillis +} + +func (t AvailabilityTargetFixture) effectiveFailureThreshold() int { + if t.FailureThreshold > 0 { + return t.FailureThreshold + } + return mockAvailabilityDefaultFailureThreshold +} + +func normalizeAvailabilityFixtureAddress(raw string) string { + value := strings.TrimSpace(raw) + if value == "" { + return "" + } + if strings.Contains(value, "://") { + if u, err := url.Parse(value); err == nil && strings.TrimSpace(u.Hostname()) != "" { + return strings.TrimSpace(u.Hostname()) + } + } + if host, _, err := net.SplitHostPort(value); err == nil && strings.TrimSpace(host) != "" { + return strings.Trim(strings.TrimSpace(host), "[]") + } + return strings.Trim(value, "[]") +} + +func defaultAvailabilityFixtures(now time.Time) []AvailabilityFixture { + base := normalizeAvailabilityFixtureTime(now) + return []AvailabilityFixture{ + { + Target: normalizeAvailabilityTargetFixture(AvailabilityTargetFixture{ + ID: "mock-availability-ups", + Name: "Rack UPS network card", + Address: "ups-rack-a.lab.local", + Protocol: mockAvailabilityProbeICMP, + Enabled: true, + PollIntervalSecs: 30, + TimeoutMillis: 1000, + FailureThreshold: 2, + }), + Available: true, + LastChecked: base.Add(-8 * time.Second), + LastSuccess: base.Add(-8 * time.Second), + LatencyMillis: 3, + }, + { + Target: normalizeAvailabilityTargetFixture(AvailabilityTargetFixture{ + ID: "mock-availability-mqtt-meter", + Name: "MQTT power meter", + Address: "power-meter-01.lab.local", + Protocol: mockAvailabilityProbeTCP, + Port: 1883, + Enabled: true, + PollIntervalSecs: 30, + TimeoutMillis: 1500, + FailureThreshold: 2, + }), + Available: true, + LastChecked: base.Add(-14 * time.Second), + LastSuccess: base.Add(-14 * time.Second), + LatencyMillis: 7, + }, + { + Target: normalizeAvailabilityTargetFixture(AvailabilityTargetFixture{ + ID: "mock-availability-solar-inverter", + Name: "Solar inverter web panel", + Address: "http://solar-inverter.lab.local/", + Protocol: mockAvailabilityProbeHTTP, + Path: "/status", + Enabled: true, + PollIntervalSecs: 60, + TimeoutMillis: 2000, + FailureThreshold: 2, + }), + Available: false, + LastChecked: base.Add(-18 * time.Second), + LastSuccess: base.Add(-3 * time.Minute), + ConsecutiveFailures: 1, + LastError: "http probe returned 503 Service Unavailable", + }, + { + Target: normalizeAvailabilityTargetFixture(AvailabilityTargetFixture{ + ID: "mock-availability-door-controller", + Name: "Workshop door controller", + Address: "10.24.40.45", + Protocol: mockAvailabilityProbeICMP, + Enabled: true, + PollIntervalSecs: 30, + TimeoutMillis: 1000, + FailureThreshold: 2, + }), + Available: false, + LastChecked: base.Add(-11 * time.Second), + LastSuccess: base.Add(-9 * time.Minute), + ConsecutiveFailures: 3, + LastError: "icmp probe timed out", + }, + } +} + +func cloneAvailabilityFixtures(in []AvailabilityFixture) []AvailabilityFixture { + if in == nil { + return nil + } + out := make([]AvailabilityFixture, len(in)) + copy(out, in) + return out +} + +func rebaseAvailabilityFixtures(fixtures []AvailabilityFixture, now time.Time) []AvailabilityFixture { + target := normalizeAvailabilityFixtureTime(now) + if len(fixtures) == 0 { + return defaultAvailabilityFixtures(target) + } + out := cloneAvailabilityFixtures(fixtures) + anchor := availabilityFixturesFreshness(fixtures) + if anchor.IsZero() { + anchor = target + } + shift := target.Sub(anchor) + for i := range out { + out[i].Target = normalizeAvailabilityTargetFixture(out[i].Target) + out[i].LastChecked = shiftTime(fixtures[i].LastChecked, shift, target) + out[i].LastSuccess = shiftTime(fixtures[i].LastSuccess, shift, target) + } + return out +} + +func availabilityFixtureRecords(fixtures []AvailabilityFixture, now time.Time) []unifiedresources.IngestRecord { + if len(fixtures) == 0 { + return nil + } + target := normalizeAvailabilityFixtureTime(now) + out := make([]unifiedresources.IngestRecord, 0, len(fixtures)) + for _, fixture := range fixtures { + record, ok := availabilityFixtureRecord(fixture, target) + if ok { + out = append(out, record) + } + } + return out +} + +func availabilityFixtureRecord(fixture AvailabilityFixture, now time.Time) (unifiedresources.IngestRecord, bool) { + target := normalizeAvailabilityTargetFixture(fixture.Target) + if strings.TrimSpace(target.ID) == "" { + return unifiedresources.IngestRecord{}, false + } + lastSeen := fixture.LastChecked + if lastSeen.IsZero() { + lastSeen = now + } + 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: fixture.Available, + LastChecked: fixture.LastChecked, + LastSuccess: fixture.LastSuccess, + LatencyMillis: fixture.LatencyMillis, + ConsecutiveFailures: fixture.ConsecutiveFailures, + LastError: fixture.LastError, + FailureThreshold: target.effectiveFailureThreshold(), + PollIntervalSeconds: target.effectivePollIntervalSecs(), + TimeoutMillis: target.effectiveTimeoutMillis(), + } + resource := unifiedresources.Resource{ + Type: unifiedresources.ResourceTypeNetworkEndpoint, + Technology: string(target.Protocol), + Name: target.displayName(), + Status: availabilityFixtureResourceStatus(target, fixture), + LastSeen: lastSeen, + UpdatedAt: now, + Sources: []unifiedresources.DataSource{unifiedresources.SourceAvailability}, + Tags: availabilityFixtureTags(target), + Availability: data, + } + if incident := availabilityFixtureIncident(target, fixture, lastSeen); incident != nil { + resource.Incidents = []unifiedresources.ResourceIncident{*incident} + } + + return unifiedresources.IngestRecord{ + SourceID: target.ID, + Resource: resource, + Identity: availabilityFixtureIdentity(target), + }, true +} + +func availabilityFixtureResourceStatus(target AvailabilityTargetFixture, fixture AvailabilityFixture) unifiedresources.ResourceStatus { + if !target.Enabled { + return unifiedresources.StatusUnknown + } + if fixture.LastChecked.IsZero() { + return unifiedresources.StatusUnknown + } + if fixture.Available { + return unifiedresources.StatusOnline + } + if fixture.ConsecutiveFailures >= target.effectiveFailureThreshold() { + return unifiedresources.StatusOffline + } + return unifiedresources.StatusWarning +} + +func availabilityFixtureIncident(target AvailabilityTargetFixture, fixture AvailabilityFixture, startedAt time.Time) *unifiedresources.ResourceIncident { + if !target.Enabled || fixture.Available || fixture.LastChecked.IsZero() { + return nil + } + if fixture.ConsecutiveFailures < target.effectiveFailureThreshold() { + return nil + } + summary := fmt.Sprintf("%s is unreachable by %s probe", target.displayName(), strings.ToUpper(target.Protocol)) + if strings.TrimSpace(fixture.LastError) != "" { + summary += ": " + fixture.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 availabilityFixtureIdentity(target AvailabilityTargetFixture) unifiedresources.ResourceIdentity { + identity := unifiedresources.ResourceIdentity{} + if ip := net.ParseIP(target.probeAddress()); ip != nil { + identity.IPAddresses = []string{ip.String()} + return identity + } + if host := target.probeAddress(); host != "" { + identity.Hostnames = []string{host} + } + return identity +} + +func availabilityFixtureTags(target AvailabilityTargetFixture) []string { + tags := []string{"agentless", "no-agent"} + switch target.Protocol { + case mockAvailabilityProbeHTTP: + tags = append(tags, "web-interface") + case mockAvailabilityProbeTCP: + if target.Port == 1883 { + tags = append(tags, "mqtt") + } + } + return tags +} + +func availabilityFixturesFreshness(fixtures []AvailabilityFixture) time.Time { + var freshness time.Time + for _, fixture := range fixtures { + for _, candidate := range []time.Time{fixture.LastChecked, fixture.LastSuccess} { + if candidate.IsZero() { + continue + } + if freshness.IsZero() || candidate.After(freshness) { + freshness = candidate + } + } + } + return freshness +} + +func normalizeAvailabilityFixtureTime(value time.Time) time.Time { + if value.IsZero() { + return time.Now().UTC() + } + return value.UTC() +} diff --git a/internal/mock/fixture_graph.go b/internal/mock/fixture_graph.go index 4f34206c9..95d49c0a8 100644 --- a/internal/mock/fixture_graph.go +++ b/internal/mock/fixture_graph.go @@ -10,12 +10,13 @@ import ( ) // FixtureGraph is the canonical mock runtime owner for snapshot-backed and -// provider-backed platform fixtures. All mock projections should derive from -// this graph rather than mixing independent snapshot and provider helpers. +// provider-backed fixtures. All mock projections should derive from this graph +// rather than mixing independent snapshot and provider helpers. type FixtureGraph struct { - State models.StateSnapshot - AlertHistory []models.Alert - PlatformFixtures PlatformFixtures + State models.StateSnapshot + AlertHistory []models.Alert + PlatformFixtures PlatformFixtures + AvailabilityFixtures []AvailabilityFixture } func emptyFixtureGraph() FixtureGraph { @@ -27,8 +28,9 @@ func emptyFixtureGraph() FixtureGraph { func buildFixtureGraph(cfg MockConfig, now time.Time) FixtureGraph { setMockUpdateInterval(cfg.UpdateInterval) graph := FixtureGraph{ - State: buildFixtureState(cfg), - PlatformFixtures: defaultPlatformFixtures(), + State: buildFixtureState(cfg), + PlatformFixtures: defaultPlatformFixtures(), + AvailabilityFixtures: defaultAvailabilityFixtures(now), } applyDemoScenarioGraph(&graph, now) syncMetricRoleRegistryFromGraph(graph) @@ -40,9 +42,10 @@ func buildFixtureGraph(cfg MockConfig, now time.Time) FixtureGraph { func cloneFixtureGraph(in FixtureGraph) FixtureGraph { return FixtureGraph{ - State: cloneState(in.State), - AlertHistory: append([]models.Alert(nil), in.AlertHistory...), - PlatformFixtures: clonePlatformFixtures(in.PlatformFixtures), + State: cloneState(in.State), + AlertHistory: append([]models.Alert(nil), in.AlertHistory...), + PlatformFixtures: clonePlatformFixtures(in.PlatformFixtures), + AvailabilityFixtures: cloneAvailabilityFixtures(in.AvailabilityFixtures), } } @@ -56,6 +59,7 @@ func (g *FixtureGraph) UpdateMetrics(cfg MockConfig, now time.Time) { syncMetricRoleRegistryFromGraph(*g) updateFixtureStateMetricsAt(&g.State, cfg, now) g.PlatformFixtures = rebasePlatformFixtures(g.PlatformFixtures, now) + g.AvailabilityFixtures = rebaseAvailabilityFixtures(g.AvailabilityFixtures, now) applyDemoScenarioGraph(g, now) syncMetricRoleRegistryFromGraph(*g) } diff --git a/internal/mock/platform_fixtures.go b/internal/mock/platform_fixtures.go index ef3d8b767..a2263c98e 100644 --- a/internal/mock/platform_fixtures.go +++ b/internal/mock/platform_fixtures.go @@ -117,12 +117,19 @@ func defaultTrueNASConnectionFixture(fixtures PlatformFixtures) TrueNASConnectio } func SupplementalRecords(source unifiedresources.DataSource) []unifiedresources.IngestRecord { - fixtures := currentOrDefaultPlatformFixtures() - switch normalizePlatformSource(source) { + if IsMockEnabled() { + return CurrentFixtureGraph().SupplementalRecords(source) + } + + platformFixtures := defaultPlatformFixtures() + now := time.Now().UTC() + switch normalizeSupplementalSource(source) { case unifiedresources.SourceTrueNAS: - return truenas.FixtureRecords(fixtures.TrueNAS) + return truenas.FixtureRecords(platformFixtures.TrueNAS) case unifiedresources.SourceVMware: - return vmware.FixtureRecords(fixtures.VMware) + return vmware.FixtureRecords(platformFixtures.VMware) + case unifiedresources.SourceAvailability: + return availabilityFixtureRecords(defaultAvailabilityFixtures(now), now) default: return nil } @@ -135,6 +142,14 @@ func PlatformOwnedSources() []unifiedresources.DataSource { } } +func SupplementalOwnedSources() []unifiedresources.DataSource { + return []unifiedresources.DataSource{ + unifiedresources.SourceTrueNAS, + unifiedresources.SourceVMware, + unifiedresources.SourceAvailability, + } +} + func UnifiedResourceSnapshot() ([]unifiedresources.Resource, time.Time) { if !IsMockEnabled() { return nil, time.Time{} @@ -145,9 +160,9 @@ func UnifiedResourceSnapshot() ([]unifiedresources.Resource, time.Time) { func (g FixtureGraph) UnifiedResourceSnapshot() ([]unifiedresources.Resource, time.Time) { registry := unifiedresources.NewRegistry(nil) - registry.IngestSnapshot(unifiedresources.SnapshotWithoutSources(g.State, PlatformOwnedSources())) + registry.IngestSnapshot(unifiedresources.SnapshotWithoutSources(g.State, SupplementalOwnedSources())) - for _, source := range PlatformOwnedSources() { + for _, source := range SupplementalOwnedSources() { records := g.SupplementalRecords(source) if len(records) == 0 { continue @@ -159,6 +174,7 @@ func (g FixtureGraph) UnifiedResourceSnapshot() ([]unifiedresources.Resource, ti for _, candidate := range []time.Time{ trueNASCollectedAt(g.PlatformFixtures.TrueNAS), g.PlatformFixtures.VMware.CollectedAt, + availabilityFixturesFreshness(g.AvailabilityFixtures), } { if candidate.IsZero() { continue @@ -179,11 +195,13 @@ func (g FixtureGraph) UnifiedResourceSnapshot() ([]unifiedresources.Resource, ti } func (g FixtureGraph) SupplementalRecords(source unifiedresources.DataSource) []unifiedresources.IngestRecord { - switch normalizePlatformSource(source) { + switch normalizeSupplementalSource(source) { case unifiedresources.SourceTrueNAS: return truenas.FixtureRecords(g.PlatformFixtures.TrueNAS) case unifiedresources.SourceVMware: return vmware.FixtureRecords(g.PlatformFixtures.VMware) + case unifiedresources.SourceAvailability: + return availabilityFixtureRecords(g.AvailabilityFixtures, availabilityFixturesFreshness(g.AvailabilityFixtures)) default: return nil } @@ -523,12 +541,14 @@ func shiftTime(value time.Time, shift time.Duration, fallback time.Time) time.Ti return value.Add(shift) } -func normalizePlatformSource(source unifiedresources.DataSource) unifiedresources.DataSource { +func normalizeSupplementalSource(source unifiedresources.DataSource) unifiedresources.DataSource { switch strings.ToLower(strings.TrimSpace(string(source))) { case "truenas": return unifiedresources.SourceTrueNAS case "vmware", "vmware-vsphere": return unifiedresources.SourceVMware + case "availability", "network-endpoint", "network-endpoints": + return unifiedresources.SourceAvailability default: return "" } diff --git a/internal/mock/platform_fixtures_test.go b/internal/mock/platform_fixtures_test.go index bf9c85454..d6d058353 100644 --- a/internal/mock/platform_fixtures_test.go +++ b/internal/mock/platform_fixtures_test.go @@ -60,6 +60,51 @@ func TestSupplementalRecordsNormalizesVMwareAlias(t *testing.T) { } } +func TestFixtureGraphProjectsAvailabilityFixturesAsNetworkEndpoints(t *testing.T) { + now := time.Date(2026, time.May, 6, 12, 0, 0, 0, time.UTC) + + graph := buildFixtureGraph(DefaultConfig, now) + resources, freshness := graph.UnifiedResourceSnapshot() + if freshness.IsZero() { + t.Fatal("expected non-zero unified resource freshness") + } + + var mqtt *unifiedresources.Resource + var door *unifiedresources.Resource + for i := range resources { + switch resources[i].Name { + case "MQTT power meter": + mqtt = &resources[i] + case "Workshop door controller": + door = &resources[i] + } + } + if mqtt == nil { + t.Fatal("expected MQTT power meter availability endpoint") + } + if mqtt.Type != unifiedresources.ResourceTypeNetworkEndpoint { + t.Fatalf("MQTT resource type = %q, want network-endpoint", mqtt.Type) + } + if mqtt.Availability == nil || mqtt.Availability.Protocol != "tcp" || mqtt.Availability.Port != 1883 { + t.Fatalf("unexpected MQTT availability metadata: %+v", mqtt.Availability) + } + if door == nil { + t.Fatal("expected workshop door controller availability endpoint") + } + if door.Status != unifiedresources.StatusOffline { + t.Fatalf("door controller status = %q, want offline", door.Status) + } + if door.Availability == nil || door.Availability.TargetID != "mock-availability-door-controller" { + t.Fatalf("unexpected door availability metadata: %+v", door.Availability) + } + if len(door.Incidents) != 1 || door.Incidents[0].Code != "availability_unreachable" { + t.Fatalf("expected availability incident, got %+v", door.Incidents) + } + if door.Canonical == nil || door.Canonical.PrimaryID != "availability:mock-availability-door-controller" { + t.Fatalf("unexpected door canonical identity: %+v", door.Canonical) + } +} + func TestBuildFixtureGraphRebasesPlatformFixtureTimestampsForDemoRuntime(t *testing.T) { now := time.Date(2026, time.March, 31, 17, 30, 0, 0, time.UTC) @@ -71,6 +116,9 @@ func TestBuildFixtureGraphRebasesPlatformFixtureTimestampsForDemoRuntime(t *test if got := graph.PlatformFixtures.VMware.CollectedAt; !got.Equal(now) { t.Fatalf("expected VMware collectedAt %s, got %s", now, got) } + if got := availabilityFixturesFreshness(graph.AvailabilityFixtures); got.IsZero() || got.Before(now.Add(-2*time.Minute)) || got.After(now) { + t.Fatalf("expected availability fixture freshness near %s, got %s", now, got) + } if got := graph.PlatformFixtures.TrueNAS.System.CollectedAt; got.IsZero() || got.Before(now.Add(-2*time.Minute)) || got.After(now) { t.Fatalf("expected rebased TrueNAS system collectedAt near %s, got %s", now, got) } @@ -98,6 +146,9 @@ func TestFixtureGraphUpdateMetricsKeepsPlatformFixtureFreshnessCurrent(t *testin if got := graph.PlatformFixtures.VMware.CollectedAt; !got.Equal(later) { t.Fatalf("expected rebased VMware collectedAt %s, got %s", later, got) } + if got := availabilityFixturesFreshness(graph.AvailabilityFixtures); got.IsZero() || got.Before(later.Add(-2*time.Minute)) || got.After(later) { + t.Fatalf("expected availability fixture freshness near %s, got %s", later, got) + } if len(graph.PlatformFixtures.VMware.Hosts) == 0 || len(graph.PlatformFixtures.VMware.Hosts[0].RecentEvents) == 0 { t.Fatal("expected canonical VMware fixtures with host events") } diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 504bea929..d7b8bb014 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -3625,7 +3625,7 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 175, + "line": 178, "heading_line": 101, } ],