mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Build canonical availability resource facets
This commit is contained in:
+13
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+160
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+42
-1
@@ -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 (
|
||||
|
||||
+59
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{}{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user