Build canonical availability resource facets

This commit is contained in:
rcourtman
2026-07-19 04:40:36 +01:00
parent 07a4d05ca3
commit 69533c7afb
62 changed files with 2720 additions and 364 deletions
+13
View File
@@ -115,6 +115,14 @@ Note: `GET /api/resources` is optimized for list views. Some large, platform-spe
Note: guest disk usage percentages use `-1` as an "unknown" sentinel — reported when a VM is stopped or its guest agent is unavailable, so there is no filesystem view to measure. Consumers should treat negative values as "no data", not as a percentage; the accompanying `diskStatusReason` field (e.g. `vm-stopped`, `agent-disabled`) says why.
Availability is an additive resource facet. `availability` is the compatibility
summary used by existing clients; `availabilityChecks` contains every check
attached to the resource. Each check can include `correlationState`
(`attached`, `standalone`, `ambiguous`, or `unresolved`), its correlation
rule/reason/candidate count, and an `evidence` envelope with observation and
validity timestamps. Attached targets also add a `checks` relationship and do
not appear as separate `network-endpoint` rows.
`GET /api/resources/stats`
Returns aggregations (counts + health rollups).
@@ -1325,6 +1333,11 @@ Target payload fields:
- `failureThreshold` - Number of consecutive failures before alerting; defaults to 2.
- `linkedResourceId` - Optional resource id hint for attaching the probe facet to an existing resource.
An explicit `linkedResourceId` is authoritative and fails closed when it
cannot resolve. Without it, Pulse attaches only on one exact normalized IP or
hostname match. Zero matches remain standalone and multiple matches remain
ambiguous; Pulse does not guess.
Example ping-only target:
```json
+9
View File
@@ -185,6 +185,15 @@ Unified Resources is now the canonical model and endpoint family:
- Canonical: `/api/resources`
Availability checks now attach to an existing canonical resource when an
explicit `linkedResourceId` resolves or one normalized IP/hostname match is
unambiguous. Attached checks disappear from the standalone Availability checks
inventory and appear on the owning platform row/detail instead. API consumers
should accept the additive `availabilityChecks`, correlation, evidence, and
`checks` relationship fields; the existing singular `availability` field
remains as a compatibility summary. Ambiguous or invalid links stay
standalone/unresolved and are never guessed.
### License and Entitlements
Pulse v6 feature gating is driven by the entitlements endpoint:
@@ -1,12 +1,12 @@
# Pulse v6 Operational Trust Implementation Spec
Last updated: 2026-07-18
Last updated: 2026-07-19
Status: ACTIVE
Primary governance surface:
- `status.json.candidate_lanes.protection-posture-attention-queue`
Related governed surface:
- `status.json.candidate_lanes.availability-as-resource-facet`
Resolved related governed surface:
- `internal/records/operational-trust-availability-resource-facet-2026-07-19.md`
## Intent
@@ -889,9 +889,10 @@ Primary candidate:
- `protection-posture-attention-queue`
Sequential related candidate:
Resolved sequential related candidate:
- `availability-as-resource-facet`
- `availability-as-resource-facet`, closed by
`internal/records/operational-trust-availability-resource-facet-2026-07-19.md`
The primary candidate's subsystem mapping must include the canonical owners
that this implementation touches, including alerts, notifications, storage and
@@ -0,0 +1,160 @@
# Operational Trust: Availability as a Resource Facet
Date: 2026-07-19
## Decision
Availability is evidence about a canonical resource, not a parallel inventory
taxonomy.
Pulse attaches a saved check in this order:
1. an explicit canonical resource link, which is authoritative and fails closed
2. one exact normalized IP address match
3. one exact normalized hostname match
Zero automatic matches remain standalone. Multiple matches are ambiguous.
Invalid explicit links are unresolved and do not fall back to address matching.
Availability-owned endpoints never become attachment candidates.
Every attached check is retained in `availabilityChecks`; the singular
`availability` field remains an additive compatibility summary. Each attached
target emits a `checks` relationship and a canonical evidence envelope bound to
the owning resource. A second check attached to the same resource remains on
that resource and does not reappear as duplicate standalone inventory.
## Runtime Result
- `internal/monitoring/availability_poller.go` authors freshness-bounded
operational-trust evidence. A never-observed target is partial/unknown.
- `internal/unifiedresources/availability.go` owns plural facet normalization,
compatibility-summary selection, and exact target lookup.
- `internal/unifiedresources/registry.go` owns explicit, unique address, and
unique hostname correlation plus typed attached/standalone/ambiguous/
unresolved outcomes.
- Attached evidence is rebound to the canonical subject and records the exact
identity-correlation rule.
- `internal/alerts/unified_incidents.go` routes availability incidents on any
resource type through the canonical alert lifecycle and selects evidence by
the incident's exact target id.
- REST, websocket, workload, Docker, and Standalone projections preserve the
additive plural/correlation/evidence fields without a per-row fetch.
- The owning platform row keeps one compact summary. Workload and Docker detail
surfaces render protocol, complete target, latest result, latency, freshness,
and last observation for every attached check.
- Expired successful evidence renders `Stale`, never `Up` or `Responding
normally`; an unobserved check renders `Not checked`.
- Attached resources are excluded from the standalone Availability checks
inventory.
## User Lens
User job: “Tell me whether this machine or service is reachable, on the resource
I already know, and show me when that answer stopped being trustworthy.”
Live exercise:
- Opened Docker Overview in the authenticated product.
- The `Tower` host row showed one compact `TCP` availability facet.
- Expanded the deepest host detail.
- The detail showed `Availability`, target `192.168.0.8:8007`, method
`TCP 8007`, result `Up`, latency `1ms`, checked age, and `fresh`.
- Opened Machines > Availability checks.
- The attached `Tower` host was absent; only the two genuinely standalone local
targets remained.
Distance to answer is one platform navigation plus one row expansion. Every
default-row element is actionable: the compact facet signals whether to open
detail; protocol, target, result, freshness, and observation time explain what
was tested and whether it can still be trusted. Provider-forensic correlation
reason and evidence stay in detail rather than widening the row.
Keep / demote / cut:
- Keep one compact availability summary on the owning row.
- Keep complete current-state and freshness detail in the expansion.
- Demote correlation/evidence forensics to detail and API payloads.
- Cut attached targets from standalone primary inventory.
- Cut green reassurance for stale successful observations.
- Cut guessed correlation and endpoint-only lifecycle forks.
## User Evidence
- [#1460: Simple ping-based monitoring](https://github.com/rcourtman/Pulse/issues/1460)
asks Pulse to monitor devices that cannot run an agent or SSH.
- [#1565: UDP/service availability without an agent](https://github.com/rcourtman/Pulse/issues/1565)
describes the burden of maintaining a separate `nmap` plus email script.
- [#1568: not all availability checks are shown](https://github.com/rcourtman/Pulse/issues/1568)
demonstrates that missing or duplicate check inventory is a trust defect.
- [#1582: failure threshold timing mismatch](https://github.com/rcourtman/Pulse/issues/1582)
demonstrates that observation timing and freshness must be explicit.
- [Discussion #1508: crashed VM remains green without an agent](https://github.com/rcourtman/Pulse/discussions/1508)
asks for reachability loss to affect the existing VM rather than require a
separate monitoring tool.
- [#1519: clock drift creates stale/offline loops](https://github.com/rcourtman/Pulse/issues/1519)
reinforces the separation between fresh receipt evidence and stale state.
No public report explicitly requested merging an availability target into a
VM/container row. The canonical decision is therefore an inference from the
resource-coherence and missing-check evidence above, not a claimed direct user
quote.
## Comparative Evidence
- [Checkmk host/service model](https://docs.checkmk.com/latest/en/monitoring_basics.html)
treats checks as services of a host and distinguishes unknown, pending, and
stale from down.
- [Zabbix host availability](https://www.zabbix.com/documentation/current/en/manual/web_interface/frontend_sections/data_collection/hosts)
attaches availability to host interfaces and preserves available,
unavailable, mixed, and unknown states.
- [Uptime Kuma](https://github.com/louislam/uptime-kuma) centers independent
monitor objects and keeps pending/maintenance distinct from down.
- [Grafana Synthetic Monitoring checks](https://grafana.com/docs/grafana-cloud/testing/synthetic-monitoring/create-checks/checks/)
centers standalone checks and uses labels for correlation.
- [Grafana missing-data behavior](https://grafana.com/docs/grafana/latest/alerting/guides/missing-data/)
preserves No Data, Error, and MissingSeries rather than silently resolving.
Pulse follows the attached host/resource facet pattern because it already owns a
canonical cross-platform resource model. It retains standalone endpoints only
where no canonical owner exists, while preserving the shared industry rule that
missing, stale, pending, and unknown evidence are not healthy.
## Proof
Focused backend proof covers:
- explicit link precedence and fail-closed invalid links
- exact IP and hostname attachment
- ambiguous candidate rejection
- plural checks on one canonical resource
- `checks` relationships
- evidence validation, canonical rebinding, freshness, and pre-first-probe
partial/unknown state
- attached Docker service failure through the canonical operational lifecycle
- mock graph attachment without standalone duplication
Focused frontend proof covers:
- row and detail presentation
- plural attached cards
- stale-success and unobserved truthfulness
- standalone duplicate exclusion
- freshness-aware status/filter behavior
- shared primitive and no-per-row-fetch guardrails
Deterministic browser proof:
- `tests/integration/tests/92-operational-trust-availability-facet.spec.ts`
Live browser proof was performed against the current authenticated development
runtime after the implementation build, including the Docker row, expanded host
detail, and Standalone Availability checks inventory described above.
## Governance
- Candidate lane: `availability-as-resource-facet`
- Owning lanes: L8 and L13
- Owning contracts: monitoring, unified resources, alerts, API contracts,
frontend primitives, performance and scalability, Patrol intelligence
- This record is the durable evidence for resolving and removing the candidate
and its completed availability coverage gap.
+1 -70
View File
@@ -8813,41 +8813,6 @@
"kind": "file"
}
]
},
{
"id": "availability-as-resource-facet",
"summary": "Agentless availability checks always mint a standalone network-endpoint resource and never attach to a known resource, so a check that monitors a Proxmox guest, Docker container, or Kubernetes workload appears as a disconnected duplicate on the Machines page instead of on the resource it actually monitors. The performance-and-scalability contract already requires agentless availability evidence to belong on the resource's bounded row path, but the ingest path has no cross-source attach: resolveLinkedResource only handles Proxmox<->agent links and findMatch is restricted to agent and physical_disk types, so SourceAvailability records always take the source-specific network-endpoint ID and the Availability facet never reaches an existing resource. The gap is the missing unambiguous attach (by explicit resource link or unique address/hostname correlation) plus platform-row surfacing of the attached facet.",
"owner": "project-owner",
"status": "planned",
"recorded_at": "2026-06-26",
"lane_ids": [
"L8",
"L13"
],
"subsystem_ids": [
"frontend-primitives",
"monitoring",
"unified-resources"
],
"proposed_resolution": "lane-expansion",
"coverage_impact": 7,
"evidence": [
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/subsystems/performance-and-scalability.md",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/monitoring/availability_poller.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/unifiedresources/registry.go",
"kind": "file"
}
]
}
],
"candidate_lanes": [
@@ -8905,43 +8870,9 @@
"storage-recovery",
"unified-resources"
]
},
{
"id": "availability-as-resource-facet",
"name": "Availability as Resource Facet",
"summary": "Promote availability-as-resource-facet into a governed lane expansion: extend unified-resource ingest so an availability check attaches as a facet on the known resource it monitors (explicit resource link first, unambiguous unique address/hostname correlation second, standalone network-endpoint fallback for genuinely unowned endpoints), keep the ingest on the existing unified-resource hot path with no per-row fetch, and surface the attached Availability facet on the platform resource row as a compact inline target/result readout with the protocol identity badge in the System column, per the performance-and-scalability bounded-row contract.",
"status": "planned",
"recorded_at": "2026-06-26",
"target_id": "v6-product-lane-expansion",
"current_lane_ids": [
"L8",
"L13"
],
"coverage_gap_ids": [
"availability-as-resource-facet"
],
"subsystem_ids": [
"frontend-primitives",
"monitoring",
"unified-resources"
]
}
],
"work_claims": [
{
"id": "codex-operational-trust-candidate-lane-protection-posture-attention-queue",
"agent_id": "codex-operational-trust",
"summary": "Canonical operational trust and protection evidence implementation specification",
"target_id": "v6-product-lane-expansion",
"claimed_at": "2026-07-18T21:46:08Z",
"heartbeat_at": "2026-07-18T21:46:08Z",
"expires_at": "2026-07-19T05:46:08Z",
"work_item": {
"kind": "candidate-lane",
"id": "protection-posture-attention-queue"
}
}
],
"work_claims": [],
"open_decisions": [],
"source_of_truth_file": "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md",
"resolved_decisions": [
@@ -1484,6 +1484,11 @@ the intentionally sparse public response.
`/api/resources/dashboard-summary` as a compatibility read; lifecycle
surfaces must continue to use install inventory, enrollment proof, and
fleet freshness truth from their owning contracts.
Those routes may now seed the availability provider from canonical plural
`availabilityChecks` facets so attached checks survive startup
rehydration. Lifecycle surfaces must not reinterpret an attached check,
its compatibility `availability` summary, or its evidence freshness as
agent enrollment, heartbeat, command reachability, or fleet liveness.
The same presentation-only boundary now covers compact storage summary
chart reads as well. Shared `/api/charts/storage-summary` transport may
request only the canonical `used` and `avail` storage series needed for the
@@ -377,10 +377,16 @@ changes must not reintroduce raw `filepath.Join(dataDir, ...)` joins from
caller-supplied directories or ad hoc history filenames.
Agentless availability incidents now enter alerts through the same unified
resource incident bridge as storage, PBS, VM, and host resource incidents.
`network-endpoint` resources with `SourceAvailability` incidents must create
canonical `resource-incident` alerts with provider display `Availability`;
availability alerting must not introduce a second endpoint-only evaluator or
alert identity family outside `internal/alerts/unified_incidents.go`.
Standalone `network-endpoint` resources and any canonical resource carrying an
attached availability facet must create canonical `resource-incident` alerts
with provider display `Availability`; availability alerting must not introduce
a second endpoint-only evaluator or alert identity family outside
`internal/alerts/unified_incidents.go`. When a resource carries multiple
checks, the incident `NativeID` selects the exact check evidence envelope that
is copied into the alert and its `OperationalRecord`; the singular
compatibility summary must never substitute evidence from a different target.
The same lifecycle transition then projects into Patrol like every other
canonical operational record.
Notification transport, provider delivery, queue safety, and notification API
transport now live under the explicit `notifications` subsystem inside the
@@ -7315,6 +7315,15 @@ Mock availability fixtures must still behave like saved targets: `/api/connectio
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.
Unified-resource transport adds typed availability trust fields without
changing the saved-target CRUD owner. `availability` remains the singular
compatibility summary; `availabilityChecks` is the complete attached set; each
entry may carry `correlationState`, `correlationRule`,
`correlationReason`, `correlationCandidates`, and an operational-trust
`evidence` envelope. Attached resources also expose one `checks` relationship
per saved target. REST, websocket, mock, and workload projections must preserve
those additive fields unchanged, and frontend consumers must not reconstruct
correlation or evidence from `/api/availability-targets`.
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
@@ -7786,6 +7795,16 @@ This is required for qualification clients and local operators to read,
approve, reject, and execute governed actions without weakening the same
capability checks for sessions, proxy users, or API tokens.
### Unified Agent observer report boundary
Existing host, Docker, and Kubernetes report endpoints accept observer reports
under the observer instance's own API token without a new wire payload shape.
Authority is an agent-side topology property: only the configured primary
destination may have its report response interpreted as configuration or
commands. Proxmox auto-registration remains one destination per request and
must answer a check-registration request before the agent mutates a local PVE
or PBS token.
### Protection posture transport
`GET /api/recovery/postures` is the authenticated `monitoring:read` transport
@@ -5006,6 +5006,16 @@ and latest latency or failure result once, inline in the agentless endpoint's
metric slot, while keeping recent check timing and fuller failure context in
the tooltip or drawer so operators can understand what was measured without
duplicated row chrome.
Known platform resources use that same compact presentation when availability
is attached. `AvailabilityProbeStatusCard` is the shared detail primitive for
Workloads and Docker host drawers; it renders the complete target, protocol,
latest result, latency when relevant, evidence freshness, and last observation.
Plural attached checks render as repeated bounded cards from
`availabilityChecks`, while the row keeps one compatibility summary. Expired
successful evidence must render an amber `Stale` state with no green
`Responding normally` copy, and a never-observed check must render
`Not checked`. A resource whose correlation state is `attached` must not also
appear as a primary row in the Machines `Availability checks` tab.
Operational navigation for those agentless endpoints belongs to the
frontend-primitives-owned Machines surface as a focused Availability checks tab
rather than a new primary nav item. The page may show availability checks beside
@@ -604,7 +604,14 @@ Supplemental records carry the saved target's optional `LinkedResourceID`
forward into `AvailabilityData` so the unified-resource registry can attach
the probe facet onto the referenced resource. Monitoring does not perform the
attach decision itself; it only forwards the link hint for the registry to
resolve.
resolve. Every completed probe also authors an operational-trust
`EvidenceEnvelope` with provider `availability`, collector
`availability-poller`, the saved target as its provider reference, the exact
observation/ingest times, and a validity window of twice the effective polling
interval. Before the first completed probe, evidence is explicitly partial and
unknown with reason `availability_not_observed`; monitoring must never encode
that state as a confirmed failure or a healthy observation. The registry owns
rebinding the envelope subject to a canonical resource after correlation.
Availability target kind is monitoring-owned runtime metadata, not a frontend
guess. Saved targets carry the bounded `targetKind` values `machine`, `service`,
and `device`; monitoring must preserve that value in probe status, supplemental
@@ -1735,6 +1742,15 @@ transition detection consumes that status instead of inventing a fixed stale
window: a stopped guest can have fresh inventory, while a stale source cannot
authoritatively prove either a stopped transition or recovery.
### Unified Agent destination delivery metrics
The local agent health listener exports
`pulse_agent_destination_configured{module,destination,role}` and
`pulse_agent_destination_delivery_up{module,destination,role}`. Role is bounded
to `primary` or `observer`; destination names come from validated configuration.
Observer delivery failure is visible but does not make the primary authority
unready or merge observer retry state into primary delivery health.
### PBS protection evidence collection
Direct PBS backup enumeration emits two separate storage/recovery inputs:
@@ -959,6 +959,15 @@ contextual Assistant handoff. Assistant is absent before selection and receives
explanation-only typed context without action or approval authority. Browser
proof is `tests/integration/tests/91-operational-trust-attention-workbench.spec.ts`.
Attached availability failures use this same queue and detail contract. Their
attention item keeps the owning canonical resource ID, the exact
availability-poller evidence ID, freshness, impact, and resource deep link;
Patrol must not create an endpoint-only finding family or route the operator to
a duplicate standalone check. A disconnected or expired availability
observation remains stale or unknown and must not be presented as recovered.
The attached-facet browser proof is
`tests/integration/tests/92-operational-trust-availability-facet.spec.ts`.
## Current State
The active Patrol queue now uses compact severity-accented rows for
@@ -1614,6 +1614,13 @@ result text across the resource identity cell and metric cells.
That text must derive from the existing resource payload and shared
presentation helper instead of adding a per-row fetch, extra hydration pass, or
unbounded badge stack.
The canonical payload may carry multiple attached checks in
`availabilityChecks`; `availability` remains the bounded compatibility summary
selected from that already-hydrated set. Resource rows render one compact
summary and detail surfaces iterate the bounded attached set from the same
payload. Neither plural presentation nor freshness evaluation may issue a
check-specific request, reconstruct the set from the settings API, or add a
second websocket hydration path.
The infrastructure summary hot path is now explicit shared ownership too:
`InfrastructureSummary.tsx` stays a render shell,
`useInfrastructureSummaryState.ts` owns chart polling and cache lifecycle, and
@@ -5212,6 +5212,7 @@
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"internal/monitoring/availability_poller_test.go",
"internal/monitoring/canonical_guardrails_test.go",
"internal/monitoring/monitor_backups_readstate_test.go",
"internal/monitoring/monitor_host_agents_test.go",
@@ -5732,6 +5733,7 @@
"frontend-modern/src/components/Workloads/workloadUrlSyncModel.ts",
"frontend-modern/src/hooks/useWorkloads.ts",
"frontend-modern/src/routing/routePreload.ts",
"frontend-modern/src/types/workloads.ts",
"frontend-modern/src/useAppRuntimeState.ts",
"frontend-modern/src/utils/thresholdSliderPresentation.ts",
"frontend-modern/src/utils/workloadsSummaryCache.ts",
@@ -5873,6 +5875,7 @@
"frontend-modern/src/components/Workloads/workloadTopology.ts",
"frontend-modern/src/components/Workloads/workloadUrlSyncModel.ts",
"frontend-modern/src/hooks/useWorkloads.ts",
"frontend-modern/src/types/workloads.ts",
"frontend-modern/src/utils/thresholdSliderPresentation.ts",
"frontend-modern/src/utils/workloadsSummaryCache.ts"
],
@@ -5881,6 +5884,7 @@
"exact_files": [
"frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx",
"frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.test.ts",
"frontend-modern/src/components/Workloads/__tests__/AvailabilityProbeStatusCard.test.tsx",
"frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx",
"frontend-modern/src/components/Workloads/__tests__/EnhancedCPUBar.test.tsx",
"frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx",
@@ -6498,6 +6502,7 @@
"frontend-modern/src/components/Discovery/DiscoveryTab.tsx",
"frontend-modern/src/components/Discovery/useDiscoveryTabState.ts",
"frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx",
"frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx",
"frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts",
"frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx",
"frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx",
@@ -6535,6 +6540,7 @@
"frontend-modern/src/features/docker/DockerContainerLifecycleControls.tsx",
"frontend-modern/src/features/docker/DockerContainersTable.tsx",
"frontend-modern/src/features/docker/dockerContainerTableModel.ts",
"frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx",
"frontend-modern/src/features/docker/DockerHostsTable.tsx",
"frontend-modern/src/features/docker/DockerImagesTable.tsx",
"frontend-modern/src/features/docker/DockerNativeTableShared.tsx",
@@ -6596,6 +6602,7 @@
"frontend-modern/src/types/resource.ts",
"frontend-modern/src/utils/actionAuditPresentation.ts",
"frontend-modern/src/utils/agentResources.ts",
"frontend-modern/src/utils/availabilityProbePresentation.ts",
"frontend-modern/src/utils/canonicalResourceTypes.ts",
"frontend-modern/src/utils/platformSupportManifest.generated.ts",
"frontend-modern/src/utils/resourceBadgePresentation.ts",
@@ -6678,6 +6685,7 @@
"frontend-modern/src/components/Discovery/DiscoveryTab.tsx",
"frontend-modern/src/components/Discovery/useDiscoveryTabState.ts",
"frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx",
"frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx",
"frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts",
"frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx",
"frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx",
@@ -6715,6 +6723,7 @@
"frontend-modern/src/features/docker/DockerContainerLifecycleControls.tsx",
"frontend-modern/src/features/docker/DockerContainersTable.tsx",
"frontend-modern/src/features/docker/dockerContainerTableModel.ts",
"frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx",
"frontend-modern/src/features/docker/DockerHostsTable.tsx",
"frontend-modern/src/features/docker/DockerImagesTable.tsx",
"frontend-modern/src/features/docker/DockerNativeTableShared.tsx",
@@ -6986,6 +6995,29 @@
"frontend-modern/src/utils/__tests__/resourcePolicyPresentation.test.ts"
]
},
{
"id": "availability-facet-presentation",
"label": "availability facet resource presentation proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx",
"frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx",
"frontend-modern/src/features/standalone/AvailabilityChecksTable.tsx",
"frontend-modern/src/features/standalone/standalonePageModel.ts",
"frontend-modern/src/features/standalone/StandalonePageSurface.tsx",
"frontend-modern/src/utils/availabilityProbePresentation.ts"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/components/Workloads/__tests__/AvailabilityProbeStatusCard.test.tsx",
"frontend-modern/src/features/docker/__tests__/DockerHostsTable.test.tsx",
"frontend-modern/src/features/standalone/__tests__/AvailabilityChecksTable.test.tsx",
"frontend-modern/src/features/standalone/__tests__/standalonePageModel.test.ts",
"frontend-modern/src/utils/__tests__/availabilityProbePresentation.test.ts",
"tests/integration/tests/92-operational-trust-availability-facet.spec.ts"
]
},
{
"id": "metrics-target-runtime",
"label": "unified resource metrics target proof",
@@ -7017,6 +7049,7 @@
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"internal/unifiedresources/availability_link_test.go",
"internal/unifiedresources/kubernetes_registry_test.go",
"internal/unifiedresources/pbs_pmg_registry_test.go",
"internal/unifiedresources/registry_merge_policy_test.go",
@@ -7064,7 +7097,9 @@
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"internal/unifiedresources/availability_link_test.go",
"internal/unifiedresources/canonical_id_pins_test.go",
"internal/unifiedresources/clone_test.go",
"internal/unifiedresources/code_standards_test.go",
"internal/unifiedresources/kubernetes_registry_test.go",
"internal/unifiedresources/metrics_targets_test.go",
@@ -1290,6 +1290,12 @@ recovery scope, or a storage/recovery-owned secret source.
for presentation, while storage and recovery must continue to treat
`AgentData.platform` as the normalized runtime platform.
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.
The same neutrality applies when one or more canonical
`availabilityChecks` facets attach to a storage-related resource. The
additive plural facet, its compatibility `availability` summary, evidence
freshness, and `checks` relationship remain monitoring context; they do not
mint a second storage row or become backup coverage, recovery readiness, or
restore evidence.
That neutrality includes availability targets whose `targetKind` is
`machine`. A Linux server, desktop, laptop, or Mac mini monitored by an
agentless reachability check still belongs to Availability checks rather
@@ -255,16 +255,16 @@ Alert decoration on those platform rows consumes the canonical active-alert
read model and the detector-enabled accessor. External notification activation
is not a resource-health field and must never suppress row alerts, change
resource filtering, or create a parallel platform-local alert truth.
The standalone Pulse Agent and Availability monitor may add one compact status
summary immediately above its canonical table. That summary must be derived
from the same already-loaded unified-resource slice, use canonical resource
status and `lastSeen` fields, keep the machine row indicator on that same
freshness-aware presentation so an old agent cannot stay visually green while
the summary warns, keep failed or degraded checks ahead of healthy
checks, and route management back to the canonical infrastructure or
availability settings paths. It must not introduce a page-local fetch, generic
Home dashboard, decorative chart, shadow health model, or proof strip detached
from the table it summarizes.
The standalone Machines monitor keeps resource-specific health on the canonical
table row and resource detail surface rather than duplicating row warnings in a
page-wide posture banner. Its row indicator must use the same canonical resource
status and `lastSeen` fields so an old agent cannot stay visually green.
The Availability monitor may add one compact status summary immediately above
its canonical table. That summary must be derived from the same already-loaded
unified-resource slice, keep failed or degraded checks ahead of healthy checks,
and route management back to the canonical availability settings path. It must
not introduce a page-local fetch, generic Home dashboard, decorative chart,
shadow health model, or proof strip detached from the table it summarizes.
Availability timestamps must preserve absence as absence. Monitoring and mock
adapters must project zero `LastChecked` or `LastSuccess` values as nil
canonical facet pointers so REST and WebSocket consumers render `Not checked`
@@ -1745,39 +1745,53 @@ canonical resources expose to alerts, AI, and frontend consumers.
Agentless availability checks are now canonical resources rather than
connection-only status rows. `SourceAvailability` emits `network-endpoint`
records with the saved target id, probe address, protocol, cadence, last check,
failure count, and threshold in `AvailabilityData`. Registry merge policy must
preserve that payload and incident state. An availability record attaches as a
facet onto a known resource when (a) the target carries an explicit
`LinkedResourceID` that resolves to a resource already in the registry by exact
resource id, unique source id, or unique canonical identity alias, or (b) the
probe address unambiguously matches exactly one known resource by IP through
`FindCandidates` with reason `ip` or `hostname+ip` (confidence at or above the
merge threshold). Fuzzy hostname-only correlation (reason `hostname`, confidence
below threshold), ambiguous source/canonical references, and references to
availability-owned resources must not attach. When attached, the known resource
inherits the `AvailabilityData` facet and `SourceAvailability` in its source
list, and no standalone `network-endpoint` is minted. When no link resolves, the
record falls back to a standalone `network-endpoint` as before. A second probe
must not overwrite a facet already attached by a different target; the second
probe stays standalone in that case.
failure count, threshold, correlation outcome, and evidence envelope in
`AvailabilityData`. Registry merge policy must preserve that payload and
incident state.
Correlation is fail-closed and ordered. A non-empty `LinkedResourceID` is
authoritative: it may resolve by exact canonical resource id, one unique source
id, or one unique canonical identity alias, and an invalid or ambiguous
explicit reference remains `unresolved` without falling back to address
matching. Without an explicit reference, the registry may attach only when one
normalized IP address or one exact normalized hostname matches exactly one
non-availability-owned canonical resource. Zero matches are `standalone`;
multiple matches are `ambiguous`; neither may be guessed. A genuinely
standalone target keeps its `network-endpoint`. An attached target does not
mint a duplicate endpoint.
The attached resource carries every check in the canonical
`availabilityChecks` facet, keyed by saved target id, while `availability`
remains an additive singular compatibility summary selected from that set.
Adding a second explicit or unambiguously correlated check must retain both
checks on the same resource, emit one `checks` relationship per target, and
must not force the later check into duplicate standalone inventory. Each
attached check's evidence subject is rebound to the owning canonical resource
and includes the exact correlation rule and matched field. Ambiguous and
unresolved standalone evidence carries a typed reason instead.
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.
Infrastructure row presentation must also consume that availability payload as
operator evidence, not only as badge identity. Any resource row carrying an
`AvailabilityData` facet—whether a standalone `network-endpoint` or a known
availability facet—whether a standalone `network-endpoint` or a known
guest that inherited the facet through explicit link or IP correlation—must
surface one visible probe readout from either the top-level availability field
or the live-state `platformData.availability` mirror: the System column uses
the probe protocol as the compact identity badge (`ICMP`, `TCP`, or `HTTP`),
while the metric cell shows only the target detail and latest latency or
failure result, such as `6053: 11 ms`, `/status: 503`, `3 ms`, or `timed out`.
The owning detail renders protocol, full target, latest result, latency,
freshness, and last observation for every attached check. Expired successful
evidence is `stale`, not `Up` or `Responding normally`; an unobserved check is
`Not checked`, not `Down`.
Frontend primitives owns Machines as the operational presentation for those
same agentless checks; unified resources owns the projection contract consumed
there. `StandalonePageSurface.tsx` must fetch both `agent` and
`network-endpoint` resources, keep standalone machines and availability checks
as separate buckets in `standalonePageModel.ts`, and let
as separate buckets in `standalonePageModel.ts`, exclude every resource whose
availability correlation state is `attached`, and let
`AvailabilityChecksTable.tsx` render saved probe method, target, latest result,
check age, failure count, and cadence from the canonical availability payload.
Recent check timing and fuller failure context may stay in tooltip or drawer
@@ -311,6 +311,7 @@
},
"requiredConsumers": [
{ "path": "src/components/Discovery/DiscoveryTab.tsx" },
{ "path": "src/components/Infrastructure/AvailabilityProbeStatusCard.tsx" },
{ "path": "src/components/Infrastructure/ResourceActionHistory.tsx" },
{ "path": "src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx" },
{ "path": "src/components/shared/WebInterfaceUrlField.tsx" },
@@ -0,0 +1,170 @@
import { For, Show, createMemo } from 'solid-js';
import { Activity, AlertCircle, Check } from 'lucide-solid';
import type { ResourceAvailabilityMeta } from '@/types/resource';
import { InfoCardFrame } from '@/components/shared/InfoCardFrame';
import {
getAvailabilityProbeMethodLabel,
getAvailabilityProbeEndpointLabel,
getAvailabilityProbePresentation,
} from '@/utils/availabilityProbePresentation';
import { formatRelativeTime } from '@/utils/format';
export interface AvailabilityProbeStatusCardProps {
availability: ResourceAvailabilityMeta;
}
export interface AvailabilityProbeStatusCardsProps {
availability?: ResourceAvailabilityMeta;
checks?: ResourceAvailabilityMeta[];
}
export function AvailabilityProbeStatusCards(props: AvailabilityProbeStatusCardsProps) {
const checks = createMemo(() => {
const byTarget = new Map<string, ResourceAvailabilityMeta>();
for (const check of props.checks ?? []) {
const key =
check.targetId?.trim() ||
`${check.protocol ?? ''}:${check.address ?? ''}:${check.port ?? ''}:${check.path ?? ''}`;
byTarget.set(key, check);
}
if (props.availability) {
const check = props.availability;
const key =
check.targetId?.trim() ||
`${check.protocol ?? ''}:${check.address ?? ''}:${check.port ?? ''}:${check.path ?? ''}`;
if (!byTarget.has(key)) byTarget.set(key, check);
}
return [...byTarget.values()];
});
return (
<For each={checks()}>
{(availability) => <AvailabilityProbeStatusCard availability={availability} />}
</For>
);
}
export function AvailabilityProbeStatusCard(props: AvailabilityProbeStatusCardProps) {
const isUp = () => props.availability.available === true;
const isDown = () => props.availability.available === false;
const latency = () => {
const ms = props.availability.latencyMillis;
return typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? `${Math.round(ms)}ms` : null;
};
const lastChecked = () => formatRelativeTime(props.availability.lastChecked);
const method = () => getAvailabilityProbeMethodLabel(props.availability);
const presentation = () =>
getAvailabilityProbePresentation({
type: 'network-endpoint',
platformType: 'availability',
status: isUp() ? 'online' : isDown() ? 'offline' : 'unknown',
availability: props.availability,
});
const isStale = () => presentation()?.freshnessLabel === 'stale';
const isFreshUp = () => isUp() && !isStale();
const targetAddr = () => getAvailabilityProbeEndpointLabel(props.availability);
const failureLabel = () => {
const err = (props.availability.lastError ?? '').trim();
if (!err) return null;
if (/timed?\s*out/i.test(err)) return 'Timed out';
const httpMatch = err.match(/\b([45]\d{2})\b/);
if (httpMatch) return `HTTP ${httpMatch[1]}`;
if (/refused|unreachable|no route/i.test(err)) return 'Unreachable';
return err.length > 40 ? `${err.slice(0, 40)}` : err;
};
return (
<InfoCardFrame data-testid="availability-probe-status">
<div class="flex items-center justify-between gap-2 mb-2">
<div class="flex min-w-0 items-center gap-1.5">
<Activity class="h-3.5 w-3.5 text-base-content/60" aria-hidden="true" />
<h3 class="truncate text-[11px] font-medium uppercase tracking-wide text-base-content">
Availability
</h3>
</div>
<span
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold"
classList={{
'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300':
isFreshUp(),
'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300': isDown() && !isStale(),
'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300': isStale(),
'bg-base-200 text-muted': !isUp() && !isDown() && !isStale(),
}}
>
{isStale() ? 'Stale' : isUp() ? 'Up' : isDown() ? 'Down' : 'Not checked'}
</span>
</div>
<div class="space-y-1.5 text-[11px]">
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Latency</span>
<Show
when={isUp() && latency()}
fallback={<span class="text-red-600 dark:text-red-400 font-medium"></span>}
>
<span
class="font-medium"
classList={{
'text-emerald-600 dark:text-emerald-400': !isStale(),
'text-amber-600 dark:text-amber-300': isStale(),
}}
>
{latency()}
</span>
</Show>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Method</span>
<span class="font-medium text-base-content" title={targetAddr()}>
{method()}
</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Target</span>
<span class="font-medium text-base-content truncate ml-2" title={targetAddr()}>
{targetAddr()}
</span>
</div>
<Show when={lastChecked()}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Checked</span>
<span class="text-base-content/70">{lastChecked()}</span>
</div>
</Show>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Freshness</span>
<span
class="font-medium"
classList={{
'text-amber-600 dark:text-amber-300': presentation()?.freshnessLabel === 'stale',
'text-base-content': presentation()?.freshnessLabel !== 'stale',
}}
>
{presentation()?.freshnessLabel ?? 'freshness unknown'}
</span>
</div>
<Show when={presentation()?.correlationLabel}>
{(label) => (
<div class="flex items-start justify-between gap-2">
<span class="text-muted">Resource</span>
<span class="text-right text-amber-600 dark:text-amber-300">{label()}</span>
</div>
)}
</Show>
<Show when={isDown() && failureLabel()}>
<div class="flex items-start gap-1.5 mt-1.5 pt-1.5 border-t border-base-200">
<AlertCircle class="h-3 w-3 text-red-500 shrink-0 mt-0.5" aria-hidden="true" />
<span class="text-[10px] text-red-600 dark:text-red-400">{failureLabel()}</span>
</div>
</Show>
<Show when={isFreshUp()}>
<div class="flex items-center gap-1 mt-1.5 pt-1.5 border-t border-base-200">
<Check class="h-3 w-3 text-emerald-500 shrink-0" aria-hidden="true" />
<span class="text-[10px] text-muted">Responding normally</span>
</div>
</Show>
</div>
</InfoCardFrame>
);
}
@@ -594,6 +594,11 @@ export const UnifiedResourceHostTableCard: Component<UnifiedResourceHostTableCar
</>
)}
</Show>
<span
class={`hidden shrink-0 text-[9px] xl:inline ${probe().toneClassName}`}
>
{probe().freshnessLabel}
</span>
</div>
)}
</Show>
@@ -355,9 +355,9 @@ export const AvailabilityTargetSlot: Component<AvailabilityTargetSlotProps> = (p
updateForm({ linkedResourceId: event.currentTarget.value })
}
fieldClass="sm:col-span-2"
help="Link this check to a known resource so its status appears on that resource's row. Leave empty to auto-detect by IP address."
help="Link this check to a known resource so its status appears on that resource's row. Leave empty to attach only when its IP address or full hostname has one exact match."
>
<option value="">Auto-detect by IP (recommended)</option>
<option value="">Attach on one exact address match (recommended)</option>
<Show when={linkedResourceMissing()}>
<option value={form().linkedResourceId}>
{form().linkedResourceId} (not currently discovered)
@@ -14,9 +14,13 @@ vi.mock('@/api/availabilityTargets', () => ({
},
}));
const resourceMocks = vi.hoisted(() => ({
resources: [] as Array<Record<string, unknown>>,
}));
vi.mock('@/hooks/useResources', () => ({
useResources: () => ({
resources: () => [],
resources: () => resourceMocks.resources,
}),
}));
@@ -25,6 +29,7 @@ const mockedCreate = vi.mocked(AvailabilityTargetsAPI.create);
describe('AvailabilityTargetSlot', () => {
beforeEach(() => {
vi.clearAllMocks();
resourceMocks.resources = [];
mockedCreate.mockResolvedValue({
id: 'target-1',
name: 'Rack sensor',
@@ -105,4 +110,40 @@ describe('AvailabilityTargetSlot', () => {
);
expect(onSaved).toHaveBeenCalledTimes(1);
});
it('binds an explicit canonical resource id in the saved target', async () => {
resourceMocks.resources = [
{
id: 'docker-service:api',
type: 'docker-service',
name: 'api',
displayName: 'Customer API',
platformId: 'docker-main',
platformType: 'docker',
sourceType: 'agent',
status: 'online',
lastSeen: Date.now(),
},
];
render(() => <AvailabilityTargetSlot onCancel={vi.fn()} onSaved={vi.fn()} />);
fireEvent.input(screen.getByLabelText('Name'), {
target: { value: 'Customer API' },
});
fireEvent.input(screen.getByPlaceholderText('service.local'), {
target: { value: 'api.example.test' },
});
fireEvent.change(screen.getByLabelText('Link to resource (optional)'), {
target: { value: 'docker-service:api' },
});
fireEvent.click(screen.getByRole('button', { name: 'Add service/device check' }));
await waitFor(() =>
expect(mockedCreate).toHaveBeenCalledWith(
expect.objectContaining({
linkedResourceId: 'docker-service:api',
}),
),
);
});
});
@@ -1,91 +0,0 @@
import { Show } from 'solid-js';
import { Activity, AlertCircle, Check } from 'lucide-solid';
import type { ResourceAvailabilityMeta } from '@/types/resource';
import { InfoCardFrame } from '@/components/shared/InfoCardFrame';
import { getAvailabilityProbeMethodLabel } from '@/utils/availabilityProbePresentation';
import { formatRelativeTime } from '@/utils/format';
interface AvailabilityProbeStatusCardProps {
availability: ResourceAvailabilityMeta;
}
export function AvailabilityProbeStatusCard(props: AvailabilityProbeStatusCardProps) {
const isUp = () => props.availability.available === true;
const latency = () => {
const ms = props.availability.latencyMillis;
return typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? `${Math.round(ms)}ms` : null;
};
const lastChecked = () => formatRelativeTime(props.availability.lastChecked);
const method = () => getAvailabilityProbeMethodLabel(props.availability);
const targetAddr = () => {
const addr = props.availability.address ?? '';
const port = props.availability.port;
return port ? `${addr}:${port}` : addr;
};
const failureLabel = () => {
const err = (props.availability.lastError ?? '').trim();
if (!err) return null;
if (/timed?\s*out/i.test(err)) return 'Timed out';
const httpMatch = err.match(/\b([45]\d{2})\b/);
if (httpMatch) return `HTTP ${httpMatch[1]}`;
if (/refused|unreachable|no route/i.test(err)) return 'Unreachable';
return err.length > 40 ? `${err.slice(0, 40)}` : err;
};
return (
<InfoCardFrame data-testid="availability-probe-status">
<div class="flex items-center justify-between gap-2 mb-2">
<div class="flex min-w-0 items-center gap-1.5">
<Activity class="h-3.5 w-3.5 text-base-content/60" aria-hidden="true" />
<h3 class="truncate text-[11px] font-medium uppercase tracking-wide text-base-content">
Availability
</h3>
</div>
<span
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold"
classList={{
'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300': isUp(),
'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300': !isUp(),
}}
>
{isUp() ? 'Up' : 'Down'}
</span>
</div>
<div class="space-y-1.5 text-[11px]">
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Latency</span>
<Show when={isUp() && latency()} fallback={<span class="text-red-600 dark:text-red-400 font-medium"></span>}>
<span class="font-medium text-emerald-600 dark:text-emerald-400">{latency()}</span>
</Show>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Method</span>
<span class="font-medium text-base-content" title={targetAddr()}>{method()}</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Target</span>
<span class="font-medium text-base-content truncate ml-2" title={targetAddr()}>{targetAddr()}</span>
</div>
<Show when={lastChecked()}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Checked</span>
<span class="text-base-content/70">{lastChecked()}</span>
</div>
</Show>
<Show when={!isUp() && failureLabel()}>
<div class="flex items-start gap-1.5 mt-1.5 pt-1.5 border-t border-base-200">
<AlertCircle class="h-3 w-3 text-red-500 shrink-0 mt-0.5" aria-hidden="true" />
<span class="text-[10px] text-red-600 dark:text-red-400">{failureLabel()}</span>
</div>
</Show>
<Show when={isUp()}>
<div class="flex items-center gap-1 mt-1.5 pt-1.5 border-t border-base-200">
<Check class="h-3 w-3 text-emerald-500 shrink-0" aria-hidden="true" />
<span class="text-[10px] text-muted">Responding normally</span>
</div>
</Show>
</div>
</InfoCardFrame>
);
}
@@ -7,11 +7,11 @@ import { buildInfrastructureOnboardingPath } from '@/components/Settings/infrast
import { DiscoveryProvenanceMarker } from '@/components/shared/DiscoveryProvenanceMarker';
import { InfoCardFrame } from '@/components/shared/InfoCardFrame';
import { WebInterfaceUrlField } from '@/components/shared/WebInterfaceUrlField';
import { AvailabilityProbeStatusCards } from '@/components/Infrastructure/AvailabilityProbeStatusCard';
import type { DiscoveryIdentifiedSummary } from '@/utils/discoveryPresentation';
import { formatBytes, formatUptime } from '@/utils/format';
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
import { AvailabilityProbeStatusCard } from './AvailabilityProbeStatusCard';
import { AvailabilityProbeSuggestionCard } from './AvailabilityProbeSuggestionCard';
import { DiskList } from './DiskList';
import { getGuestDrawerMemoryRows, isGuestDrawerVM } from './guestDrawerModel';
@@ -475,15 +475,21 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
suggestedUrlReasonTitle={props.discoveryIdentifiedSummary?.suggestedUrlReasonTitle}
suggestedUrlDiagnostic={props.discoveryIdentifiedSummary?.suggestedUrlDiagnostic}
/>
<Show when={props.guest.availability}>
<Show when={props.guest.availability || props.guest.availabilityChecks?.length}>
<div class="mt-3 max-w-sm">
<AvailabilityProbeStatusCard availability={props.guest.availability!} />
<div class="space-y-3">
<AvailabilityProbeStatusCards
availability={props.guest.availability}
checks={props.guest.availabilityChecks}
/>
</div>
</div>
</Show>
<Show
when={
props.discoveryIdentifiedSummary?.suggestedAvailabilityProbe &&
!props.guest.availability
!props.guest.availability &&
!props.guest.availabilityChecks?.length
}
>
<div class="mt-3 max-w-sm">
@@ -412,10 +412,16 @@ function AvailabilityProbeCell(props: {
const badgeText = createMemo(() => {
const result = p().resultLabel;
if (/^\d+\s*ms$/.test(result)) return result.replace(/\s/, '');
if (result === 'reachable') return 'up';
if (result === 'not checked') return '';
return result;
const compactResult = /^\d+\s*ms$/.test(result)
? result.replace(/\s/, '')
: result === 'reachable'
? 'up'
: result === 'not checked'
? 'pending'
: result;
const freshness =
p().freshnessLabel === 'freshness unknown' ? 'unknown' : p().freshnessLabel;
return `${compactResult} · ${freshness}`;
});
return (
@@ -0,0 +1,59 @@
import { cleanup, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it } from 'vitest';
import { AvailabilityProbeStatusCard } from '@/components/Infrastructure/AvailabilityProbeStatusCard';
afterEach(cleanup);
describe('AvailabilityProbeStatusCard', () => {
it('does not turn an unobserved check into a confirmed failure', () => {
render(() => (
<AvailabilityProbeStatusCard
availability={{
targetId: 'probe-new',
address: 'api.example.test',
protocol: 'https',
enabled: true,
}}
/>
));
expect(screen.getByText('Not checked')).toBeInTheDocument();
expect(screen.queryByText('Down')).not.toBeInTheDocument();
expect(screen.getByText('freshness unknown')).toBeInTheDocument();
});
it('shows stale evidence and an unresolved canonical resource link', () => {
render(() => (
<AvailabilityProbeStatusCard
availability={{
targetId: 'probe-api',
address: 'api.example.test',
protocol: 'https',
enabled: true,
available: true,
latencyMillis: 12,
lastChecked: '2026-01-01T00:00:00Z',
correlationState: 'unresolved',
evidence: {
id: 'evidence-probe-api',
source: { provider: 'availability', collector: 'availability-poller' },
subject: { resourceId: 'network-endpoint:probe-api' },
observedAt: '2026-01-01T00:00:00Z',
ingestedAt: '2026-01-01T00:00:00Z',
validUntil: '2026-01-01T00:02:00Z',
completeness: 'complete',
confidence: 'confirmed',
permissions: 'sufficient',
},
}}
/>
));
expect(screen.getByText('Stale')).toBeInTheDocument();
expect(screen.queryByText('Up')).not.toBeInTheDocument();
expect(screen.getByText('stale')).toBeInTheDocument();
expect(screen.getByText('Resource link is unresolved')).toBeInTheDocument();
expect(screen.queryByText('Responding normally')).not.toBeInTheDocument();
});
});
@@ -1095,6 +1095,7 @@ describe('shared primitive guardrails', () => {
};
const expectedConsumers = [
'src/components/Discovery/DiscoveryTab.tsx',
'src/components/Infrastructure/AvailabilityProbeStatusCard.tsx',
'src/components/Infrastructure/ResourceActionHistory.tsx',
'src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx',
'src/components/shared/WebInterfaceUrlField.tsx',
@@ -5,6 +5,7 @@ import {
buildDrawerDiskListItems,
type DrawerDiskListItem,
} from '@/components/Workloads/DrawerDiskListCard';
import { AvailabilityProbeStatusCards } from '@/components/Infrastructure/AvailabilityProbeStatusCard';
import { InfoCardFrame } from '@/components/shared/InfoCardFrame';
import { useResourceDetailDrawerDockerActionsState } from '@/components/Infrastructure/useResourceDetailDrawerDockerActionsState';
import { hostOverrideIdCandidates } from '@/features/alerts/alertOverridesModel';
@@ -346,6 +347,12 @@ export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) {
<div class="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(25%-0.75rem)] [&>*]:min-w-[200px] [&>*]:max-w-full [&>*]:overflow-hidden">
<DetailCard title="System" rows={systemRows()} />
<DetailCard title="Runtime" rows={runtimeRows()} />
<Show when={props.host.availability || props.host.availabilityChecks?.length}>
<AvailabilityProbeStatusCards
availability={props.host.availability}
checks={props.host.availabilityChecks}
/>
</Show>
<InfoCardFrame data-testid="docker-host-drawer-containers-card">
<h3 class="mb-2 text-[11px] font-medium uppercase tracking-wide text-base-content">
Containers
@@ -135,6 +135,67 @@ describe('DockerHostsTable', () => {
expect(screen.getByRole('tab', { name: 'History' })).toHaveAttribute('type', 'button');
});
it('keeps an attached availability check visible in the host detail', () => {
render(() => (
<DockerHostsTable
resources={[
makeDockerHost({
availability: {
targetId: 'tower-api',
address: '192.168.0.8',
port: 8007,
protocol: 'tcp',
enabled: true,
pollIntervalSeconds: 60,
available: true,
latencyMillis: 9,
lastChecked: new Date().toISOString(),
correlationState: 'attached',
},
availabilityChecks: [
{
targetId: 'tower-api',
address: '192.168.0.8',
port: 8007,
protocol: 'tcp',
enabled: true,
pollIntervalSeconds: 60,
available: true,
latencyMillis: 9,
lastChecked: new Date().toISOString(),
correlationState: 'attached',
},
{
targetId: 'tower-web',
address: 'tower.example.test',
protocol: 'https',
path: '/health',
enabled: true,
pollIntervalSeconds: 60,
available: true,
latencyMillis: 14,
lastChecked: new Date().toISOString(),
correlationState: 'attached',
},
],
}),
]}
emptyIcon={<span />}
emptyTitle="No Docker hosts"
emptyDescription="No hosts"
showToolbar={false}
/>
));
fireEvent.click(screen.getByText('docker-01').closest('tr')!);
const drawer = screen.getByTestId('docker-host-drawer');
expect(within(drawer).getAllByTestId('availability-probe-status')).toHaveLength(2);
expect(within(drawer).getByText('192.168.0.8:8007')).toBeInTheDocument();
expect(within(drawer).getByText('tower.example.test/health')).toBeInTheDocument();
expect(within(drawer).getAllByText('fresh')).toHaveLength(2);
});
it('colors drawer host temperatures from configured thresholds', () => {
render(() => (
<DockerHostsTable
@@ -18,13 +18,18 @@ import {
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
import type { Resource, ResourceAvailabilityMeta } from '@/types/resource';
import { getAvailabilityProbePresentation } from '@/utils/availabilityProbePresentation';
import { getSimpleStatusIndicator } from '@/utils/status';
import {
getAvailabilityProbeEndpointLabel,
getAvailabilityProbePresentation,
} from '@/utils/availabilityProbePresentation';
import {
buildAvailabilitySettingsPath,
buildAvailabilityTargetAddPath,
} from '@/components/Settings/availabilitySettingsModel';
import { sortStandaloneResourcesByAttention } from './standalonePageModel';
import {
getStandaloneResourceStatusIndicator,
sortStandaloneResourcesByAttention,
} from './standalonePageModel';
const settingsLinkClass =
'inline-flex min-h-8 items-center justify-center gap-1.5 rounded-md border border-border bg-surface px-2.5 py-1 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover';
@@ -36,17 +41,7 @@ const availabilityFor = (resource: Resource): ResourceAvailabilityMeta | undefin
const formatTarget = (resource: Resource): string => {
const availability = availabilityFor(resource);
if (!availability) return resource.name;
const address = (availability.address ?? '').trim();
if (!address) return resource.name;
const protocol = (availability.protocol ?? '').trim().toLowerCase();
if (protocol === 'tcp' && availability.port) return `${address}:${availability.port}`;
if ((protocol === 'http' || protocol === 'https') && availability.path) {
const path = availability.path.trim();
if (path && !address.endsWith(path)) {
return `${address.replace(/\/+$/, '')}${path.startsWith('/') ? path : `/${path}`}`;
}
}
return address;
return getAvailabilityProbeEndpointLabel(availability) || resource.name;
};
const formatFailures = (availability: ResourceAvailabilityMeta | undefined): string => {
@@ -68,7 +63,13 @@ export const AvailabilityChecksTable: Component<{
const tableState = createPlatformTableFilterState({
resources: () => props.resources,
initialStatus: 'all' as PlatformResourceStatusFilter,
filter: filterPlatformResources,
filter: (resources, search, status) =>
filterPlatformResources(resources, search, status, (resource) => {
const variant = getStandaloneResourceStatusIndicator(resource).variant;
if (variant === 'success') return 'online';
if (variant === 'danger') return 'offline';
return 'degraded';
}),
});
const orderedChecks = createMemo(() => sortStandaloneResourcesByAttention(tableState.filtered()));
@@ -175,7 +176,7 @@ export const AvailabilityChecksTable: Component<{
{(check) => {
const availability = () => availabilityFor(check);
const probe = () => getAvailabilityProbePresentation(check);
const indicator = () => getSimpleStatusIndicator(check.status);
const indicator = () => getStandaloneResourceStatusIndicator(check);
const method = () =>
probe()?.methodLabel ?? availability()?.protocol ?? 'Probe';
const result = () => probe()?.resultLabel ?? indicator().label;
@@ -137,7 +137,6 @@ export function StandalonePageSurface() {
const setMachineStatusFilter = (status: PlatformResourceStatusFilter) => {
setSearchParams({ status: status === 'all' ? null : status }, { replace: true });
};
const machinePosture = createMemo(() => buildStandalonePostureSummary(model().machines));
const availabilityPosture = createMemo(() =>
buildStandalonePostureSummary(model().availabilityChecks),
);
@@ -272,25 +271,6 @@ export function StandalonePageSurface() {
}
>
<div class="space-y-4">
<Show when={model().machines.length > 0}>
<StandalonePostureCard
label="Pulse Agents"
noun="machine"
summary={machinePosture()}
actions={
<Button
type="button"
variant="secondary"
size="sm"
class="gap-2"
onClick={() => navigate(buildInfrastructureOnboardingPath('pick'))}
>
<SettingsIcon class="h-3.5 w-3.5" />
Add agent
</Button>
}
/>
</Show>
<PlatformOutdatedAgentNotice
hosts={outdatedAgentHosts()}
targetVersion={serverVersionDisplay()}
@@ -99,6 +99,16 @@ const resource = (overrides: Partial<Resource>): Resource =>
...overrides,
}) as Resource;
const freshAvailability = (
overrides: NonNullable<Resource['availability']> = {},
): NonNullable<Resource['availability']> => ({
available: true,
lastChecked: new Date().toISOString(),
pollIntervalSeconds: 60,
correlationState: 'standalone',
...overrides,
});
beforeEach(() => {
mocks.pathname = '/standalone/machines';
mocks.searchParams = {};
@@ -113,14 +123,14 @@ beforeEach(() => {
type: 'network-endpoint',
platformType: 'availability',
sources: ['availability'],
availability: { targetKind: 'machine' },
availability: freshAvailability({ targetKind: 'machine' }),
}),
resource({
id: 'mqtt-meter',
type: 'network-endpoint',
platformType: 'availability',
sources: ['availability'],
availability: { targetKind: 'service' },
availability: freshAvailability({ targetKind: 'service' }),
}),
],
loading: () => false,
@@ -158,12 +168,33 @@ describe('StandalonePageSurface', () => {
'machines,availability',
);
expect(screen.getByTestId('agents-machines-table')).toHaveAttribute('data-resource-count', '1');
expect(screen.getByTestId('standalone-posture-summary')).toHaveTextContent(
'All 1 machine reporting normally',
);
expect(screen.queryByTestId('standalone-posture-summary')).not.toBeInTheDocument();
expect(screen.queryByTestId('availability-checks-table')).not.toBeInTheDocument();
});
it('keeps machine attention in the table instead of adding a page-wide posture banner', () => {
mocks.useUnifiedResources.mockReturnValue({
resources: () => [
resource({
id: 'tower',
name: 'tower',
type: 'agent',
platformType: 'agent',
sources: ['agent'],
status: 'warning',
}),
],
loading: () => false,
error: () => null,
refetch: vi.fn(),
});
render(() => <StandalonePageSurface />);
expect(screen.getByTestId('agents-machines-table')).toHaveAttribute('data-resource-count', '1');
expect(screen.queryByTestId('standalone-posture-summary')).not.toBeInTheDocument();
});
it('surfaces stale Pulse Agent binaries on the Machines page', () => {
mocks.versionInfo.mockReturnValue({
version: 'v6.0.0-rc.6',
@@ -243,7 +274,7 @@ describe('StandalonePageSurface', () => {
platformType: 'availability',
sources: ['availability'],
status: 'offline',
availability: { targetKind: 'service', available: false },
availability: freshAvailability({ targetKind: 'service', available: false }),
}),
resource({
id: 'healthy-check',
@@ -251,7 +282,7 @@ describe('StandalonePageSurface', () => {
platformType: 'availability',
sources: ['availability'],
status: 'online',
availability: { targetKind: 'service', available: true },
availability: freshAvailability({ targetKind: 'service' }),
}),
],
loading: () => false,
@@ -147,4 +147,63 @@ describe('standalonePageModel', () => {
expect(model.availabilityChecks.map((item) => item.id)).toEqual(['router-ping', 'endpoint-1']);
expect(model.resources.map((item) => item.id)).toEqual(['mac-mini']);
});
it('does not duplicate an attached resource in the availability-check inventory', () => {
const model = buildStandalonePageModel([
resource({
id: 'agent:docker-trust',
platformType: 'docker',
type: 'agent',
sources: ['agent', 'docker', 'availability'],
availability: {
targetId: 'tower-api',
correlationState: 'attached',
available: true,
},
}),
resource({
id: 'availability:orphan',
platformType: 'availability',
type: 'network-endpoint',
sources: ['availability'],
availability: {
targetId: 'orphan',
correlationState: 'standalone',
available: true,
},
}),
]);
expect(model.availabilityChecks.map((item) => item.id)).toEqual(['availability:orphan']);
});
it('treats stale or unobserved availability evidence as attention', () => {
const now = Date.parse('2026-07-19T04:00:00Z');
const summary = buildStandalonePostureSummary(
[
resource({
id: 'availability:stale',
platformType: 'availability',
type: 'network-endpoint',
availability: {
targetId: 'stale',
available: true,
lastChecked: '2026-07-19T03:50:00Z',
pollIntervalSeconds: 60,
},
}),
resource({
id: 'availability:unobserved',
platformType: 'availability',
type: 'network-endpoint',
availability: { targetId: 'unobserved' },
}),
],
now,
);
expect(summary.attention).toBe(2);
expect(summary.warning).toBe(2);
expect(summary.normal).toBe(0);
});
});
@@ -1,4 +1,5 @@
import type { Resource } from '@/types/resource';
import { getAvailabilityProbePresentation } from '@/utils/availabilityProbePresentation';
import { isPulseAgentPlatformResource } from '@/utils/agentResources';
import { getSimpleStatusIndicator } from '@/utils/status';
@@ -22,15 +23,31 @@ export const isStandaloneMachineResource = (resource: Resource): boolean =>
isPulseAgentPlatformResource(resource);
export const isAgentlessAvailabilityResource = (resource: Resource): boolean =>
resource.type === 'network-endpoint' ||
resource.platformType === 'availability' ||
resource.sources?.includes('availability') === true;
resource.availability?.correlationState !== 'attached' &&
(resource.type === 'network-endpoint' || resource.platformType === 'availability');
const AGENT_REPORT_STALE_AFTER_MS = 5 * 60 * 1000;
export const getStandaloneResourceStatusIndicator = (resource: Resource, nowMs = Date.now()) => {
const indicator = getSimpleStatusIndicator(resource.status);
if (indicator.variant === 'danger') return indicator;
const availability = getAvailabilityProbePresentation(resource, new Date(nowMs));
if (
availability?.freshnessLabel === 'stale' ||
availability?.freshnessLabel === 'freshness unknown' ||
resource.availability?.correlationState === 'ambiguous' ||
resource.availability?.correlationState === 'unresolved'
) {
return {
variant: 'warning' as const,
label:
availability?.freshnessLabel === 'stale'
? 'Stale'
: availability?.freshnessLabel === 'freshness unknown'
? 'Freshness unknown'
: 'Identity unresolved',
};
}
if (
resource.type === 'agent' &&
(resource.agent?.stale === true ||
@@ -400,24 +400,8 @@ type APIResource = {
recentTaskSummary?: string;
snapshotCount?: number;
};
availability?: {
targetId?: string;
name?: string;
address?: string;
protocol?: string;
port?: number;
path?: string;
enabled?: boolean;
available?: boolean;
lastChecked?: string;
lastSuccess?: string;
latencyMillis?: number;
consecutiveFailures?: number;
lastError?: string;
failureThreshold?: number;
pollIntervalSeconds?: number;
timeoutMillis?: number;
};
availability?: ResourceAvailabilityMeta;
availabilityChecks?: ResourceAvailabilityMeta[];
recentChanges?: ResourceChange[];
facetCounts?: ResourceFacetCounts;
physicalDisk?: {
@@ -820,6 +804,7 @@ const toResource = (v2: APIResource): Resource => {
vmware: v2.vmware as ResourceVMwareMeta | undefined,
pbs: v2.pbs as ResourcePBSMeta | undefined,
availability: v2.availability as ResourceAvailabilityMeta | undefined,
availabilityChecks: v2.availabilityChecks as ResourceAvailabilityMeta[] | undefined,
physicalDisk: v2.physicalDisk,
storage: v2.storage as ResourceStorageMeta | undefined,
ceph: v2.ceph as ResourceCephMeta | undefined,
@@ -901,6 +886,7 @@ const toResource = (v2: APIResource): Resource => {
kubernetes: v2.kubernetes,
vmware: v2.vmware,
availability: v2.availability,
availabilityChecks: v2.availabilityChecks,
physicalDisk: v2.physicalDisk,
ceph: v2.ceph,
metrics: v2.metrics,
@@ -155,6 +155,7 @@ type APIResource = {
};
discoveryReadiness?: ResourceDiscoveryReadiness;
availability?: ResourceAvailabilityMeta;
availabilityChecks?: ResourceAvailabilityMeta[];
actionReadiness?: ResourceActionReadiness[];
};
@@ -570,6 +571,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
}
: undefined,
availability: resource.availability as ResourceAvailabilityMeta | undefined,
availabilityChecks: resource.availabilityChecks as ResourceAvailabilityMeta[] | undefined,
};
};
+9
View File
@@ -29,6 +29,7 @@ import {
PLATFORM_TYPE_KEYS as GENERATED_PLATFORM_TYPE_KEYS,
type GeneratedPlatformType,
} from '@/utils/platformSupportManifest.generated';
import type { EvidenceEnvelope } from '@/types/operationalTrust';
// Resource types - what kind of entity is being monitored
export type ResourceType =
@@ -268,6 +269,7 @@ export type ResourceFacetSourceAdapter =
export interface ResourceFacetCounts {
recentChanges: number;
availabilityChecks?: number;
recentChangeKinds?: Partial<Record<ResourceChangeKind, number>>;
recentChangeSourceTypes?: Partial<Record<ResourceChangeSourceType, number>>;
recentChangeSourceAdapters?: Partial<Record<ResourceFacetSourceAdapter, number>>;
@@ -280,6 +282,7 @@ export type ResourceRelationshipType =
| 'exposed_by'
| 'owned_by'
| 'attached_to'
| 'checks'
| string;
export interface ResourceRelationship {
@@ -1388,6 +1391,11 @@ export interface ResourceAvailabilityMeta {
failureThreshold?: number;
pollIntervalSeconds?: number;
timeoutMillis?: number;
correlationState?: 'attached' | 'standalone' | 'ambiguous' | 'unresolved';
correlationRule?: string;
correlationReason?: string;
correlationCandidates?: number;
evidence?: EvidenceEnvelope;
}
/**
@@ -1475,6 +1483,7 @@ export interface Resource {
proxmox?: ResourceProxmoxMeta;
pbs?: ResourcePBSMeta;
availability?: ResourceAvailabilityMeta;
availabilityChecks?: ResourceAvailabilityMeta[];
physicalDisk?: ResourcePhysicalDiskMeta;
storage?: ResourceStorageMeta;
ceph?: ResourceCephMeta;
+2
View File
@@ -58,4 +58,6 @@ export type WorkloadGuest = (VM | Container) & {
};
/** Availability probe facet from the unified resource model. */
availability?: ResourceAvailabilityMeta;
/** Complete availability facet set; availability is its compatibility summary. */
availabilityChecks?: ResourceAvailabilityMeta[];
};
@@ -160,4 +160,54 @@ describe('availabilityProbePresentation', () => {
expect(presentation?.resultLabel).toBe('3 ms');
expect(presentation?.toneClassName).toContain('emerald');
});
it('keeps freshness independent from a successful probe result', () => {
const now = new Date('2026-05-06T13:05:00Z');
const presentation = getAvailabilityProbePresentation(
makeAvailabilityResource({
availability: {
protocol: 'icmp',
available: true,
latencyMillis: 3,
lastChecked: '2026-05-06T13:00:00Z',
evidence: {
id: 'evidence-1',
source: { provider: 'availability', collector: 'availability-poller' },
subject: { resourceId: 'vm:100' },
observedAt: '2026-05-06T13:00:00Z',
ingestedAt: '2026-05-06T13:00:00Z',
validUntil: '2026-05-06T13:02:00Z',
completeness: 'complete',
confidence: 'confirmed',
permissions: 'sufficient',
},
},
}),
now,
);
expect(presentation).toMatchObject({
resultLabel: '3 ms',
freshnessLabel: 'stale',
});
expect(presentation?.detailLabel).toContain('stale');
expect(presentation?.toneClassName).toContain('amber');
});
it('names ambiguous resource identity without presenting it as an attachment', () => {
const presentation = getAvailabilityProbePresentation(
makeAvailabilityResource({
availability: {
protocol: 'icmp',
available: true,
correlationState: 'ambiguous',
correlationCandidates: 2,
},
}),
);
expect(presentation?.correlationLabel).toBe('2 possible resource matches');
expect(presentation?.detailLabel).toContain('2 possible resource matches');
expect(presentation?.toneClassName).toContain('amber');
});
});
@@ -402,6 +402,58 @@ describe('resourceStateAdapters nodeFromResource', () => {
expect((resource.platformData as Record<string, unknown>).sources).toEqual(['availability']);
});
it('preserves plural attached availability facets across thin realtime merges', () => {
const availabilityChecks = [
{
targetId: 'ops-api',
linkedResourceId: 'docker-host-1',
protocol: 'tcp',
address: '192.0.2.18',
port: 8007,
available: true,
correlationState: 'attached' as const,
},
{
targetId: 'ops-web',
linkedResourceId: 'docker-host-1',
protocol: 'https',
address: 'ops.example.test',
path: '/health',
available: true,
correlationState: 'attached' as const,
},
];
const existing = {
id: 'docker-host-1',
type: 'docker-host',
name: 'Operations Docker',
displayName: 'Operations Docker',
platformId: 'docker-host-1',
platformType: 'docker',
sourceType: 'api',
sources: ['docker', 'availability'],
status: 'online',
lastSeen: Date.now() - 1_000,
availability: availabilityChecks[0],
availabilityChecks,
} as Resource;
const [resource] = mergeCanonicalResourceSnapshot(
[
{
...existing,
lastSeen: Date.now(),
availability: undefined,
availabilityChecks: undefined,
} as Resource,
],
[existing],
);
expect(resource.availability).toEqual(availabilityChecks[0]);
expect(resource.availabilityChecks).toEqual(availabilityChecks);
});
it('canonicalizes native TrueNAS share resources with the TrueNAS facet intact', () => {
const [resource] = mergeCanonicalResourceSnapshot(
[
@@ -5,6 +5,8 @@ export interface AvailabilityProbePresentation {
methodLabel: string;
targetLabel: string | null;
resultLabel: string;
freshnessLabel: 'fresh' | 'stale' | 'freshness unknown';
correlationLabel: string | null;
netIoLabel: string;
rowLabel: string;
detailLabel: string;
@@ -54,6 +56,29 @@ export const getAvailabilityProbeTargetLabel = (
return null;
};
export const getAvailabilityProbeEndpointLabel = (
availability?: ResourceAvailabilityMeta | null,
): string => {
const address = (availability?.address ?? '').trim();
const protocol = normalizeAvailabilityProtocol(availability?.protocol);
const port = availability?.port;
const addressWithPort =
address &&
typeof port === 'number' &&
Number.isFinite(port) &&
port > 0 &&
!address.endsWith(`:${port}`)
? `${address}:${port}`
: address;
if ((protocol === 'http' || protocol === 'https') && availability?.path) {
const path = availability.path.trim();
if (path && !addressWithPort.endsWith(path)) {
return `${addressWithPort.replace(/\/+$/, '')}${path.startsWith('/') ? path : `/${path}`}`;
}
}
return addressWithPort;
};
const getAvailabilityProbeFailureLabel = (availability: ResourceAvailabilityMeta): string => {
const lastError = (availability.lastError ?? '').trim();
const normalizedError = lastError.toLowerCase();
@@ -88,12 +113,18 @@ const getAvailabilityProbeResultLabel = (
const getAvailabilityProbeToneClassName = (
resource: Pick<Resource, 'status'>,
availability: ResourceAvailabilityMeta,
freshnessLabel: AvailabilityProbePresentation['freshnessLabel'],
): string => {
const normalizedStatus = (resource.status ?? '').trim().toLowerCase();
if (availability.available === false || normalizedStatus === 'offline') {
return AVAILABILITY_PROBE_ERROR_CLASS;
}
if (normalizedStatus === 'degraded') {
if (
normalizedStatus === 'degraded' ||
freshnessLabel === 'stale' ||
availability.correlationState === 'ambiguous' ||
availability.correlationState === 'unresolved'
) {
return AVAILABILITY_PROBE_WARNING_CLASS;
}
if (availability.available === true || normalizedStatus === 'online') {
@@ -102,6 +133,46 @@ const getAvailabilityProbeToneClassName = (
return AVAILABILITY_PROBE_UNKNOWN_CLASS;
};
const getAvailabilityFreshnessLabel = (
availability: ResourceAvailabilityMeta,
now: Date,
): AvailabilityProbePresentation['freshnessLabel'] => {
const explicitValidUntil = availability.evidence?.validUntil;
const validUntilMillis = explicitValidUntil ? Date.parse(explicitValidUntil) : Number.NaN;
if (Number.isFinite(validUntilMillis)) {
return validUntilMillis >= now.getTime() ? 'fresh' : 'stale';
}
const checkedMillis = availability.lastChecked
? Date.parse(availability.lastChecked)
: Number.NaN;
const pollIntervalSeconds = availability.pollIntervalSeconds;
if (
Number.isFinite(checkedMillis) &&
typeof pollIntervalSeconds === 'number' &&
Number.isFinite(pollIntervalSeconds) &&
pollIntervalSeconds > 0
) {
return checkedMillis + pollIntervalSeconds * 2_000 >= now.getTime() ? 'fresh' : 'stale';
}
return 'freshness unknown';
};
const getAvailabilityCorrelationLabel = (availability: ResourceAvailabilityMeta): string | null => {
switch (availability.correlationState) {
case 'ambiguous':
return availability.correlationCandidates && availability.correlationCandidates > 1
? `${availability.correlationCandidates} possible resource matches`
: 'Resource match is ambiguous';
case 'unresolved':
return 'Resource link is unresolved';
case 'standalone':
return 'Standalone endpoint';
default:
return null;
}
};
const getFailureCountLabel = (availability: ResourceAvailabilityMeta): string | null => {
const failures = availability.consecutiveFailures;
if (typeof failures !== 'number' || !Number.isFinite(failures) || failures <= 0) {
@@ -116,10 +187,10 @@ const getFailureCountLabel = (availability: ResourceAvailabilityMeta): string |
export const getAvailabilityProbePresentation = (
resource: AvailabilityProbeResource,
now = new Date(),
): AvailabilityProbePresentation | null => {
const platformAvailability = resource.platformData?.availability as
| ResourceAvailabilityMeta
| undefined;
ResourceAvailabilityMeta | undefined;
const availability = resource.availability ?? platformAvailability;
if (!availability) {
return null;
@@ -128,11 +199,14 @@ export const getAvailabilityProbePresentation = (
const methodLabel = getAvailabilityProbeMethodLabel(availability);
const targetLabel = getAvailabilityProbeTargetLabel(availability);
const resultLabel = getAvailabilityProbeResultLabel(resource, availability);
const freshnessLabel = getAvailabilityFreshnessLabel(availability, now);
const correlationLabel = getAvailabilityCorrelationLabel(availability);
const netIoLabel = targetLabel ? `${targetLabel}: ${resultLabel}` : resultLabel;
const checked = formatRelativeTime(availability.lastChecked);
const failures = getFailureCountLabel(availability);
const detailParts = [`${methodLabel} - ${resultLabel}`];
const detailParts = [`${methodLabel} - ${resultLabel}`, freshnessLabel];
if (checked) detailParts.push(`checked ${checked}`);
if (correlationLabel) detailParts.push(correlationLabel);
if (failures) detailParts.push(failures);
if (availability.lastSuccess && availability.available === false) {
const lastSuccess = formatRelativeTime(availability.lastSuccess);
@@ -146,9 +220,11 @@ export const getAvailabilityProbePresentation = (
methodLabel,
targetLabel,
resultLabel,
freshnessLabel,
correlationLabel,
netIoLabel,
rowLabel,
detailLabel: detailParts.join(' - '),
toneClassName: getAvailabilityProbeToneClassName(resource, availability),
toneClassName: getAvailabilityProbeToneClassName(resource, availability, freshnessLabel),
};
};
@@ -678,6 +678,9 @@ export const canonicalizeRealtimeResource = (
storage: resource.storage ?? (platformRecord?.storage as Resource['storage']),
availability:
resource.availability ?? (platformRecord?.availability as Resource['availability']),
availabilityChecks:
resource.availabilityChecks ??
(platformRecord?.availabilityChecks as Resource['availabilityChecks']),
physicalDisk:
resource.physicalDisk ?? (platformRecord?.physicalDisk as Resource['physicalDisk']),
};
@@ -785,6 +788,11 @@ export const mergeCanonicalResource = (incoming: Resource, existing?: Resource):
incomingSources,
'availability',
) as Resource['availability'],
availabilityChecks:
incoming.availabilityChecks ??
(shouldKeepSourceFacet(incomingSources, 'availability')
? existingCanonical.availabilityChecks
: undefined),
storage: mergeRecord(
incoming.storage as JsonRecord | undefined,
existingCanonical.storage as JsonRecord | undefined,
+12 -2
View File
@@ -6,6 +6,7 @@ import (
"time"
alertspecs "github.com/rcourtman/pulse-go-rewrite/internal/alerts/specs"
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
@@ -236,6 +237,9 @@ func containsCanonicalString(values []string, target string) bool {
}
func resourceSupportsUnifiedIncidentAlerts(resource unifiedresources.Resource) bool {
if len(unifiedresources.AvailabilityChecksForResource(resource)) > 0 && len(resource.Incidents) > 0 {
return true
}
switch resource.Type {
case unifiedresources.ResourceTypeStorage, unifiedresources.ResourceTypePhysicalDisk:
return true
@@ -283,7 +287,7 @@ func unifiedIncidentAlert(resource unifiedresources.Resource, incident unifiedre
startTime = now
}
return &Alert{
alert := &Alert{
ID: alertID,
Type: alertType,
Level: level,
@@ -298,6 +302,12 @@ func unifiedIncidentAlert(resource unifiedresources.Resource, incident unifiedre
LastSeen: now,
Metadata: unifiedIncidentMetadata(resource, incident, alertType),
}
if availability := unifiedresources.AvailabilityCheckByTargetID(resource, incident.NativeID); availability != nil && availability.Evidence != nil {
alert.Evidence = []operationaltrust.EvidenceEnvelope{
availability.Evidence.Clone(),
}
}
return alert
}
func unifiedIncidentAlertID(resource unifiedresources.Resource, incident unifiedresources.ResourceIncident) string {
@@ -356,7 +366,7 @@ func unifiedIncidentInstance(resource unifiedresources.Resource) string {
return strings.TrimSpace(resource.VMware.VCenterHost)
}
return "vSphere"
case resource.Availability != nil:
case len(unifiedresources.AvailabilityChecksForResource(resource)) > 0:
return "Availability"
case resource.Storage != nil && strings.TrimSpace(resource.Storage.Platform) != "":
platform := strings.TrimSpace(resource.Storage.Platform)
+52 -6
View File
@@ -2,7 +2,9 @@ package alerts
import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
"github.com/rcourtman/pulse-go-rewrite/internal/storagehealth"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
@@ -73,16 +75,36 @@ func TestSyncUnifiedResourceIncidentsCreatesAndClearsAlerts(t *testing.T) {
assertAlertMissing(t, m, alertID)
}
func TestSyncUnifiedResourceIncidentsSupportsAvailabilityEndpoints(t *testing.T) {
func TestSyncUnifiedResourceIncidentsRoutesAttachedAvailabilityEvidenceThroughLifecycle(t *testing.T) {
m := newTestManager(t)
configureUnifiedEvalManager(t, m, unifiedEvalBaseConfig())
observedAt := time.Date(2026, 7, 19, 2, 0, 0, 0, time.UTC)
source := operationaltrust.EvidenceSource{
Provider: "availability",
Collector: "availability-poller",
}
subject := operationaltrust.EvidenceSubject{ResourceID: "docker-service:api"}
evidenceID, err := operationaltrust.NewEvidenceID(source, subject, observedAt, "energy-meter")
if err != nil {
t.Fatalf("NewEvidenceID() error = %v", err)
}
validUntil := observedAt.Add(2 * time.Minute)
resource := unifiedresources.Resource{
ID: "availability:energy-meter",
Type: unifiedresources.ResourceTypeNetworkEndpoint,
Name: "Energy meter",
Sources: []unifiedresources.DataSource{unifiedresources.SourceAvailability},
ID: "docker-service:api",
Type: unifiedresources.ResourceTypeDockerService,
Name: "API",
Sources: []unifiedresources.DataSource{unifiedresources.SourceDocker, unifiedresources.SourceAvailability},
Availability: &unifiedresources.AvailabilityData{
TargetID: "api-health",
Address: "192.0.2.45",
Protocol: "http",
Port: 8080,
Enabled: true,
Available: true,
CorrelationState: unifiedresources.AvailabilityCorrelationAttached,
},
AvailabilityChecks: []unifiedresources.AvailabilityData{{
TargetID: "energy-meter",
Address: "192.0.2.44",
Protocol: "icmp",
@@ -90,7 +112,23 @@ func TestSyncUnifiedResourceIncidentsSupportsAvailabilityEndpoints(t *testing.T)
Available: false,
ConsecutiveFailures: 2,
FailureThreshold: 2,
},
CorrelationState: unifiedresources.AvailabilityCorrelationAttached,
Evidence: &operationaltrust.EvidenceEnvelope{
ID: evidenceID,
Source: source,
Subject: subject,
ObservedAt: observedAt,
IngestedAt: observedAt,
ValidUntil: &validUntil,
Completeness: operationaltrust.EvidenceComplete,
Confidence: operationaltrust.EvidenceConfirmed,
Permissions: operationaltrust.EvidencePermissionsSufficient,
PayloadRef: &operationaltrust.EvidencePayloadRef{
Kind: "availability-target",
ID: "energy-meter",
},
},
}},
Incidents: []unifiedresources.ResourceIncident{{
Provider: "availability",
NativeID: "energy-meter",
@@ -125,6 +163,14 @@ func TestSyncUnifiedResourceIncidentsSupportsAvailabilityEndpoints(t *testing.T)
if got := alert.Metadata["incidentProvider"]; got != "availability" {
t.Fatalf("incidentProvider = %v, want availability", got)
}
if len(alert.Evidence) != 1 || alert.Evidence[0].ID != evidenceID {
t.Fatalf("alert evidence = %+v, want %q", alert.Evidence, evidenceID)
}
if alert.OperationalRecord == nil ||
len(alert.OperationalRecord.EvidenceIDs) != 1 ||
alert.OperationalRecord.EvidenceIDs[0] != evidenceID {
t.Fatalf("operational record = %+v, want canonical availability evidence", alert.OperationalRecord)
}
}
func TestSyncUnifiedResourceIncidentsKeepsInstanceScopedNodeDisplayNames(t *testing.T) {
+14
View File
@@ -217,6 +217,20 @@ type resourceContractSnapshot struct {
Type string
}
func TestContract_UnifiedSeedSourcesIncludesPluralAvailabilityFacets(t *testing.T) {
sources := unifiedSeedSources([]unifiedresources.Resource{{
ID: "docker-host:ops",
Type: unifiedresources.ResourceTypeAgent,
AvailabilityChecks: []unifiedresources.AvailabilityData{{
TargetID: "ops-api",
}},
}})
if _, ok := sources[unifiedresources.SourceAvailability]; !ok {
t.Fatalf("seed sources = %v, want availability from plural resource facet", sources)
}
}
func TestContract_AlertDeliveryDiagnosisRouteIsReadOnlyMonitoringRead(t *testing.T) {
source, err := os.ReadFile("alerts.go")
if err != nil {
+1 -1
View File
@@ -1178,7 +1178,7 @@ func unifiedSeedSources(resources []unified.Resource) map[unified.DataSource]str
sources[unified.SourceTrueNAS] = struct{}{}
case resource.VMware != nil:
sources[unified.SourceVMware] = struct{}{}
case resource.Availability != nil:
case len(unified.AvailabilityChecksForResource(resource)) > 0:
sources[unified.SourceAvailability] = struct{}{}
}
}
+56
View File
@@ -7,6 +7,7 @@ import (
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
"github.com/rcourtman/pulse-go-rewrite/internal/storagehealth"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
@@ -346,6 +347,7 @@ func availabilityFixtureRecord(fixture AvailabilityFixture, now time.Time) (unif
PollIntervalSeconds: target.effectivePollIntervalSecs(),
TimeoutMillis: target.effectiveTimeoutMillis(),
}
data.Evidence = mockAvailabilityEvidence(target, fixture, lastSeen, now)
resource := unifiedresources.Resource{
Type: unifiedresources.ResourceTypeNetworkEndpoint,
Technology: string(target.Protocol),
@@ -368,6 +370,60 @@ func availabilityFixtureRecord(fixture AvailabilityFixture, now time.Time) (unif
}, true
}
func mockAvailabilityEvidence(
target AvailabilityTargetFixture,
fixture AvailabilityFixture,
observedAt time.Time,
ingestedAt time.Time,
) *operationaltrust.EvidenceEnvelope {
source := operationaltrust.EvidenceSource{
Provider: string(unifiedresources.SourceAvailability),
Collector: "availability-poller",
}
subject := operationaltrust.EvidenceSubject{
ProviderRef: target.ID,
ProviderScope: "availability-target",
}
id, err := operationaltrust.NewEvidenceID(source, subject, observedAt, target.ID)
if err != nil {
return nil
}
validUntil := observedAt.Add(
time.Duration(target.effectivePollIntervalSecs()*2) * time.Second,
)
completeness := operationaltrust.EvidenceComplete
confidence := operationaltrust.EvidenceConfirmed
var reason *operationaltrust.EvidenceReason
if fixture.LastChecked.IsZero() {
completeness = operationaltrust.EvidencePartial
confidence = operationaltrust.EvidenceUnknown
reason = &operationaltrust.EvidenceReason{
Code: "availability_not_observed",
Message: "The availability target has not completed its first probe.",
}
}
envelope := operationaltrust.EvidenceEnvelope{
ID: id,
Source: source,
Subject: subject,
ObservedAt: observedAt,
IngestedAt: ingestedAt,
ValidUntil: &validUntil,
Completeness: completeness,
Confidence: confidence,
Reason: reason,
Permissions: operationaltrust.EvidencePermissionsSufficient,
PayloadRef: &operationaltrust.EvidencePayloadRef{
Kind: "availability-target",
ID: target.ID,
},
}
if err := envelope.Validate(); err != nil {
return nil
}
return &envelope
}
func availabilityFixtureTimePointer(value time.Time) *time.Time {
if value.IsZero() {
return nil
+20
View File
@@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
@@ -246,6 +247,10 @@ func TestAvailabilityFixtureRecordOmitsUnknownProbeTimes(t *testing.T) {
if record.Resource.Availability.LastSuccess != nil {
t.Fatalf("last success = %v, want nil before the first successful probe", record.Resource.Availability.LastSuccess)
}
if record.Resource.Availability.Evidence == nil ||
record.Resource.Availability.Evidence.Completeness != operationaltrust.EvidencePartial {
t.Fatalf("initial evidence = %+v, want partial envelope", record.Resource.Availability.Evidence)
}
}
func TestFixtureGraphAttachesServiceAvailabilityFixturesToServiceResources(t *testing.T) {
@@ -292,6 +297,21 @@ func TestFixtureGraphAttachesServiceAvailabilityFixturesToServiceResources(t *te
if !slices.Contains(dockerService.Sources, unifiedresources.SourceAvailability) {
t.Fatalf("expected Docker service sources to include availability, got %+v", dockerService.Sources)
}
if dockerService.Availability.CorrelationState != unifiedresources.AvailabilityCorrelationAttached ||
dockerService.Availability.Evidence == nil ||
dockerService.Availability.Evidence.Subject.ResourceID != dockerService.ID {
t.Fatalf("Docker service availability trust contract = %+v", dockerService.Availability)
}
hasChecksRelationship := false
for _, relationship := range dockerService.Relationships {
if relationship.Type == unifiedresources.RelChecks &&
relationship.TargetID == dockerService.ID {
hasChecksRelationship = true
}
}
if !hasChecksRelationship {
t.Fatalf("Docker service relationships = %+v, want checks edge", dockerService.Relationships)
}
if kubernetesService == nil {
t.Fatal("expected Kubernetes service availability facet on curated mock service")
@@ -14,6 +14,7 @@ import (
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
"github.com/rcourtman/pulse-go-rewrite/internal/storagehealth"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
@@ -534,6 +535,7 @@ func availabilityResourceFromTarget(target config.AvailabilityTarget, status Ava
PollIntervalSeconds: target.EffectivePollIntervalSecs(),
TimeoutMillis: target.EffectiveTimeoutMillis(),
}
data.Evidence = availabilityEvidenceEnvelope(target, status, lastSeen, now)
resource := unifiedresources.Resource{
Type: unifiedresources.ResourceTypeNetworkEndpoint,
Technology: string(target.Protocol),
@@ -558,6 +560,74 @@ func availabilityResourceFromTarget(target config.AvailabilityTarget, status Ava
return resource, identity
}
func availabilityEvidenceEnvelope(
target config.AvailabilityTarget,
status AvailabilityProbeStatus,
observedAt time.Time,
ingestedAt time.Time,
) *operationaltrust.EvidenceEnvelope {
if observedAt.IsZero() {
return nil
}
if ingestedAt.IsZero() {
ingestedAt = observedAt
}
source := operationaltrust.EvidenceSource{
Provider: string(unifiedresources.SourceAvailability),
Collector: "availability-poller",
}
subject := operationaltrust.EvidenceSubject{
ProviderRef: target.ID,
ProviderScope: "availability-target",
}
evidenceID, err := operationaltrust.NewEvidenceID(
source,
subject,
observedAt,
target.ID,
)
if err != nil {
return nil
}
validUntil := observedAt.Add(
time.Duration(target.EffectivePollIntervalSecs()*2) * time.Second,
)
completeness := operationaltrust.EvidenceComplete
confidence := operationaltrust.EvidenceConfirmed
var reason *operationaltrust.EvidenceReason
if status.LastChecked.IsZero() {
completeness = operationaltrust.EvidencePartial
confidence = operationaltrust.EvidenceUnknown
reason = &operationaltrust.EvidenceReason{
Code: "availability_not_observed",
Message: "The availability target has not completed its first probe.",
}
}
envelope := operationaltrust.EvidenceEnvelope{
ID: evidenceID,
Source: source,
Subject: subject,
ObservedAt: observedAt,
IngestedAt: ingestedAt,
ValidUntil: &validUntil,
Completeness: completeness,
Confidence: confidence,
Reason: reason,
Permissions: operationaltrust.EvidencePermissionsSufficient,
PayloadRef: &operationaltrust.EvidencePayloadRef{
Kind: "availability-target",
ID: target.ID,
},
}
if err := envelope.Validate(); err != nil {
return nil
}
return &envelope
}
func availabilityResourceTags(target config.AvailabilityTarget) []string {
tags := []string{"agentless"}
if target.TargetKind != "" {
@@ -9,6 +9,7 @@ import (
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
@@ -134,6 +135,19 @@ func TestAvailabilityPollProviderSupplementalRecordsProjectNetworkEndpointIncide
if resource.Availability.TargetKind != string(config.AvailabilityTargetDevice) {
t.Fatalf("availability target kind = %q, want %q", resource.Availability.TargetKind, config.AvailabilityTargetDevice)
}
if resource.Availability.Evidence == nil {
t.Fatal("availability evidence = nil")
}
if err := resource.Availability.Evidence.Validate(); err != nil {
t.Fatalf("availability evidence validation error = %v", err)
}
if resource.Availability.Evidence.Subject.ProviderRef != target.ID ||
resource.Availability.Evidence.Subject.ProviderScope != "availability-target" {
t.Fatalf("availability evidence subject = %+v", resource.Availability.Evidence.Subject)
}
if resource.Availability.Evidence.ObservedAt != checkedAt {
t.Fatalf("evidence observed at = %v, want %v", resource.Availability.Evidence.ObservedAt, checkedAt)
}
if len(resource.Incidents) != 1 || resource.Incidents[0].Code != "availability_unreachable" {
t.Fatalf("incidents = %+v, want availability_unreachable", resource.Incidents)
}
@@ -179,6 +193,13 @@ func TestAvailabilityResourceFromTargetOmitsUnsetProbeTimes(t *testing.T) {
if resource.Availability.LastSuccess != nil {
t.Fatalf("last success = %v, want nil before the first successful probe", resource.Availability.LastSuccess)
}
if resource.Availability.Evidence == nil {
t.Fatal("availability evidence = nil before first probe")
}
if resource.Availability.Evidence.Completeness != operationaltrust.EvidencePartial ||
resource.Availability.Evidence.Confidence != operationaltrust.EvidenceUnknown {
t.Fatalf("initial availability evidence = %+v, want partial/unknown", resource.Availability.Evidence)
}
}
func TestAvailabilityResourceFromTargetPreservesProbeTimes(t *testing.T) {
+125
View File
@@ -0,0 +1,125 @@
package unifiedresources
import (
"fmt"
"sort"
"strings"
)
// AvailabilityChecksForResource returns the complete canonical availability
// facet set. The singular Availability field is retained as an additive
// compatibility summary and is folded into the result when older payloads do
// not yet carry AvailabilityChecks.
func AvailabilityChecksForResource(resource Resource) []AvailabilityData {
return mergeAvailabilityChecks(nil, nil, resource.AvailabilityChecks, resource.Availability)
}
// AvailabilityCheckByTargetID returns the observation facet associated with a
// provider target. Alert and evidence consumers use this instead of assuming
// the compatibility summary is the incident's originating check.
func AvailabilityCheckByTargetID(resource Resource, targetID string) *AvailabilityData {
targetID = strings.TrimSpace(targetID)
for _, check := range AvailabilityChecksForResource(resource) {
if strings.TrimSpace(check.TargetID) == targetID {
cloned := cloneAvailabilityData(&check)
return cloned
}
}
return nil
}
func normalizeResourceAvailability(resource *Resource) {
if resource == nil {
return
}
resource.AvailabilityChecks = mergeAvailabilityChecks(
nil,
nil,
resource.AvailabilityChecks,
resource.Availability,
)
resource.Availability = primaryAvailabilityCheck(resource.AvailabilityChecks)
}
func mergeAvailabilityChecks(
existing []AvailabilityData,
existingPrimary *AvailabilityData,
incoming []AvailabilityData,
incomingPrimary *AvailabilityData,
) []AvailabilityData {
byKey := make(map[string]AvailabilityData, len(existing)+len(incoming)+2)
add := func(check AvailabilityData) {
key := availabilityCheckKey(check)
if key == "" {
return
}
cloned := cloneAvailabilityData(&check)
if cloned != nil {
byKey[key] = *cloned
}
}
for _, check := range existing {
add(check)
}
if existingPrimary != nil {
add(*existingPrimary)
}
for _, check := range incoming {
add(check)
}
if incomingPrimary != nil {
add(*incomingPrimary)
}
if len(byKey) == 0 {
return nil
}
keys := make([]string, 0, len(byKey))
for key := range byKey {
keys = append(keys, key)
}
sort.Strings(keys)
out := make([]AvailabilityData, 0, len(keys))
for _, key := range keys {
out = append(out, byKey[key])
}
return out
}
func availabilityCheckKey(check AvailabilityData) string {
if targetID := strings.TrimSpace(check.TargetID); targetID != "" {
return "target:" + targetID
}
address := strings.ToLower(strings.TrimSpace(check.Address))
protocol := strings.ToLower(strings.TrimSpace(check.Protocol))
path := strings.TrimSpace(check.Path)
if address == "" && protocol == "" && check.Port == 0 && path == "" {
return ""
}
return fmt.Sprintf("endpoint:%s:%s:%d:%s", protocol, address, check.Port, path)
}
func primaryAvailabilityCheck(checks []AvailabilityData) *AvailabilityData {
if len(checks) == 0 {
return nil
}
best := 0
for index := 1; index < len(checks); index++ {
if availabilityCheckPriority(checks[index]) < availabilityCheckPriority(checks[best]) {
best = index
}
}
return cloneAvailabilityData(&checks[best])
}
func availabilityCheckPriority(check AvailabilityData) int {
if check.LastChecked != nil && !check.Available {
return 0
}
if check.LastChecked == nil ||
check.CorrelationState == AvailabilityCorrelationAmbiguous ||
check.CorrelationState == AvailabilityCorrelationUnresolved {
return 1
}
return 2
}
@@ -3,6 +3,8 @@ package unifiedresources
import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
)
// ingestAgentFixture ingests a single agent resource and returns its
@@ -48,9 +50,42 @@ func availabilityProbeRecord(targetID, address string, facet *AvailabilityData)
}
}
func availabilityProbeEvidence(t *testing.T, targetID string, observedAt time.Time) *operationaltrust.EvidenceEnvelope {
t.Helper()
source := operationaltrust.EvidenceSource{
Provider: string(SourceAvailability),
Collector: "availability-poller",
}
subject := operationaltrust.EvidenceSubject{
ProviderRef: targetID,
ProviderScope: "availability-target",
}
id, err := operationaltrust.NewEvidenceID(source, subject, observedAt, targetID)
if err != nil {
t.Fatalf("NewEvidenceID() error = %v", err)
}
validUntil := observedAt.Add(2 * time.Minute)
return &operationaltrust.EvidenceEnvelope{
ID: id,
Source: source,
Subject: subject,
ObservedAt: observedAt,
IngestedAt: observedAt,
ValidUntil: &validUntil,
Completeness: operationaltrust.EvidenceComplete,
Confidence: operationaltrust.EvidenceConfirmed,
Permissions: operationaltrust.EvidencePermissionsSufficient,
PayloadRef: &operationaltrust.EvidencePayloadRef{
Kind: "availability-target",
ID: targetID,
},
}
}
func TestAvailabilityExplicitLinkAttachesFacetToKnownResource(t *testing.T) {
rr := NewRegistry(nil)
hostID := ingestAgentFixture(t, rr, "host-1", "machine-1")
observedAt := time.Now().UTC()
rr.IngestRecords(SourceAvailability, []IngestRecord{
availabilityProbeRecord("probe-1", "192.0.2.10", &AvailabilityData{
@@ -59,6 +94,8 @@ func TestAvailabilityExplicitLinkAttachesFacetToKnownResource(t *testing.T) {
Protocol: "icmp",
Enabled: true,
Available: true,
LastChecked: &observedAt,
Evidence: availabilityProbeEvidence(t, "probe-1", observedAt),
}),
})
@@ -75,6 +112,30 @@ func TestAvailabilityExplicitLinkAttachesFacetToKnownResource(t *testing.T) {
if !hasDataSource(host.Sources, SourceAvailability) {
t.Fatalf("expected host sources to include availability, got %v", host.Sources)
}
if host.Availability.CorrelationState != AvailabilityCorrelationAttached ||
host.Availability.CorrelationRule != "explicit_resource_link" {
t.Fatalf("availability correlation = %+v, want attached explicit link", host.Availability)
}
if host.Availability.Evidence == nil ||
host.Availability.Evidence.Subject.ResourceID != hostID ||
host.Availability.Evidence.Subject.ProviderRef != "" {
t.Fatalf("bound evidence = %+v, want canonical subject %q", host.Availability.Evidence, hostID)
}
if host.Availability.Evidence.Correlation == nil ||
host.Availability.Evidence.Correlation.Rule != "explicit_resource_link" {
t.Fatalf("evidence correlation = %+v, want explicit resource link", host.Availability.Evidence.Correlation)
}
foundChecksRelationship := false
for _, relationship := range host.Relationships {
if relationship.Type == RelChecks &&
relationship.TargetID == hostID &&
relationship.Metadata["targetId"] == "probe-1" {
foundChecksRelationship = true
}
}
if !foundChecksRelationship {
t.Fatalf("relationships = %+v, want availability checks edge", host.Relationships)
}
}
func TestAvailabilityUnlinkedUnmatchedMintsNetworkEndpoint(t *testing.T) {
@@ -86,6 +147,9 @@ func TestAvailabilityUnlinkedUnmatchedMintsNetworkEndpoint(t *testing.T) {
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 1 {
t.Fatalf("expected 1 standalone network endpoint, got %d", len(got))
} else if got[0].Availability == nil ||
got[0].Availability.CorrelationState != AvailabilityCorrelationStandalone {
t.Fatalf("standalone availability correlation = %+v", got[0].Availability)
}
}
@@ -109,6 +173,72 @@ func TestAvailabilityExactIPMatchAttachesToKnownResource(t *testing.T) {
if !ok || host == nil || host.Availability == nil || host.Availability.TargetID != "probe-ip" {
t.Fatalf("expected availability facet probe-ip on host, got %+v", host)
}
if host.Availability.CorrelationRule != "normalized_ip" {
t.Fatalf("correlation rule = %q, want normalized_ip", host.Availability.CorrelationRule)
}
}
func TestAvailabilityExactFullHostnameMatchAttachesToKnownResource(t *testing.T) {
rr := NewRegistry(nil)
now := time.Now().UTC()
rr.IngestRecords(SourceAgent, []IngestRecord{{
SourceID: "host-1",
Resource: Resource{
Type: ResourceTypeAgent,
Name: "host-1",
Status: StatusOnline,
LastSeen: now,
},
Identity: ResourceIdentity{
MachineID: "machine-1",
Hostnames: []string{"API.Example.Test."},
},
}})
hostID := rr.ListByType(ResourceTypeAgent)[0].ID
record := availabilityProbeRecord("probe-hostname", "api.example.test", nil)
record.Identity = ResourceIdentity{Hostnames: []string{"api.example.test"}}
rr.IngestRecords(SourceAvailability, []IngestRecord{record})
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 0 {
t.Fatalf("expected hostname probe to attach, got %d standalone endpoints", len(got))
}
host, ok := rr.Get(hostID)
if !ok || host == nil || host.Availability == nil {
t.Fatalf("host availability = %+v", host)
}
if host.Availability.CorrelationRule != "normalized_hostname" {
t.Fatalf("correlation rule = %q, want normalized_hostname", host.Availability.CorrelationRule)
}
}
func TestAvailabilityShortHostnameCollisionDoesNotAttach(t *testing.T) {
rr := NewRegistry(nil)
now := time.Now().UTC()
rr.IngestRecords(SourceAgent, []IngestRecord{
{
SourceID: "host-a",
Resource: Resource{Type: ResourceTypeAgent, Name: "host-a", Status: StatusOnline, LastSeen: now},
Identity: ResourceIdentity{MachineID: "machine-a", Hostnames: []string{"api.alpha.test"}},
},
{
SourceID: "host-b",
Resource: Resource{Type: ResourceTypeAgent, Name: "host-b", Status: StatusOnline, LastSeen: now},
Identity: ResourceIdentity{MachineID: "machine-b", Hostnames: []string{"api.beta.test"}},
},
})
record := availabilityProbeRecord("probe-hostname", "api.gamma.test", nil)
record.Identity = ResourceIdentity{Hostnames: []string{"api.gamma.test"}}
rr.IngestRecords(SourceAvailability, []IngestRecord{record})
endpoints := rr.ListByType(ResourceTypeNetworkEndpoint)
if len(endpoints) != 1 || endpoints[0].Availability == nil {
t.Fatalf("standalone endpoints = %+v, want unresolved hostname endpoint", endpoints)
}
if endpoints[0].Availability.CorrelationState != AvailabilityCorrelationStandalone {
t.Fatalf("correlation state = %q, want standalone", endpoints[0].Availability.CorrelationState)
}
}
func TestAvailabilityAmbiguousIPDoesNotAttach(t *testing.T) {
@@ -125,10 +255,45 @@ func TestAvailabilityAmbiguousIPDoesNotAttach(t *testing.T) {
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 1 {
t.Fatalf("expected 1 standalone endpoint (ambiguous IP, no attach), got %d", len(got))
} else if got[0].Availability == nil ||
got[0].Availability.CorrelationState != AvailabilityCorrelationAmbiguous ||
got[0].Availability.CorrelationCandidates != 2 {
t.Fatalf("ambiguous correlation = %+v, want 2 candidates", got[0].Availability)
}
}
func TestAvailabilityDoesNotOverwriteDifferentAttachedTarget(t *testing.T) {
func TestAvailabilityInvalidExplicitLinkFailsClosedBeforeAddressCorrelation(t *testing.T) {
rr := NewRegistry(nil)
hostID := ingestAgentFixture(t, rr, "host-1", "machine-1", "203.0.113.20")
rr.IngestRecords(SourceAvailability, []IngestRecord{
availabilityProbeRecord("probe-explicit-missing", "203.0.113.20", &AvailabilityData{
LinkedResourceID: "missing-resource",
Address: "203.0.113.20",
Protocol: "icmp",
Enabled: true,
Available: true,
}),
})
host, ok := rr.Get(hostID)
if !ok || host == nil {
t.Fatalf("host %q missing", hostID)
}
if host.Availability != nil {
t.Fatalf("invalid explicit link must not fall back to IP attachment, got %+v", host.Availability)
}
endpoints := rr.ListByType(ResourceTypeNetworkEndpoint)
if len(endpoints) != 1 || endpoints[0].Availability == nil {
t.Fatalf("unresolved endpoints = %+v", endpoints)
}
if endpoints[0].Availability.CorrelationState != AvailabilityCorrelationUnresolved ||
endpoints[0].Availability.CorrelationReason != "explicit_resource_link_unresolved" {
t.Fatalf("correlation = %+v, want explicit unresolved", endpoints[0].Availability)
}
}
func TestAvailabilityKeepsMultipleChecksOnOneCanonicalResource(t *testing.T) {
rr := NewRegistry(nil)
hostID := ingestAgentFixture(t, rr, "host-1", "machine-1")
@@ -141,8 +306,9 @@ func TestAvailabilityDoesNotOverwriteDifferentAttachedTarget(t *testing.T) {
Available: true,
}),
})
// A second probe explicitly linked to the same host must not overwrite the
// first target's facet; it stays a standalone network-endpoint instead.
// A second probe explicitly linked to the same host belongs to the same
// canonical resource. The singular facet stays as a compatibility summary
// while the plural facet and relationships retain both checks.
rr.IngestRecords(SourceAvailability, []IngestRecord{
availabilityProbeRecord("probe-b", "203.0.113.11", &AvailabilityData{
LinkedResourceID: hostID,
@@ -154,10 +320,30 @@ func TestAvailabilityDoesNotOverwriteDifferentAttachedTarget(t *testing.T) {
})
host, ok := rr.Get(hostID)
if !ok || host == nil || host.Availability == nil || host.Availability.TargetID != "probe-a" {
t.Fatalf("expected first probe probe-a to remain attached, got %+v", host)
if !ok || host == nil || host.Availability == nil {
t.Fatalf("expected availability summary on host, got %+v", host)
}
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 1 {
t.Fatalf("expected second probe to stay standalone (1 endpoint), got %d", len(got))
checks := AvailabilityChecksForResource(*host)
if len(checks) != 2 {
t.Fatalf("availability checks = %+v, want both attached checks", checks)
}
targets := map[string]bool{}
for _, check := range checks {
targets[check.TargetID] = true
}
if !targets["probe-a"] || !targets["probe-b"] {
t.Fatalf("availability targets = %+v, want probe-a and probe-b", targets)
}
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 0 {
t.Fatalf("expected both probes to attach (0 endpoints), got %d", len(got))
}
checkRelationships := 0
for _, relationship := range host.Relationships {
if relationship.Type == RelChecks {
checkRelationships++
}
}
if checkRelationships != 2 {
t.Fatalf("checks relationships = %d, want 2: %+v", checkRelationships, host.Relationships)
}
}
+21 -1
View File
@@ -44,6 +44,7 @@ func cloneResource(in *Resource) Resource {
out.TrueNAS = cloneTrueNASData(in.TrueNAS)
out.VMware = cloneVMwareData(in.VMware)
out.Availability = cloneAvailabilityData(in.Availability)
out.AvailabilityChecks = cloneAvailabilityDataSlice(in.AvailabilityChecks)
out.FacetCounts = resourceFacetCounts(out)
RefreshCanonicalMetadata(&out)
return out
@@ -51,7 +52,8 @@ func cloneResource(in *Resource) Resource {
func resourceFacetCounts(resource Resource) ResourceFacetCounts {
return ResourceFacetCounts{
RecentChanges: len(resource.RecentChanges),
RecentChanges: len(resource.RecentChanges),
AvailabilityChecks: len(AvailabilityChecksForResource(resource)),
}
}
@@ -327,9 +329,27 @@ func cloneAvailabilityData(in *AvailabilityData) *AvailabilityData {
out := *in
out.LastChecked = cloneTimePtr(in.LastChecked)
out.LastSuccess = cloneTimePtr(in.LastSuccess)
if in.Evidence != nil {
evidence := in.Evidence.Clone()
out.Evidence = &evidence
}
return &out
}
func cloneAvailabilityDataSlice(in []AvailabilityData) []AvailabilityData {
if in == nil {
return nil
}
out := make([]AvailabilityData, 0, len(in))
for index := range in {
cloned := cloneAvailabilityData(&in[index])
if cloned != nil {
out = append(out, *cloned)
}
}
return out
}
func cloneVMwareData(in *VMwareData) *VMwareData {
if in == nil {
return nil
+9
View File
@@ -158,17 +158,26 @@ func TestCloneResource_MutateAvailabilityTimes(t *testing.T) {
LastChecked: &checkedAt,
LastSuccess: &succeededAt,
},
AvailabilityChecks: []AvailabilityData{{
TargetID: "probe-1",
LastChecked: &checkedAt,
LastSuccess: &succeededAt,
}},
}
cloned := cloneResource(original)
*cloned.Availability.LastChecked = checkedAt.Add(time.Hour)
*cloned.Availability.LastSuccess = succeededAt.Add(time.Hour)
*cloned.AvailabilityChecks[0].LastChecked = checkedAt.Add(2 * time.Hour)
if !original.Availability.LastChecked.Equal(checkedAt) {
t.Error("mutating cloned availability LastChecked should not affect original")
}
if !original.Availability.LastSuccess.Equal(succeededAt) {
t.Error("mutating cloned availability LastSuccess should not affect original")
}
if !original.AvailabilityChecks[0].LastChecked.Equal(checkedAt) {
t.Error("mutating cloned AvailabilityChecks should not affect original")
}
}
func TestCloneResource_MutateVMwareDetailSlices(t *testing.T) {
@@ -673,10 +673,10 @@ func TestAgentlessAvailabilityTargetKindStaysCanonical(t *testing.T) {
},
"types.go": {
"Availability *AvailabilityData `json:\"availability,omitempty\"`",
"TargetKind string `json:\"targetKind,omitempty\"`",
"LinkedResourceID string `json:\"linkedResourceId,omitempty\"`",
"LastChecked *time.Time `json:\"lastChecked,omitempty\"`",
"LastSuccess *time.Time `json:\"lastSuccess,omitempty\"`",
"TargetKind string `json:\"targetKind,omitempty\"`",
"LinkedResourceID string `json:\"linkedResourceId,omitempty\"`",
"LastChecked *time.Time `json:\"lastChecked,omitempty\"`",
"LastSuccess *time.Time `json:\"lastSuccess,omitempty\"`",
},
filepath.Join("..", "..", "frontend-modern", "src", "api", "availabilityTargets.ts"): {
"export type AvailabilityTargetKind = 'machine' | 'service' | 'device';",
@@ -36,7 +36,7 @@ func IncidentCategoryForResource(resource *Resource, incident ResourceIncident)
return IncidentCategoryAvailability
}
if resource.Type == ResourceTypeNetworkEndpoint || resource.Availability != nil {
if resource.Type == ResourceTypeNetworkEndpoint || len(AvailabilityChecksForResource(*resource)) > 0 {
return IncidentCategoryAvailability
}
if resource.Type == ResourceTypePhysicalDisk {
+1 -1
View File
@@ -96,7 +96,7 @@ func addPlatformScopesForFacets(scopes map[string]struct{}, resource Resource) {
if resource.VMware != nil {
addPlatformScope(scopes, "vmware-vsphere")
}
if resource.Availability != nil || CanonicalResourceType(resource.Type) == ResourceTypeNetworkEndpoint {
if len(AvailabilityChecksForResource(resource)) > 0 || CanonicalResourceType(resource.Type) == ResourceTypeNetworkEndpoint {
addPlatformScope(scopes, "availability")
}
}
+278 -45
View File
@@ -11,6 +11,7 @@ import (
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
"github.com/rcourtman/pulse-go-rewrite/internal/storagehealth"
"github.com/rcourtman/pulse-go-rewrite/pkg/fsfilters"
)
@@ -610,6 +611,7 @@ func (rr *ResourceRegistry) ingestResources(resources []Resource, thresholds map
continue
}
resource.Type = CanonicalResourceType(resource.Type)
normalizeResourceAvailability(resource)
if resource.SourceStatus == nil && len(resource.Sources) > 0 {
resource.SourceStatus = make(map[DataSource]SourceStatus, len(resource.Sources))
for _, source := range resource.Sources {
@@ -670,6 +672,18 @@ func (rr *ResourceRegistry) seedSourceMappingsFromResourceLocked(resource *Resou
})
for _, source := range sources {
if source == SourceAvailability {
if _, ok := rr.bySource[source]; !ok {
rr.bySource[source] = make(map[string]string)
}
for _, check := range AvailabilityChecksForResource(*resource) {
sourceID := normalizeSourceID(check.TargetID)
if sourceID != "" {
rr.bySource[source][sourceID] = resource.ID
}
}
continue
}
sourceID := rr.seedSourceIDForResourceLocked(resource, source)
sourceID = normalizeSourceID(sourceID)
if sourceID == "" {
@@ -2379,16 +2393,27 @@ func (rr *ResourceRegistry) ingest(source DataSource, sourceID string, resource
}
}
candidateID := rr.sourceSpecificID(resource.Type, source, sourceID)
// Agentless availability probes attach as a facet on the known resource
// they monitor instead of minting a parallel network-endpoint. An explicit
// linkedResourceId wins; otherwise an exact, unique IP match may attach.
// Lossy hostname-only correlation is intentionally avoided to prevent
// ambiguous one-sided merges, and the link is refused when it would
// overwrite a different target's already-attached facet or fold one probe
// into another.
// linkedResourceId wins; otherwise an exact, unique normalized IP or full
// hostname may attach. Ambiguous or invalid correlations remain explicit
// on the fallback endpoint and are never guessed.
var availabilityResolution availabilityLinkResolution
if source == SourceAvailability && resource.Type == ResourceTypeNetworkEndpoint {
if linked := rr.resolveAvailabilityLink(resource); linked != "" {
availabilityResolution = rr.resolveAvailabilityLink(resource)
applyAvailabilityResolution(resource.Availability, availabilityResolution)
if linked := availabilityResolution.ResourceID; linked != "" {
if existing := rr.resources[linked]; existing != nil {
bindAvailabilityEvidence(resource.Availability, existing.ID, availabilityResolution)
existing.Relationships = upsertAvailabilityCheckRelationship(
existing.Relationships,
candidateID,
existing.ID,
resource.Availability,
availabilityResolution,
)
rr.mergeInto(existing, resource, source)
rr.bySource[source][sourceID] = existing.ID
return existing.ID
@@ -2396,8 +2421,6 @@ func (rr *ResourceRegistry) ingest(source DataSource, sourceID string, resource
}
}
candidateID := rr.sourceSpecificID(resource.Type, source, sourceID)
if resource.Type == ResourceTypeAgent || resource.Type == ResourceTypePhysicalDisk {
if match, excluded := rr.findMatch(identity, resource.Type, candidateID); match != nil {
existing := rr.resources[match.ResourceB]
@@ -2416,6 +2439,10 @@ func (rr *ResourceRegistry) ingest(source DataSource, sourceID string, resource
}
resource.ID = rr.chooseNewID(resource.Type, identity, source, sourceID)
if source == SourceAvailability && resource.Availability != nil {
bindAvailabilityEvidence(resource.Availability, resource.ID, availabilityResolution)
normalizeResourceAvailability(&resource)
}
if existing := rr.resources[resource.ID]; existing != nil {
rr.mergeInto(existing, resource, source)
rr.bySource[source][sourceID] = existing.ID
@@ -2530,42 +2557,139 @@ func (rr *ResourceRegistry) resolveLinkedResource(source DataSource, sourceID st
return ""
}
type availabilityLinkResolution struct {
ResourceID string
State AvailabilityCorrelationState
Rule string
Reason string
CandidateCount int
MatchedFields map[string]string
}
// resolveAvailabilityLink attaches an agentless availability probe to the
// known resource it monitors, instead of minting a parallel
// network-endpoint. An explicit linkedResourceId wins unambiguously;
// otherwise the probe address may attach on an exact, unique IP overlap.
// Lossy hostname-only correlation is intentionally avoided, and the link is
// refused when it would overwrite a different target's already-attached
// availability facet or fold one probe into another.
func (rr *ResourceRegistry) resolveAvailabilityLink(resource Resource) string {
// known resource it monitors. Explicit links are authoritative and fail
// closed. Automatic correlation accepts only one exact normalized IP or full
// hostname candidate; ambiguous identity is preserved as such.
func (rr *ResourceRegistry) resolveAvailabilityLink(resource Resource) availabilityLinkResolution {
if resource.Availability == nil {
return ""
return availabilityLinkResolution{
State: AvailabilityCorrelationUnresolved,
Reason: "availability_metadata_missing",
}
}
if linkedID := strings.TrimSpace(resource.Availability.LinkedResourceID); linkedID != "" {
if resolved := rr.resolveAvailabilityLinkedResource(linkedID, resource); resolved != "" {
return resolved
return availabilityLinkResolution{
ResourceID: resolved,
State: AvailabilityCorrelationAttached,
Rule: "explicit_resource_link",
Reason: "explicit_resource_link",
CandidateCount: 1,
MatchedFields: map[string]string{"linkedResourceId": linkedID},
}
}
return availabilityLinkResolution{
State: AvailabilityCorrelationUnresolved,
Rule: "explicit_resource_link",
Reason: "explicit_resource_link_unresolved",
CandidateCount: 0,
MatchedFields: map[string]string{"linkedResourceId": linkedID},
}
}
matchID := ""
for _, candidate := range rr.matcher.FindCandidates(resource.Identity) {
if candidate.Reason != "ip" && candidate.Reason != "hostname+ip" {
continue
rule, normalizedAddress, candidates := rr.availabilityAddressCandidates(resource.Identity)
switch len(candidates) {
case 0:
return availabilityLinkResolution{
State: AvailabilityCorrelationStandalone,
Rule: rule,
Reason: "no_canonical_resource_match",
CandidateCount: 0,
}
existing := rr.resources[candidate.ID]
if existing == nil || isAvailabilityOwnedResource(*existing) {
continue
case 1:
return availabilityLinkResolution{
ResourceID: candidates[0],
State: AvailabilityCorrelationAttached,
Rule: rule,
Reason: "unique_canonical_resource_match",
CandidateCount: 1,
MatchedFields: availabilityMatchedFields(rule, normalizedAddress),
}
if !availabilityFacetCompatible(existing, resource) {
continue
default:
return availabilityLinkResolution{
State: AvailabilityCorrelationAmbiguous,
Rule: rule,
Reason: "multiple_canonical_resource_matches",
CandidateCount: len(candidates),
}
if matchID != "" && matchID != candidate.ID {
return ""
}
matchID = candidate.ID
}
return matchID
}
func (rr *ResourceRegistry) availabilityAddressCandidates(identity ResourceIdentity) (string, string, []string) {
for _, rawIP := range identity.IPAddresses {
normalizedIP := NormalizeIP(rawIP)
if normalizedIP == "" || isNonUniqueIP(normalizedIP) {
continue
}
return "normalized_ip", normalizedIP, rr.resourcesMatchingAvailabilityAddress(
func(existingIdentity ResourceIdentity) bool {
for _, candidateIP := range existingIdentity.IPAddresses {
if NormalizeIP(candidateIP) == normalizedIP {
return true
}
}
return false
},
)
}
for _, rawHostname := range identity.Hostnames {
normalizedHostname := NormalizeFullHostname(rawHostname)
if normalizedHostname == "" {
continue
}
return "normalized_hostname", normalizedHostname, rr.resourcesMatchingAvailabilityAddress(
func(existingIdentity ResourceIdentity) bool {
for _, candidateHostname := range existingIdentity.Hostnames {
if NormalizeFullHostname(candidateHostname) == normalizedHostname {
return true
}
}
return false
},
)
}
return "none", "", nil
}
func (rr *ResourceRegistry) resourcesMatchingAvailabilityAddress(
matches func(ResourceIdentity) bool,
) []string {
resourceIDs := make([]string, 0)
for resourceID, existing := range rr.resources {
if existing == nil || isAvailabilityOwnedResource(*existing) || !matches(existing.Identity) {
continue
}
resourceIDs = append(resourceIDs, resourceID)
}
sort.Strings(resourceIDs)
return resourceIDs
}
func availabilityMatchedFields(rule string, normalizedAddress string) map[string]string {
if normalizedAddress == "" {
return nil
}
switch rule {
case "normalized_ip":
return map[string]string{"ipAddress": normalizedAddress}
case "normalized_hostname":
return map[string]string{"hostname": normalizedAddress}
default:
return nil
}
}
func (rr *ResourceRegistry) resolveAvailabilityLinkedResource(ref string, incoming Resource) string {
@@ -2576,7 +2700,7 @@ func (rr *ResourceRegistry) resolveAvailabilityLinkedResource(ref string, incomi
exactID := CanonicalResourceID(ref)
if existing := rr.resources[exactID]; existing != nil {
if !isAvailabilityOwnedResource(*existing) && availabilityFacetCompatible(existing, incoming) {
if !isAvailabilityOwnedResource(*existing) {
return exactID
}
return ""
@@ -2587,7 +2711,7 @@ func (rr *ResourceRegistry) resolveAvailabilityLinkedResource(ref string, incomi
rr.uniqueCanonicalIdentityResourceIDLocked(ref),
) {
existing := rr.resources[candidateID]
if existing != nil && !isAvailabilityOwnedResource(*existing) && availabilityFacetCompatible(existing, incoming) {
if existing != nil && !isAvailabilityOwnedResource(*existing) {
return candidateID
}
}
@@ -2613,19 +2737,122 @@ func isAvailabilityOwnedResource(r Resource) bool {
return true
}
// availabilityFacetCompatible reports whether an existing resource can accept
// the incoming availability facet without silently overwriting a different
// target's already-attached probe.
func availabilityFacetCompatible(existing *Resource, incoming Resource) bool {
if existing == nil || existing.Availability == nil {
return true
func applyAvailabilityResolution(
availability *AvailabilityData,
resolution availabilityLinkResolution,
) {
if availability == nil {
return
}
current := strings.TrimSpace(existing.Availability.TargetID)
if current == "" {
return true
availability.CorrelationState = resolution.State
availability.CorrelationRule = resolution.Rule
availability.CorrelationReason = resolution.Reason
availability.CorrelationCandidates = resolution.CandidateCount
}
func bindAvailabilityEvidence(
availability *AvailabilityData,
resourceID string,
resolution availabilityLinkResolution,
) {
if availability == nil || availability.Evidence == nil {
return
}
incomingID := strings.TrimSpace(incoming.Availability.TargetID)
return incomingID == "" || incomingID == current
resourceID = CanonicalResourceID(resourceID)
if resourceID == "" {
return
}
evidence := availability.Evidence.Clone()
evidence.Subject = operationaltrust.EvidenceSubject{ResourceID: resourceID}
evidence.Correlation = nil
if resolution.State == AvailabilityCorrelationAttached &&
resolution.CandidateCount == 1 &&
len(resolution.MatchedFields) > 0 {
evidence.Correlation = &operationaltrust.IdentityCorrelation{
Rule: resolution.Rule,
MatchedFields: cloneStringMap(resolution.MatchedFields),
CandidateCount: 1,
}
}
if (resolution.State == AvailabilityCorrelationAmbiguous ||
resolution.State == AvailabilityCorrelationUnresolved) &&
evidence.Reason == nil {
evidence.Reason = &operationaltrust.EvidenceReason{
Code: resolution.Reason,
Message: "Pulse could not bind this observation to one canonical resource.",
}
}
sourceObservationID := strings.TrimSpace(availability.TargetID)
if evidence.PayloadRef != nil && strings.TrimSpace(evidence.PayloadRef.ID) != "" {
sourceObservationID = strings.TrimSpace(evidence.PayloadRef.ID)
}
evidenceID, err := operationaltrust.NewEvidenceID(
evidence.Source,
evidence.Subject,
evidence.ObservedAt,
sourceObservationID,
)
if err != nil {
return
}
evidence.ID = evidenceID
if err := evidence.Validate(); err != nil {
return
}
availability.Evidence = &evidence
}
func upsertAvailabilityCheckRelationship(
relationships []ResourceRelationship,
checkResourceID string,
targetResourceID string,
availability *AvailabilityData,
resolution availabilityLinkResolution,
) []ResourceRelationship {
checkResourceID = CanonicalResourceID(checkResourceID)
targetResourceID = CanonicalResourceID(targetResourceID)
if availability == nil || checkResourceID == "" || targetResourceID == "" {
return relationships
}
observedAt := time.Time{}
if availability.LastChecked != nil {
observedAt = availability.LastChecked.UTC()
}
if observedAt.IsZero() && availability.Evidence != nil {
observedAt = availability.Evidence.ObservedAt
}
confidence := 0.95
if resolution.Rule == "explicit_resource_link" {
confidence = 1
}
relation := ResourceRelationship{
SourceID: checkResourceID,
TargetID: targetResourceID,
Type: RelChecks,
Confidence: confidence,
Active: true,
Discoverer: "availability_attachment",
ObservedAt: observedAt,
LastSeenAt: observedAt,
Metadata: map[string]any{
"targetId": availability.TargetID,
"protocol": availability.Protocol,
"correlationRule": resolution.Rule,
},
}
out := append([]ResourceRelationship(nil), relationships...)
for index := range out {
if out[index].Type == RelChecks &&
CanonicalResourceID(out[index].SourceID) == checkResourceID {
out[index] = relation
return out
}
}
return append(out, relation)
}
func (rr *ResourceRegistry) findCorroboratedOneSidedProxmoxLink(
@@ -2750,7 +2977,13 @@ func (rr *ResourceRegistry) mergeInto(existing *Resource, incoming Resource, sou
case SourceVMware:
existing.VMware = mergeVMwareData(existing.VMware, incoming.VMware)
case SourceAvailability:
existing.Availability = incoming.Availability
existing.AvailabilityChecks = mergeAvailabilityChecks(
existing.AvailabilityChecks,
existing.Availability,
incoming.AvailabilityChecks,
incoming.Availability,
)
existing.Availability = primaryAvailabilityCheck(existing.AvailabilityChecks)
}
existing.Sources = addSource(existing.Sources, source)
@@ -34,6 +34,8 @@ func RelationshipTypeLabel(t RelationshipType) string {
return "Owned by"
case RelAttachedTo:
return "Attached to"
case RelChecks:
return "Checks"
default:
raw := strings.TrimSpace(strings.ReplaceAll(string(t), "_", " "))
if raw == "" {
@@ -14,6 +14,7 @@ const (
RelExposedBy RelationshipType = "exposed_by" // e.g., container exposed_by ingress
RelOwnedBy RelationshipType = "owned_by" // e.g., pod owned_by deployment
RelAttachedTo RelationshipType = "attached_to" // e.g., container attached_to network
RelChecks RelationshipType = "checks" // e.g., availability check checks resource
)
// ResourceRelationship represents a typed relationship edge between two unified resources.
+40 -18
View File
@@ -5,6 +5,7 @@ import (
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
"github.com/rcourtman/pulse-go-rewrite/internal/storagehealth"
)
@@ -79,12 +80,16 @@ type Resource struct {
TrueNAS *TrueNASData `json:"truenas,omitempty"`
VMware *VMwareData `json:"vmware,omitempty"`
Availability *AvailabilityData `json:"availability,omitempty"`
// AvailabilityChecks is the canonical plural facet. Availability remains
// the compatibility summary chosen from this set for existing consumers.
AvailabilityChecks []AvailabilityData `json:"availabilityChecks,omitempty"`
}
// ResourceFacetCounts captures the total count of each resource facet that
// may be surfaced in row summaries or detail drawers.
type ResourceFacetCounts struct {
RecentChanges int `json:"recentChanges"`
AvailabilityChecks int `json:"availabilityChecks,omitempty"`
RecentChangeKinds map[ChangeKind]int `json:"recentChangeKinds,omitempty"`
RecentChangeSourceTypes map[ChangeSourceType]int `json:"recentChangeSourceTypes,omitempty"`
RecentChangeSourceAdapters map[ChangeSourceAdapter]int `json:"recentChangeSourceAdapters,omitempty"`
@@ -1585,26 +1590,43 @@ type TrueNASShare struct {
MapAllGroup string `json:"mapAllGroup,omitempty"`
}
// AvailabilityCorrelationState records whether an agentless check has a
// trustworthy canonical owner. It is deliberately separate from probe health:
// a reachable endpoint can still have ambiguous identity.
type AvailabilityCorrelationState string
const (
AvailabilityCorrelationAttached AvailabilityCorrelationState = "attached"
AvailabilityCorrelationStandalone AvailabilityCorrelationState = "standalone"
AvailabilityCorrelationAmbiguous AvailabilityCorrelationState = "ambiguous"
AvailabilityCorrelationUnresolved AvailabilityCorrelationState = "unresolved"
)
// AvailabilityData contains agentless endpoint probe metadata for a resource.
type AvailabilityData struct {
TargetID string `json:"targetId,omitempty"`
LinkedResourceID string `json:"linkedResourceId,omitempty"`
Name string `json:"name,omitempty"`
TargetKind string `json:"targetKind,omitempty"`
Address string `json:"address,omitempty"`
Protocol string `json:"protocol,omitempty"`
Port int `json:"port,omitempty"`
Path string `json:"path,omitempty"`
Enabled bool `json:"enabled"`
Available bool `json:"available"`
LastChecked *time.Time `json:"lastChecked,omitempty"`
LastSuccess *time.Time `json:"lastSuccess,omitempty"`
LatencyMillis int64 `json:"latencyMillis,omitempty"`
ConsecutiveFailures int `json:"consecutiveFailures,omitempty"`
LastError string `json:"lastError,omitempty"`
FailureThreshold int `json:"failureThreshold,omitempty"`
PollIntervalSeconds int `json:"pollIntervalSeconds,omitempty"`
TimeoutMillis int `json:"timeoutMillis,omitempty"`
TargetID string `json:"targetId,omitempty"`
LinkedResourceID string `json:"linkedResourceId,omitempty"`
Name string `json:"name,omitempty"`
TargetKind string `json:"targetKind,omitempty"`
Address string `json:"address,omitempty"`
Protocol string `json:"protocol,omitempty"`
Port int `json:"port,omitempty"`
Path string `json:"path,omitempty"`
Enabled bool `json:"enabled"`
Available bool `json:"available"`
LastChecked *time.Time `json:"lastChecked,omitempty"`
LastSuccess *time.Time `json:"lastSuccess,omitempty"`
LatencyMillis int64 `json:"latencyMillis,omitempty"`
ConsecutiveFailures int `json:"consecutiveFailures,omitempty"`
LastError string `json:"lastError,omitempty"`
FailureThreshold int `json:"failureThreshold,omitempty"`
PollIntervalSeconds int `json:"pollIntervalSeconds,omitempty"`
TimeoutMillis int `json:"timeoutMillis,omitempty"`
CorrelationState AvailabilityCorrelationState `json:"correlationState,omitempty"`
CorrelationRule string `json:"correlationRule,omitempty"`
CorrelationReason string `json:"correlationReason,omitempty"`
CorrelationCandidates int `json:"correlationCandidates,omitempty"`
Evidence *operationaltrust.EvidenceEnvelope `json:"evidence,omitempty"`
}
// K8sMetricCapabilities describes which Kubernetes metric families are available
@@ -244,6 +244,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
"allow_same_subsystem_tests": False,
"test_prefixes": [],
"exact_files": [
"internal/monitoring/availability_poller_test.go",
"internal/monitoring/canonical_guardrails_test.go",
"internal/monitoring/monitor_backups_readstate_test.go",
"internal/monitoring/monitor_host_agents_test.go",
@@ -4222,6 +4222,7 @@ class SubsystemLookupTest(unittest.TestCase):
self.assertEqual(
match["verification_requirement"]["exact_files"],
[
"internal/unifiedresources/availability_link_test.go",
"internal/unifiedresources/kubernetes_registry_test.go",
"internal/unifiedresources/pbs_pmg_registry_test.go",
"internal/unifiedresources/registry_merge_policy_test.go",
@@ -4250,6 +4251,7 @@ class SubsystemLookupTest(unittest.TestCase):
self.assertEqual(
match["verification_requirement"]["exact_files"],
[
"internal/unifiedresources/availability_link_test.go",
"internal/unifiedresources/kubernetes_registry_test.go",
"internal/unifiedresources/pbs_pmg_registry_test.go",
"internal/unifiedresources/registry_merge_policy_test.go",
@@ -0,0 +1,738 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { test as base, expect, type Page, type Route } from "@playwright/test";
import { createAuthenticatedStorageState } from "./helpers";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type WorkerFixtures = {
authStorageStatePath: string;
};
const test = base.extend<{}, WorkerFixtures>({
storageState: async ({ authStorageStatePath }, use) => {
await use(authStorageStatePath);
},
authStorageStatePath: [
async ({ browser }, use, workerInfo) => {
const storageStatePath = path.resolve(
__dirname,
"..",
"..",
"tmp",
"playwright-auth",
`operational-trust-availability-facet-${workerInfo.project.name}.json`,
);
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
await createAuthenticatedStorageState(browser, storageStatePath);
try {
await use(storageStatePath);
} finally {
fs.rmSync(storageStatePath, { force: true });
}
},
{ scope: "worker" },
],
});
const DOCKER_HOST_ID = "docker-host:operational-trust";
const DOCKER_HOST_NAME = "Operational Trust Docker Host";
const ATTACHED_TCP_TARGET_ID = "ops-api";
const ATTACHED_HTTPS_TARGET_ID = "ops-web";
const CANONICAL_AVAILABILITY_SPEC_ID =
"alertspec:provider-incident:22f0f1f19599cd71";
const AVAILABILITY_EVIDENCE_ID = "evidence_2825048c9470f82ba5490f8f8496813a";
const ATTENTION_ID = `${DOCKER_HOST_ID}::${CANONICAL_AVAILABILITY_SPEC_ID}`;
type RouteResource = Record<string, unknown>;
const resourceResponse = (resources: RouteResource[]) => ({
data: resources,
meta: {
page: 1,
limit: 100,
total: resources.length,
totalPages: resources.length > 0 ? 1 : 0,
},
links: { next: null },
});
async function routeResources(page: Page, resources: RouteResource[]) {
// Keep the fixture authoritative: an empty mocked socket makes the
// websocket-first resource hook fall back to the routed REST snapshot
// without accepting a live backend frame.
await page.routeWebSocket("**/ws", () => {});
await page.route("**/api/resources**", async (route) => {
const requestUrl = new URL(route.request().url());
if (requestUrl.pathname !== "/api/resources") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(resourceResponse(resources)),
});
});
}
const evidenceEnvelope = ({
id,
resourceId,
targetId,
observedAt,
validUntil,
completeness = "complete",
confidence = "confirmed",
}: {
id: string;
resourceId: string;
targetId: string;
observedAt: string;
validUntil?: string;
completeness?: "complete" | "partial" | "unavailable";
confidence?: "confirmed" | "inferred" | "unknown";
}) => ({
id,
source: {
provider: "availability",
collector: "availability-poller",
},
subject: {
resourceId,
providerRef: targetId,
providerScope: "availability-target",
},
observedAt,
ingestedAt: observedAt,
validUntil,
completeness,
confidence,
permissions: "sufficient",
payloadRef: {
kind: "availability-target",
id: targetId,
},
correlation: {
rule: "explicit-linked-resource",
matchedFields: {
linkedResourceId: resourceId,
},
candidateCount: 1,
},
});
const availabilityCheck = ({
targetId,
address,
protocol,
observedAt,
validUntil,
port,
path: targetPath,
latencyMillis,
}: {
targetId: string;
address: string;
protocol: string;
observedAt: string;
validUntil: string;
port?: number;
path?: string;
latencyMillis: number;
}) => ({
targetId,
linkedResourceId: DOCKER_HOST_ID,
name: targetId,
targetKind: "service",
address,
protocol,
port,
path: targetPath,
enabled: true,
available: true,
lastChecked: observedAt,
lastSuccess: observedAt,
latencyMillis,
consecutiveFailures: 0,
failureThreshold: 2,
pollIntervalSeconds: 60,
timeoutMillis: 5_000,
correlationState: "attached",
correlationRule: "explicit-linked-resource",
correlationCandidates: 1,
evidence: evidenceEnvelope({
id: `evidence_${targetId}`,
resourceId: DOCKER_HOST_ID,
targetId,
observedAt,
validUntil,
}),
});
test.describe("Operational trust availability resource facet", () => {
test.setTimeout(180_000);
test("renders attached checks once on the Docker host and exposes both observations in detail", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("mobile-"),
"Desktop table and drawer proof",
);
const now = Date.now();
const firstObservedAt = new Date(now - 2 * 60_000).toISOString();
const secondObservedAt = new Date(now - 45_000).toISOString();
const validUntil = new Date(now + 5 * 60_000).toISOString();
const tcpCheck = availabilityCheck({
targetId: ATTACHED_TCP_TARGET_ID,
address: "192.0.2.18",
protocol: "tcp",
port: 8007,
observedAt: firstObservedAt,
validUntil,
latencyMillis: 12,
});
const httpsCheck = availabilityCheck({
targetId: ATTACHED_HTTPS_TARGET_ID,
address: "ops.example.test",
protocol: "https",
path: "/health",
observedAt: secondObservedAt,
validUntil,
latencyMillis: 23,
});
await routeResources(page, [
{
id: DOCKER_HOST_ID,
type: "docker-host",
name: DOCKER_HOST_NAME,
status: "online",
lastSeen: secondObservedAt,
sources: ["docker", "availability"],
docker: {
hostname: "ops-docker.example.test",
runtime: "docker",
runtimeVersion: "27.5.1",
containerCount: 4,
hostSourceId: "ops-docker",
uptimeSeconds: 86_400,
},
availability: tcpCheck,
availabilityChecks: [tcpCheck, httpsCheck],
},
]);
await page.goto("/docker/overview", { waitUntil: "domcontentloaded" });
const dockerPage = page.getByTestId("docker-page");
await expect(dockerPage).toBeVisible({ timeout: 30_000 });
const hostRows = dockerPage.locator(
`[data-docker-host-row="${DOCKER_HOST_ID}"]`,
);
await expect(hostRows).toHaveCount(1);
const hostRow = hostRows.first();
await expect(hostRow).toContainText(DOCKER_HOST_NAME);
await expect(
hostRow.getByTitle(/TCP availability probe.*192\.0\.2\.18:8007/i).last(),
).toBeVisible();
await hostRow.click();
const drawer = dockerPage.getByTestId("docker-host-drawer");
await expect(drawer).toBeVisible();
const cards = drawer.getByTestId("availability-probe-status");
await expect(cards).toHaveCount(2);
const tcpCard = cards.filter({ hasText: "192.0.2.18:8007" });
await expect(tcpCard).toHaveCount(1);
await expect(tcpCard).toContainText("TCP 8007");
await expect(tcpCard).toContainText("Up");
await expect(tcpCard).toContainText("12ms");
await expect(tcpCard).toContainText("fresh");
await expect(tcpCard).toContainText("Checked");
await expect(tcpCard.getByText(/(?:ago|now)/i)).toBeVisible();
const httpsCard = cards.filter({ hasText: "ops.example.test" });
await expect(httpsCard).toHaveCount(1);
await expect(httpsCard).toContainText("HTTPS /health");
await expect(httpsCard).toContainText("Up");
await expect(httpsCard).toContainText("23ms");
await expect(httpsCard).toContainText("fresh");
await expect(httpsCard).toContainText("Checked");
});
test("keeps attached checks out of standalone inventory and does not infer health from stale or missing observations", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("mobile-"),
"Desktop availability table proof",
);
const now = Date.now();
const staleObservedAt = new Date(now - 15 * 60_000).toISOString();
const staleValidUntil = new Date(now - 10 * 60_000).toISOString();
const freshObservedAt = new Date(now - 30_000).toISOString();
const freshValidUntil = new Date(now + 2 * 60_000).toISOString();
await routeResources(page, [
{
id: DOCKER_HOST_ID,
type: "docker-host",
name: DOCKER_HOST_NAME,
status: "online",
lastSeen: freshObservedAt,
sources: ["docker", "availability"],
availability: {
targetId: ATTACHED_TCP_TARGET_ID,
linkedResourceId: DOCKER_HOST_ID,
address: "192.0.2.18",
protocol: "tcp",
port: 8007,
enabled: true,
available: true,
lastChecked: freshObservedAt,
latencyMillis: 12,
pollIntervalSeconds: 60,
correlationState: "attached",
evidence: evidenceEnvelope({
id: "evidence_attached_tcp",
resourceId: DOCKER_HOST_ID,
targetId: ATTACHED_TCP_TARGET_ID,
observedAt: freshObservedAt,
validUntil: freshValidUntil,
}),
},
},
{
id: "network-endpoint:standalone-switch",
type: "network-endpoint",
name: "Standalone lab switch",
status: "online",
lastSeen: freshObservedAt,
sources: ["availability"],
availability: {
targetId: "standalone-switch",
address: "192.0.2.40",
protocol: "icmp",
enabled: true,
available: true,
lastChecked: freshObservedAt,
lastSuccess: freshObservedAt,
latencyMillis: 4,
pollIntervalSeconds: 60,
correlationState: "standalone",
evidence: evidenceEnvelope({
id: "evidence_standalone_switch",
resourceId: "network-endpoint:standalone-switch",
targetId: "standalone-switch",
observedAt: freshObservedAt,
validUntil: freshValidUntil,
}),
},
},
{
id: "network-endpoint:stale-success",
type: "network-endpoint",
name: "Stale successful service",
status: "online",
lastSeen: staleObservedAt,
sources: ["availability"],
availability: {
targetId: "stale-success",
address: "stale.example.test",
protocol: "https",
path: "/ready",
enabled: true,
available: true,
lastChecked: staleObservedAt,
lastSuccess: staleObservedAt,
latencyMillis: 17,
pollIntervalSeconds: 60,
correlationState: "standalone",
evidence: evidenceEnvelope({
id: "evidence_stale_success",
resourceId: "network-endpoint:stale-success",
targetId: "stale-success",
observedAt: staleObservedAt,
validUntil: staleValidUntil,
}),
},
},
{
id: "network-endpoint:not-observed",
type: "network-endpoint",
name: "Unobserved new service",
status: "unknown",
lastSeen: staleObservedAt,
sources: ["availability"],
availability: {
targetId: "not-observed",
address: "new.example.test",
protocol: "tcp",
port: 443,
enabled: true,
pollIntervalSeconds: 60,
correlationState: "standalone",
evidence: evidenceEnvelope({
id: "evidence_not_observed",
resourceId: "network-endpoint:not-observed",
targetId: "not-observed",
observedAt: staleObservedAt,
completeness: "partial",
confidence: "unknown",
}),
},
},
]);
await page.goto("/standalone/availability", {
waitUntil: "domcontentloaded",
});
const standalonePage = page.getByTestId("standalone-page");
await expect(standalonePage).toBeVisible({ timeout: 30_000 });
await expect(standalonePage.getByText(DOCKER_HOST_NAME)).toHaveCount(0);
await expect(
standalonePage.getByText("Standalone lab switch"),
).toBeVisible();
const staleRow = standalonePage.locator(
'[data-availability-check-row="network-endpoint:stale-success"]',
);
await expect(staleRow).toBeVisible();
await expect(staleRow.getByTitle("Stale", { exact: true })).toBeVisible();
await expect(staleRow.getByText("17 ms", { exact: true })).toHaveAttribute(
"title",
/stale/i,
);
await expect(staleRow).not.toContainText("Healthy");
const unobservedRow = standalonePage.locator(
'[data-availability-check-row="network-endpoint:not-observed"]',
);
await expect(unobservedRow).toBeVisible();
await expect(
unobservedRow.getByText("not checked", { exact: true }),
).toBeVisible();
await expect(unobservedRow).not.toContainText("Healthy");
const posture = standalonePage.getByTestId("standalone-posture-summary");
await expect(posture).toContainText("1 healthy");
await expect(posture).toContainText("2 need attention");
await expect(posture).not.toContainText("All 3 checks reporting normally");
});
test("routes an attached availability failure into Patrol with canonical lifecycle evidence", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("mobile-"),
"Desktop Patrol workbench proof",
);
const observedAt = "2026-07-19T02:00:00Z";
const ingestedAt = "2026-07-19T02:00:01Z";
const evaluatedAt = "2026-07-19T02:00:02Z";
const item = {
id: ATTENTION_ID,
operationalRecordId: ATTENTION_ID,
subjectResourceId: DOCKER_HOST_ID,
subjectResourceName: DOCKER_HOST_NAME,
subjectResourceType: "docker-host",
title: `Availability check failed for ${DOCKER_HOST_NAME}`,
plainLanguageSummary:
"The attached TCP availability check failed twice and reached its alert threshold.",
severity: "critical",
state: "open",
firstObservedAt: observedAt,
lastObservedAt: observedAt,
evidenceFreshness: "fresh",
evidenceCompleteness: "complete",
impact:
"The Docker API may be unreachable even if older host telemetry remains visible.",
relatedResources: [],
recommendedNextStep:
"Verify TCP connectivity to 192.0.2.18:8007 before changing the Docker host.",
availableActions: [],
verificationState: "not_available",
};
const summary = {
activeCount: 1,
openCount: 1,
acknowledgedCount: 0,
suppressedCount: 0,
uncertainCount: 0,
resolvedCount: 0,
calm: false,
coverageState: "current",
evaluatedAt,
};
const availabilityEvidence = {
id: AVAILABILITY_EVIDENCE_ID,
source: {
provider: "availability",
collector: "availability-poller",
},
subject: {
resourceId: DOCKER_HOST_ID,
providerRef: ATTACHED_TCP_TARGET_ID,
providerScope: "availability-target",
},
observedAt,
ingestedAt,
validUntil: "2026-07-19T02:02:00Z",
completeness: "complete",
confidence: "confirmed",
permissions: "sufficient",
reason: {
code: "availability_unreachable",
message: "TCP probe to 192.0.2.18:8007 failed twice.",
},
payloadRef: {
kind: "availability-target",
id: ATTACHED_TCP_TARGET_ID,
},
};
const detail = {
item,
operationalRecord: {
id: ATTENTION_ID,
canonicalSpecId: CANONICAL_AVAILABILITY_SPEC_ID,
subjectResourceId: DOCKER_HOST_ID,
state: "open",
severity: "critical",
firstObservedAt: observedAt,
lastObservedAt: observedAt,
stateChangedAt: observedAt,
evidenceIds: [AVAILABILITY_EVIDENCE_ID],
causeKey: ATTENTION_ID,
relatedResourceIds: [],
impactSummary: item.impact,
recommendedNextStep: item.recommendedNextStep,
},
timeline: [
{
id: "transition_availability_open",
operationalRecordId: ATTENTION_ID,
from: "observing",
to: "open",
at: observedAt,
cause: "detector_decision",
causeKey: ATTENTION_ID,
evidenceIds: [AVAILABILITY_EVIDENCE_ID],
reason:
"The attached availability check reached its failure threshold.",
},
],
evidence: [availabilityEvidence],
};
await routeResources(page, []);
await routePatrolSupport(page, { item, summary, detail });
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
const queue = page.getByRole("region", { name: "Needs attention" });
await expect(queue).toBeVisible({ timeout: 30_000 });
await expect(
page.getByRole("tab", { name: "Patrol: 1 active attention item" }),
).toBeVisible();
await expect(queue.getByText(item.plainLanguageSummary)).toBeVisible();
const detailResponsePromise = page.waitForResponse((response) => {
const requestUrl = new URL(response.url());
return (
requestUrl.pathname.startsWith("/api/ai/patrol/attention/") &&
!requestUrl.pathname.endsWith("/summary")
);
});
await queue.getByRole("button", { name: `Open ${item.title}` }).click();
const detailResponse = await detailResponsePromise;
const routedDetail = (await detailResponse.json()) as typeof detail;
expect(routedDetail.operationalRecord.canonicalSpecId).toBe(
CANONICAL_AVAILABILITY_SPEC_ID,
);
expect(routedDetail.operationalRecord.evidenceIds).toEqual([
AVAILABILITY_EVIDENCE_ID,
]);
expect(routedDetail.evidence).toEqual([availabilityEvidence]);
const detailPanel = page.getByRole("complementary", { name: item.title });
await expect(detailPanel).toBeVisible();
await expect(
detailPanel.getByText("Availability", { exact: true }),
).toBeVisible();
await expect(
detailPanel.getByText(/availability-poller · observed/i),
).toBeVisible();
await expect(
detailPanel.getByText("TCP probe to 192.0.2.18:8007 failed twice."),
).toBeVisible();
await expect(detailPanel.getByText("Observing to Open")).toBeVisible();
});
});
async function routePatrolSupport(
page: Page,
fixture: {
item: Record<string, unknown>;
summary: Record<string, unknown>;
detail: Record<string, unknown>;
},
) {
await page.route("**/api/replication/jobs", async (route) => {
await fulfillJSON(route, []);
});
await page.route("**/api/ai/patrol/status", async (route) => {
await fulfillJSON(route, {
runtime_state: "active",
running: false,
enabled: true,
last_patrol_at: fixture.item.lastObservedAt,
next_patrol_at: "2026-07-19T08:00:00Z",
last_duration_ms: 1_200,
resources_checked: 1,
findings_count: 0,
error_count: 0,
healthy: true,
interval_ms: 21_600_000,
fixed_count: 0,
blocked_reason: "",
blocked_at: "",
license_required: false,
license_status: "active",
summary: { critical: 0, warning: 0, watch: 0, info: 0 },
});
});
await page.route("**/api/ai/patrol/runs*", async (route) => {
await fulfillJSON(route, []);
});
await page.route("**/api/ai/patrol/autonomy", async (route) => {
await fulfillJSON(route, {
autonomy_level: "monitor",
requested_autonomy_level: "monitor",
effective_autonomy_level: "monitor",
full_mode_unlocked: false,
autopilot_acknowledgement: {
code: "not_requested",
active: false,
currentVersion: 1,
acceptedScope: [],
acceptedLimits: {
policyAllowlistRequired: true,
emergencyStopHonored: true,
approvalFloorsHonored: true,
verificationReconciledWhenSupported: true,
evidenceClassDisclosed: true,
inconclusiveOutcomeAllowed: true,
executionSuccessIsNotOutcomeTruth: true,
},
},
investigation_budget: 15,
investigation_timeout_sec: 300,
});
});
await page.route("**/api/ai/patrol/findings*", async (route) => {
await fulfillJSON(route, []);
});
await page.route("**/api/ai/unified/findings*", async (route) => {
await fulfillJSON(route, { findings: [], count: 0, active_count: 0 });
});
await page.route("**/api/ai/intelligence", async (route) => {
await fulfillJSON(route, {
timestamp: fixture.summary.evaluatedAt,
overall_health: {
score: 100,
grade: "A",
trend: "stable",
factors: [],
prediction: "Operational lifecycle attention is routed separately.",
},
findings_count: {
critical: 0,
warning: 0,
watch: 0,
info: 0,
total: 0,
},
predictions_count: 0,
recent_changes_count: 0,
recent_changes: [],
learning: {
resources_with_knowledge: 0,
total_notes: 0,
resources_with_baselines: 0,
patterns_detected: 0,
correlations_learned: 0,
incidents_tracked: 0,
},
});
});
await page.route("**/api/ai/intelligence/correlations*", async (route) => {
await fulfillJSON(route, { correlations: [], count: 0 });
});
await page.route("**/api/ai/circuit/status", async (route) => {
await fulfillJSON(route, {
state: "closed",
can_patrol: true,
consecutive_failures: 0,
total_successes: 1,
total_failures: 0,
});
});
await page.route("**/api/ai/approvals", async (route) => {
await fulfillJSON(route, { approvals: [] });
});
await page.route("**/api/settings/ai", async (route) => {
await fulfillJSON(route, {
patrol_enabled: true,
patrol_interval_minutes: 360,
patrol_model: "",
model: "",
alert_triggered_analysis: false,
patrol_alert_triggers_enabled: true,
patrol_anomaly_triggers_enabled: false,
patrol_event_triggers_enabled: true,
patrol_auto_fix: false,
auto_fix_model: "",
});
});
await page.route("**/api/ai/models", async (route) => {
await fulfillJSON(route, { models: [] });
});
await page.route("**/api/ai/patrol/attention**", async (route) => {
const requestUrl = new URL(route.request().url());
if (requestUrl.pathname.endsWith("/summary")) {
await fulfillJSON(route, fixture.summary);
return;
}
if (requestUrl.pathname !== "/api/ai/patrol/attention") {
await fulfillJSON(route, fixture.detail);
return;
}
await fulfillJSON(route, {
data: [fixture.item],
summary: fixture.summary,
meta: {
page: 1,
limit: 50,
total: 1,
totalPages: 1,
},
});
});
}
async function fulfillJSON(route: Route, body: unknown) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
}