mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
@@ -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.
|
||||
|
||||
+55
-3
@@ -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>
|
||||
|
||||
+66
@@ -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);
|
||||
});
|
||||
});
|
||||
+43
@@ -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,
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "[]")
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user