Add mock availability endpoint fixtures

Refs #1460
This commit is contained in:
rcourtman
2026-05-06 14:08:03 +01:00
parent 1f3e3ec9cd
commit 2f5aa20122
34 changed files with 1371 additions and 54 deletions
@@ -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
@@ -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
@@ -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.
@@ -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
@@ -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
@@ -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.
@@ -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<keyof AvailabilityForm> = new Set([
'path',
'port',
'protocol',
]);
export const AvailabilityTargetSlot: Component<AvailabilityTargetSlotProps> = (props) => {
const [form, setForm] = createSignal<AvailabilityForm>(newAvailabilityForm());
const [selectedPreset, setSelectedPreset] = createSignal<AvailabilityTargetPresetID>(
CUSTOM_AVAILABILITY_PRESET_ID,
);
const [loading, setLoading] = createSignal(false);
const [saving, setSaving] = createSignal(false);
const [testing, setTesting] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [testResult, setTestResult] = createSignal<AvailabilityTestResponse | null>(null);
const updateForm = (patch: Partial<AvailabilityForm>) => {
const updateForm = (patch: Partial<AvailabilityForm>, 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<AvailabilityTargetSlotProps> = (p
</Show>
<div class="grid gap-4 sm:grid-cols-2">
<FormSelect
label="Preset"
value={selectedPreset()}
onChange={(event) =>
handlePresetChange(event.currentTarget.value as AvailabilityTargetPresetID)
}
fieldClass="sm:col-span-2"
>
<option value={CUSTOM_AVAILABILITY_PRESET_ID}>Custom endpoint</option>
{AVAILABILITY_TARGET_PRESETS.map((preset) => (
<option value={preset.id}>{preset.label}</option>
))}
</FormSelect>
<label class={formField}>
<span class={formLabel}>Name</span>
<input
@@ -203,7 +255,7 @@ export const AvailabilityTargetSlot: Component<AvailabilityTargetSlotProps> = (p
class={formControl}
value={form().address}
onInput={(event) => updateForm({ address: event.currentTarget.value })}
placeholder={form().protocol === 'http' ? 'http://device.local/status' : 'device.local'}
placeholder={addressPlaceholder()}
/>
<span class={formHelpText}>
{form().protocol === 'icmp'
@@ -221,7 +273,7 @@ export const AvailabilityTargetSlot: Component<AvailabilityTargetSlotProps> = (p
inputMode="numeric"
value={form().port}
onInput={(event) => updateForm({ port: event.currentTarget.value })}
placeholder={form().protocol === 'http' ? 'Optional' : '1883'}
placeholder={portPlaceholder()}
/>
</label>
</Show>
@@ -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(() => <AvailabilityTargetSlot onCancel={vi.fn()} onSaved={onSaved} />);
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);
});
});
@@ -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);
});
});
@@ -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 = <T extends AvailabilityPresetFields>(
form: T,
presetId: string,
): T => {
const preset = availabilityPresetById(presetId);
if (!preset) return form;
return {
...form,
protocol: preset.protocol,
port: preset.port ?? '',
path: preset.path ?? '',
};
};
@@ -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<UseUnifiedResourcesModule['useUnifiedResources']> | 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,
@@ -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,
+1 -1
View File
@@ -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 =
@@ -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(
@@ -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<string, unknown>).sources).toEqual(['availability']);
});
it('preserves richer existing resource details when realtime updates are thinner', () => {
const existing: Resource = {
id: 'node-1',
@@ -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');
});
@@ -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');
});
@@ -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<string, unknown> | 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<string, unknown> & { 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]);
@@ -30,6 +30,10 @@ import {
getPreferredResourceClusterName,
getPreferredResourceHostname,
} from '@/utils/resourceIdentity';
import {
resolvePlatformTypeFromSources,
resolveSourceTypeFromSources,
} from '@/utils/sourcePlatforms';
type JsonRecord = Record<string, unknown>;
@@ -66,10 +70,7 @@ const getCanonicalPlatformId = (resource: Resource): string | undefined => {
export const resourcePlatformData = (resource: Resource): Record<string, unknown> | 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:
@@ -94,6 +94,10 @@ const RESOURCE_TYPE_PRESENTATION: Partial<Record<ResourceType | string, Resource
label: 'Ceph',
badgeClasses: DEFAULT_BADGE_CLASSES,
},
'network-endpoint': {
label: 'Network Endpoint',
badgeClasses: DEFAULT_BADGE_CLASSES,
},
};
const EXTERNAL_TYPE_PRESENTATION: Record<string, ResourceTypePresentation> = {
+7 -2
View File
@@ -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';
+14
View File
@@ -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
@@ -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)
+6
View File
@@ -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)
@@ -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)
}
}
+43
View File
@@ -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 {
+126
View File
@@ -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
+3 -3
View File
@@ -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, "[]")
}
+12
View File
@@ -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",
+394
View File
@@ -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()
}
+14 -10
View File
@@ -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)
}
+28 -8
View File
@@ -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 ""
}
+51
View File
@@ -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")
}
@@ -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,
}
],