From cc470635b99283f49efc1350e73fb1a8cc69d10d Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 29 Apr 2026 15:20:36 +0100 Subject: [PATCH] Surface resource relationship map --- ...rce-relationship-map-surface-2026-04-29.md | 19 +++ docs/release-control/v6/internal/status.json | 97 +++++++++++++-- .../v6/internal/subsystems/agent-lifecycle.md | 8 ++ .../v6/internal/subsystems/api-contracts.md | 16 +++ .../subsystems/performance-and-scalability.md | 3 + .../internal/subsystems/storage-recovery.md | 9 ++ .../internal/subsystems/unified-resources.md | 25 +++- .../src/api/__tests__/resources.test.ts | 26 ++++ frontend-modern/src/api/resources.ts | 4 + .../ResourceCorrelationSummary.tsx | 113 ++++++++++++++++-- .../ResourceDetailDrawerOverviewTab.tsx | 44 ++----- .../ResourceCorrelationSummary.test.tsx | 54 +++++++-- .../ResourceDetailDrawer.history.test.tsx | 58 ++++++--- .../useResourceDetailDrawerDerivedState.ts | 34 +++--- .../useResourceDetailDrawerHistoryState.ts | 10 ++ .../useResourceDetailDrawerState.ts | 9 +- frontend-modern/src/types/resource.ts | 42 +++++++ .../resourceCorrelationPresentation.test.ts | 50 +++++++- .../utils/resourceCorrelationPresentation.ts | 63 +++++++++- internal/api/contract_test.go | 92 +++++++++++++- internal/api/resources.go | 12 +- internal/api/resources_test.go | 12 +- .../unifiedresources/code_standards_test.go | 5 + .../relationship_presentation_test.go | 50 +++++++- internal/unifiedresources/relationships.go | 69 +++++++++++ 25 files changed, 800 insertions(+), 124 deletions(-) create mode 100644 docs/release-control/v6/internal/records/resource-relationship-map-surface-2026-04-29.md diff --git a/docs/release-control/v6/internal/records/resource-relationship-map-surface-2026-04-29.md b/docs/release-control/v6/internal/records/resource-relationship-map-surface-2026-04-29.md new file mode 100644 index 000000000..50216b615 --- /dev/null +++ b/docs/release-control/v6/internal/records/resource-relationship-map-surface-2026-04-29.md @@ -0,0 +1,19 @@ +# Resource Relationship Map Surface + +Date: 2026-04-29 +Lane: L19 resource change intelligence +Follow-up: `resource-change-intelligence-post-rc-hardening` + +## Outcome + +The resource drawer now surfaces canonical resource relationships as a first-class Relationship map, using `resource.relationships` from the unified resource payload and the shared `ResourceCorrelationSummary` / `resourceCorrelationPresentation` ownership path. The relationship map sits beside change/action history instead of being hidden in the AI investigation disclosure, so deterministic infrastructure relationships are visible without requiring AI context. + +The resource facet endpoint also returns the selected resource's canonical relationships and capabilities, so the drawer can hydrate the relationship map from the same resource-facet contract that owns recent changes and facet counts. + +## Proof + +- `npm --prefix frontend-modern test -- src/components/Infrastructure/__tests__/ResourceCorrelationSummary.test.tsx src/utils/__tests__/resourceCorrelationPresentation.test.ts src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx` +- `npm --prefix frontend-modern run lint:eslint -- --quiet` +- `python3 scripts/release_control/status_audit.py --pretty` +- `python3 scripts/release_control/registry_audit.py --check --staged` +- In-browser infrastructure drawer inspection on the local v6 app confirmed the existing drawer still renders cleanly and does not show an empty relationship map when the selected resource has no relationship facets. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 0a392e633..9a97b8605 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -2792,6 +2792,11 @@ "frontend-primitives" ], "evidence": [ + { + "repo": "pulse", + "path": "frontend-modern/src/AppLayout.tsx", + "kind": "file" + }, { "repo": "pulse", "path": "frontend-modern/src/components/Settings/Settings.tsx", @@ -2809,17 +2814,12 @@ }, { "repo": "pulse", - "path": "frontend-modern/src/features/dashboardOverview/estateSummaryModel.ts", + "path": "frontend-modern/src/pages/Infrastructure.tsx", "kind": "file" }, { "repo": "pulse", - "path": "frontend-modern/src/features/dashboardOverview/EstateSummaryPanel.tsx", - "kind": "file" - }, - { - "repo": "pulse", - "path": "frontend-modern/src/pages/Dashboard.tsx", + "path": "frontend-modern/src/pages/RuntimeHome.tsx", "kind": "file" }, { @@ -2829,7 +2829,7 @@ }, { "repo": "pulse", - "path": "tests/integration/tests/71-dashboard-estate-orientation.spec.ts", + "path": "tests/integration/tests/19-telemetry-disclosure.spec.ts", "kind": "file" } ] @@ -3549,7 +3549,7 @@ "status": "partial", "completion": { "state": "bounded-residual", - "summary": "Resource change intelligence now has a first-class governed floor: canonical resource relationships, durable resource-change envelopes, AI/Patrol recent-change presentation, dedicated resource timeline/facet reads, and relationship-aware resource timelines are all owned by the unified-resource and API-contract boundary. Broader surfaced timeline IA, relationship graph exploration, enterprise correlation depth, and cross-resource investigation workflows remain a named post-RC hardening track.", + "summary": "Resource change intelligence now has a first-class governed floor: canonical resource relationships, durable resource-change envelopes, AI/Patrol recent-change presentation, dedicated resource timeline/facet reads, relationship-aware resource timelines, and an operator-visible resource relationship map are all owned by the unified-resource and API-contract boundary. Broader surfaced timeline IA, enterprise correlation depth, and cross-resource investigation workflows remain a named post-RC hardening track.", "tracking": [ { "kind": "lane-followup", @@ -3560,6 +3560,11 @@ "blockers": [], "subsystems": [], "evidence": [ + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/records/resource-relationship-map-surface-2026-04-29.md", + "kind": "file" + }, { "repo": "pulse", "path": "docs/release-control/v6/internal/subsystems/api-contracts.md", @@ -3570,16 +3575,66 @@ "path": "docs/release-control/v6/internal/subsystems/unified-resources.md", "kind": "file" }, + { + "repo": "pulse", + "path": "frontend-modern/src/api/resources.ts", + "kind": "file" + }, + { + "repo": "pulse", + "path": "frontend-modern/src/components/Infrastructure/__tests__/ResourceCorrelationSummary.test.tsx", + "kind": "file" + }, + { + "repo": "pulse", + "path": "frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx", + "kind": "file" + }, { "repo": "pulse", "path": "frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx", "kind": "file" }, + { + "repo": "pulse", + "path": "frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx", + "kind": "file" + }, + { + "repo": "pulse", + "path": "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx", + "kind": "file" + }, + { + "repo": "pulse", + "path": "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts", + "kind": "file" + }, + { + "repo": "pulse", + "path": "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts", + "kind": "file" + }, + { + "repo": "pulse", + "path": "frontend-modern/src/types/resource.ts", + "kind": "file" + }, + { + "repo": "pulse", + "path": "frontend-modern/src/utils/__tests__/resourceCorrelationPresentation.test.ts", + "kind": "file" + }, { "repo": "pulse", "path": "frontend-modern/src/utils/resourceChangePresentation.ts", "kind": "file" }, + { + "repo": "pulse", + "path": "frontend-modern/src/utils/resourceCorrelationPresentation.ts", + "kind": "file" + }, { "repo": "pulse", "path": "internal/ai/intelligence.go", @@ -3590,6 +3645,11 @@ "path": "internal/ai/patrol_ai.go", "kind": "file" }, + { + "repo": "pulse", + "path": "internal/api/contract_test.go", + "kind": "file" + }, { "repo": "pulse", "path": "internal/api/resources.go", @@ -4697,7 +4757,7 @@ }, { "id": "first-session-post-rc-polish", - "summary": "Track broader first-session polish and parity work that is intentionally outside the RC stabilization floor, including the dashboard-home requirement that the first viewport preserves immediate estate orientation for v5-upgrade users through the canonical connected-infrastructure projection.", + "summary": "Track broader first-session polish and parity work that is intentionally outside the RC stabilization floor after the default landing surface moved to Infrastructure; future first-session orientation belongs to Infrastructure and Add infrastructure, not a dashboard-home surface.", "owner": "project-owner", "status": "planned", "recorded_at": "2026-03-13", @@ -4839,7 +4899,7 @@ }, { "id": "resource-change-intelligence-post-rc-hardening", - "summary": "Track broader resource-change intelligence hardening beyond the current canonical timeline floor, including surfaced cross-resource timeline IA, relationship graph exploration, enterprise correlation depth, and investigation workflows that promote relationship-aware timelines from backend foundation into a primary operator experience.", + "summary": "Track broader resource-change intelligence hardening beyond the current canonical timeline and relationship-map floor, including surfaced cross-resource timeline IA, enterprise correlation depth, and investigation workflows that promote relationship-aware timelines from backend foundation into deeper operator workflows.", "owner": "project-owner", "status": "planned", "recorded_at": "2026-04-25", @@ -5359,7 +5419,7 @@ }, { "id": "dashboard-home-estate-orientation-contract", - "summary": "The v6 dashboard remains the default landing surface only if its first viewport immediately proves that Pulse sees the operator's estate: the page must surface canonical connected-infrastructure system count, health, source coverage, freshness, and an explicit Infrastructure handoff before detailed dashboard rows, without restoring platform-special navigation or widening the dashboard hot path.", + "summary": "Superseded by `infrastructure-default-landing-surface`: Dashboard no longer remains the default landing surface or a preserved overview path. The prior estate-orientation requirement now belongs to Infrastructure and Add infrastructure.", "kind": "contract", "decided_at": "2026-04-23", "subsystem_ids": [ @@ -5391,6 +5451,19 @@ "lane_ids": [ "L5" ] + }, + { + "id": "infrastructure-default-landing-surface", + "summary": "Pulse v6 lands authenticated users directly on Infrastructure. The Dashboard page, route, primary-nav item, dashboard summary API, and dashboard-specific overview widgets are retired rather than preserved as a wallboard or compatibility surface; first-session and setup completion handoffs must route users into Infrastructure or Add infrastructure.", + "kind": "contract", + "decided_at": "2026-04-29", + "subsystem_ids": [ + "frontend-primitives" + ], + "lane_ids": [ + "L8", + "L13" + ] } ] } diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index f53e8460d..d6d9bb4d2 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -981,6 +981,14 @@ at the API boundary: lifecycle-adjacent fleet views may consume the direct plus `relatedResources` history returned by `internal/api/resources.go`, but they must not rebuild cross-resource timeline joins inside lifecycle-owned routes or change the direct-only store default used by other callers. +The bundled facet read may also expose the selected resource's canonical +capabilities and relationships for shared drawers, but lifecycle-adjacent +surfaces must treat those fields as API/unified-resource facts rather than +agent-lifecycle-owned install, approval, or topology state. +Agent-host, Kubernetes, and runtime parentage exposed through `ParentID` must +therefore enter shared drawers as facet relationships from +`internal/api/resources.go`; lifecycle surfaces must not rederive those edges +from agent install state, cluster names, or local fleet table grouping. That same shared `internal/api/` boundary now also exposes a dedicated VM inventory export route for reporting. Fleet and install surfaces may coexist with that export, but `internal/api/reporting_inventory_handlers.go` and diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index ad6e7ebd7..ba7c8b6ef 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -228,6 +228,10 @@ the canonical monitored-system blocked payload. includes direct changes and changes that name the resource in `relatedResources` instead of hiding child or dependency activity from the owning resource. + The same facet bundle must return the selected resource's backend-authored + `capabilities` and canonical `relationships` alongside recent changes and + grouped counts, so frontend detail surfaces consume one governed API payload + instead of rebuilding capability or topology context from the list response. 7. Route unified-resource list ordering through `internal/api/resources.go`, `internal/api/contract_test.go`, and the owned unified-resource registry helpers together; list payloads must stay deterministic for equal-name resources by carrying one canonical `name -> type -> id` tie-break across cold seed, REST pagination, and websocket-backed refreshes instead of inheriting map order or page-local re-sorts That same shared API contract also owns the external resource `type`, canonical display name, and cluster identity published through `/api/resources` and `/api/state`; the websocket/state hydrate path must not emit legacy aliases or raw store labels once the unified resource contract has normalized them. 8. Route unified-agent installer and binary download headers through `internal/api/unified_agent.go` and `internal/api/contract_test.go` together; published release downloads must keep the canonical `X-Checksum-Sha256` plus `X-Signature-Ed25519` contract for updater clients and the base64-encoded `X-Signature-SSHSIG` contract for installer clients whether the asset is served locally or proxied from the matching GitHub release, instead of leaving callers to infer trust from source location alone. @@ -1176,6 +1180,10 @@ in addition to policy and identity metadata, so the backend payload contract stays aligned with the timeline and control-plane model instead of flattening those fields away. The frontend consumer, however, only preserves the timeline-first `recentChanges` slice and its counts on the bundle contract. +Relationship facets include explicit `resource.relationships` plus the +canonical parent edge derived from `ParentID` by the unified-resource model +when no equivalent edge already exists, so topology hydration stays +backend-owned and one-hop relationship maps do not vary by page. The same resource contract now also exposes a dedicated `/api/resources/{id}/timeline` history endpoint and bundled facet reads under `/api/resources/{id}/facets`, so operators can inspect change history without @@ -1575,6 +1583,14 @@ The same API contract now also owns the dedicated frontend resource facet client in `frontend-modern/src/api/resources.ts`, which fetches the governed capability, relationship, and timeline surfaces from `internal/api/resources.go` instead of teaching the drawer or list views to reconstruct them inline. +Those facet reads now explicitly include the selected resource's canonical +`capabilities` and `relationships`, so action affordances and relationship-map +surfaces remain hydrated from the same backend-owned resource contract as +recent changes and facet counts. +The relationship facet must call the shared unified-resource parent-edge +deriver rather than serializing only the raw resource relationship slice, so +resources parented through the registry still expose the same canonical +relationship-map contract as resources with adapter-supplied edges. The same AI resource-intelligence payload now also carries dependency and dependent correlation arrays plus correlation evidence, so the drawer can render canonical correlation context from the shared AI contract instead of inferring it diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index f7314b266..9d0081d45 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -936,6 +936,9 @@ still reading totals from the shared resource contract. The drawer history surface reuses the same governed resource route helpers for relationship and related-resource links, so cross-resource navigation stays within the existing infrastructure surface rather than branching into custom detail-only routing. +Relationship-map detail now stays on that same drawer-only path: canonical +relationships come from the bundled resource facet payload when a resource is +opened, not from additional table-row hydration or a second all-resources pass. The detail drawer now follows the same default posture by collapsing its history overview down to timeline counts and timeline-summary chips, so the performance-sensitive shared presentation path stays aligned with the diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 67a397800..3cca19938 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -1279,6 +1279,15 @@ consume now also carries grouped `recentChangeKinds` counts by canonical change kind, so storage and recovery surfaces can show the distribution of restarts, anomalies, relationships, and capability changes without re-deriving their own timeline breakdowns. +That same facet bundle may include the selected resource's canonical +capabilities and relationships for shared detail drawers, but storage and +recovery surfaces must treat that topology/action metadata as adjacent +API/unified-resource context rather than storage protection, restore, or +recovery ownership. +Derived parent relationships in that facet bundle are still topology context: +storage and recovery may render or link them through the shared resource +drawer, but they must not reinterpret those parent edges as backup coverage, +restore eligibility, or recovery-event evidence. That same shared facet bundle now also carries grouped `recentChangeSourceTypes` counts by canonical source type, so storage and recovery surfaces can separate platform events, pulse diffs, heuristics, diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 775cbdf58..52a8eb7ff 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -928,8 +928,15 @@ correlation evidence through the shared `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx` card, so the same learned-edge list stays governed by one frontend surface instead of separate page-local implementations. That shared card also owns -the correlation ordering and truncation rule, so callers pass raw correlation -lists instead of encoding their own sort or top-N behavior. +the first-class relationship-map surface for canonical `resource.relationships`, +the correlation ordering, and the truncation rule, so callers pass raw +relationships and correlation lists instead of encoding their own sort or +top-N behavior. +Canonical parent edges now also originate in this subsystem: `ParentID` is +folded into the facet relationship set through +`ResourceRelationshipsWithCanonicalParent` before any drawer or Patrol +consumer renders a relationship map, so pages do not rederive parent topology +from raw resource fields or invent relationship-map fallbacks locally. The same surfaces now also render recent changes through the shared `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` card, so canonical timeline wording and ordering stay governed by one @@ -1006,6 +1013,10 @@ write when the target database still requires it. `/api/resources/{id}/timeline` reads, while the bundled `/api/resources/{id}/facets` surface keeps the facet summary and recent-change history available without forcing consumers to parse the full resource payload. +The facet bundle's relationship slice must be produced with +`ResourceRelationshipsWithCanonicalParent`, preserving explicit resource +relationships and adding a canonical parent edge from `ParentID` only when an +equivalent typed edge is not already present. Those resource-owned timeline and facet reads are relationship-aware at the API boundary: when the drawer requests a resource timeline, the store must return direct changes for that canonical ID plus changes whose @@ -1541,10 +1552,12 @@ recent-change presentation unowned or rebuilding helper-local labels inside AI, Patrol, performance, or infrastructure surfaces. The shared correlation and policy-posture presentation boundaries are also owned here now. `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx` -is the canonical shared card for dependency, dependent, and learned-correlation -context, while `frontend-modern/src/utils/resourceCorrelationPresentation.ts` -owns endpoint labels, headline formatting, summary wording, and canonical -correlation ordering. `frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx` +is the canonical shared card for canonical resource relationships, dependency, +dependent, and learned-correlation context, while +`frontend-modern/src/utils/resourceCorrelationPresentation.ts` owns endpoint +labels, relationship labels, headline formatting, summary wording, and canonical +relationship/correlation ordering. +`frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx` is the canonical shared card for governed policy-posture counts, while `frontend-modern/src/utils/resourcePolicyPresentation.ts` owns the canonical sensitivity, routing, and redaction labels and aggregate count summaries. diff --git a/frontend-modern/src/api/__tests__/resources.test.ts b/frontend-modern/src/api/__tests__/resources.test.ts index 1c8955f34..bafe9b771 100644 --- a/frontend-modern/src/api/__tests__/resources.test.ts +++ b/frontend-modern/src/api/__tests__/resources.test.ts @@ -62,6 +62,19 @@ describe('ResourceAPI', () => { it('fetches the resource history bundle from the facet endpoint', async () => { vi.mocked(apiFetchJSON).mockResolvedValueOnce({ resourceId: 'vm:42', + capabilities: [{ name: 'restart', type: 'common' }], + relationships: [ + { + sourceId: 'vm:42', + targetId: 'node-1', + type: 'runs_on', + confidence: 1, + active: true, + discoverer: 'proxmox_adapter', + observedAt: '2026-03-18T12:00:00Z', + lastSeenAt: '2026-03-18T12:00:00Z', + }, + ], recentChanges: [{ id: 'change-1' }], counts: { recentChanges: 3, @@ -96,6 +109,19 @@ describe('ResourceAPI', () => { ); expect(result).toEqual({ resourceId: 'vm:42', + capabilities: [{ name: 'restart', type: 'common' }], + relationships: [ + { + sourceId: 'vm:42', + targetId: 'node-1', + type: 'runs_on', + confidence: 1, + active: true, + discoverer: 'proxmox_adapter', + observedAt: '2026-03-18T12:00:00Z', + lastSeenAt: '2026-03-18T12:00:00Z', + }, + ], recentChanges: [{ id: 'change-1' }], counts: { recentChanges: 3, diff --git a/frontend-modern/src/api/resources.ts b/frontend-modern/src/api/resources.ts index 493c1cff3..cae95fa8a 100644 --- a/frontend-modern/src/api/resources.ts +++ b/frontend-modern/src/api/resources.ts @@ -1,5 +1,6 @@ import { apiFetchJSON } from '@/utils/apiClient'; import type { + ResourceCapability, ResourceChange, ResourceChangeKind, ResourceChangeSourceAdapter, @@ -7,6 +8,7 @@ import type { ResourceFacetCounts, ResourceMetricsTarget, ResourcePolicy, + ResourceRelationship, } from '@/types/resource'; export interface ResourceTimelineQueryOptions { @@ -24,6 +26,8 @@ export interface ResourceTimelineResponse { } export interface ResourceFacetBundle { + capabilities?: ResourceCapability[]; + relationships?: ResourceRelationship[]; recentChanges: ResourceChange[]; counts: ResourceFacetCounts; } diff --git a/frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx b/frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx index 579905baf..a21251634 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx @@ -1,5 +1,6 @@ import { For, Show, createMemo, type Component } from 'solid-js'; import type { ResourceCorrelation } from '@/types/aiIntelligence'; +import type { ResourceRelationship } from '@/types/resource'; import { formatRelativeTime } from '@/utils/format'; import { buildInfrastructureResourceHref } from '@/routing/resourceLinks'; import { @@ -8,15 +9,20 @@ import { formatResourceCorrelationPattern, formatResourceCorrelationSummary, formatResourceCorrelationSummaryText, + formatResourceRelationshipSummary, + formatResourceRelationshipType, sortResourceCorrelations, + sortResourceRelationships, } from '@/utils/resourceCorrelationPresentation'; interface ResourceCorrelationSummaryProps { - correlations?: ResourceCorrelation[] | null; - dependencies?: string[] | null; - dependents?: string[] | null; + correlations?: readonly ResourceCorrelation[] | null; + relationships?: readonly ResourceRelationship[] | null; + dependencies?: readonly string[] | null; + dependents?: readonly string[] | null; title?: string; summaryText?: string; + dataTestId?: string; buildResourceHref?: (resourceId: string) => string | null | undefined; resolveResourceLabel?: (resourceId: string) => string | null | undefined; showLastSeen?: boolean; @@ -27,15 +33,20 @@ interface ResourceCorrelationSummaryProps { export const ResourceCorrelationSummary: Component = (props) => { const className = () => props.class?.trim() ?? ''; const correlations = createMemo(() => sortResourceCorrelations(props.correlations ?? [])); + const relationships = createMemo(() => sortResourceRelationships(props.relationships ?? [])); const dependencies = () => props.dependencies ?? []; const dependents = () => props.dependents ?? []; const buildResourceHref = props.buildResourceHref ?? buildInfrastructureResourceHref; const resolveResourceLabel = props.resolveResourceLabel; const maxCorrelations = () => props.maxCorrelations ?? 3; const hasContent = () => - dependencies().length > 0 || dependents().length > 0 || correlations().length > 0; + relationships().length > 0 || + dependencies().length > 0 || + dependents().length > 0 || + correlations().length > 0; const summaryText = () => formatResourceCorrelationSummaryText({ + relationshipsCount: relationships().length, dependenciesCount: dependencies().length, dependentsCount: dependents().length, correlationsCount: correlations().length, @@ -44,7 +55,10 @@ export const ResourceCorrelationSummary: Component -
+

@@ -56,6 +70,80 @@ export const ResourceCorrelationSummary: Component

+ 0}> +
+
Canonical relationships
+
+ + {(relationship) => { + const sourceLabel = + resolveResourceLabel?.(relationship.sourceId)?.trim() || relationship.sourceId; + const targetLabel = + resolveResourceLabel?.(relationship.targetId)?.trim() || relationship.targetId; + const sourceHref = buildResourceHref(relationship.sourceId); + const targetHref = buildResourceHref(relationship.targetId); + const typeLabel = formatResourceRelationshipType(relationship); + const summary = formatResourceRelationshipSummary(relationship); + const lastSeenLabel = + props.showLastSeen && relationship.lastSeenAt + ? formatRelativeTime(relationship.lastSeenAt, { + compact: true, + emptyText: 'just now', + }) + : ''; + + return ( +
+
+ {sourceHref ? ( + + {sourceLabel} + + ) : ( + + {sourceLabel} + + )} + + {targetHref ? ( + + {targetLabel} + + ) : ( + + {targetLabel} + + )} + + {typeLabel} + +
+ +
+ {summary} + + <> + {summary ? ' · ' : ''}last seen {lastSeenLabel} + + +
+
+
+ ); + }} +
+
+
+
+ 0}>
Depends on
@@ -123,12 +211,13 @@ export const ResourceCorrelationSummary: Component
@@ -166,7 +255,7 @@ export const ResourceCorrelationSummary: Component {summary} - <>{' '}· last seen {lastSeenLabel} + <> · last seen {lastSeenLabel}
diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx index 16afc961e..fd664e9be 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx @@ -522,6 +522,19 @@ export const ResourceDetailDrawerOverviewTab: Component
+ + + + - -
-
- - Correlations - - -
- - -
- -
-
-
-
)} diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceCorrelationSummary.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceCorrelationSummary.test.tsx index d1bd5722f..8ea734fd6 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceCorrelationSummary.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceCorrelationSummary.test.tsx @@ -30,10 +30,12 @@ describe('ResourceCorrelationSummary', () => { expect(screen.getByText('Learned correlations')).toBeInTheDocument(); expect(screen.getByText('5 total')).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Open source resource Storage 1 in Infrastructure' })) - .toHaveAttribute('href', '/infrastructure?resource=storage-1'); - expect(screen.getByRole('link', { name: 'Open target resource Host 1 in Infrastructure' })) - .toHaveAttribute('href', '/infrastructure?resource=host-1'); + expect( + screen.getByRole('link', { name: 'Open source resource Storage 1 in Infrastructure' }), + ).toHaveAttribute('href', '/infrastructure?resource=storage-1'); + expect( + screen.getByRole('link', { name: 'Open target resource Host 1 in Infrastructure' }), + ).toHaveAttribute('href', '/infrastructure?resource=host-1'); expect(screen.getByText('Disk Full → Restart')).toBeInTheDocument(); expect(screen.getByText(/2 occurrences · avg delay 2m · 88% confidence/)).toBeInTheDocument(); expect(screen.getByText('Disk pressure often precedes restarts')).toBeInTheDocument(); @@ -44,14 +46,28 @@ describe('ResourceCorrelationSummary', () => { render(() => ( - resourceId === 'storage-1' - ? 'Storage 1 alias' - : resourceId === 'vm-child' - ? 'VM Child' - : resourceId + resourceId === 'node:pve-1' + ? 'PVE 1' + : resourceId === 'storage-1' + ? 'Storage 1 alias' + : resourceId === 'vm-child' + ? 'VM Child' + : resourceId } correlations={[ { @@ -74,18 +90,30 @@ describe('ResourceCorrelationSummary', () => { )); expect(screen.getByText('Correlation context')).toBeInTheDocument(); - expect(screen.getByText('1 dependency · 1 dependent · 1 correlation')).toBeInTheDocument(); + expect( + screen.getByText('1 canonical relationship · 1 dependency · 1 dependent · 1 correlation'), + ).toBeInTheDocument(); + expect(screen.getByText('Canonical relationships')).toBeInTheDocument(); expect(screen.getByText('Depends on')).toBeInTheDocument(); expect(screen.getByText('Used by')).toBeInTheDocument(); expect(screen.getByText('Correlations')).toBeInTheDocument(); expect( - screen.getByRole('link', { name: 'Open dependency resource Storage 1 alias in Infrastructure' }), + screen.getByRole('link', { name: 'Open source resource PVE 1 in Infrastructure' }), + ).toHaveAttribute('href', '/infrastructure?resource=node%3Apve-1'); + expect( + screen.getByRole('link', { name: 'Open target resource VM Child in Infrastructure' }), + ).toHaveAttribute('href', '/infrastructure?resource=vm-child'); + expect(screen.getByText('Runs On')).toBeInTheDocument(); + expect(screen.getByText(/100% confidence · Proxmox Adapter · last seen/)).toBeInTheDocument(); + expect( + screen.getByRole('link', { + name: 'Open dependency resource Storage 1 alias in Infrastructure', + }), ).toHaveAttribute('href', '/infrastructure?resource=storage-1'); expect(screen.getByText('Storage 1 alias')).toBeInTheDocument(); expect( screen.getByRole('link', { name: 'Open dependent resource VM Child in Infrastructure' }), ).toHaveAttribute('href', '/infrastructure?resource=vm-child'); - expect(screen.getByText('VM Child')).toBeInTheDocument(); - expect(screen.getByText(/last seen/i)).toBeInTheDocument(); + expect(screen.getAllByText(/last seen/i).length).toBeGreaterThanOrEqual(2); }); }); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx index 3b8aef451..b6cc0e6ff 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx @@ -176,6 +176,12 @@ describe('ResourceDetailDrawer change history section', () => { expect(resourceDetailDrawerHistoryStateSource).toContain('AIAPI.getResourceIntelligence'); expect(resourceDetailDrawerHistoryStateSource).toContain('ActionAuditAPI.listActionAudits'); expect(resourceDetailDrawerOverviewSource).toContain("from './ResourceActionHistory'"); + expect(resourceDetailDrawerOverviewSource).toContain( + 'dataTestId="resource-relationship-map-section"', + ); + expect(resourceDetailDrawerHistoryStateSource).toContain('resourceFacetRelationships'); + expect(resourceDetailDrawerDerivedStateSource).toContain('options.resourceRelationships?.()'); + expect(resourceDetailDrawerDerivedStateSource).toContain('resource.relationships ?? []'); expect(resourceActionHistorySource).toContain('getActionAuditStatePresentation'); expect(resourceActionHistorySource).toContain('formatActionApprovalPolicyLabel'); expect(actionAuditApiSource).toContain('/api/audit/actions'); @@ -362,6 +368,18 @@ describe('ResourceDetailDrawer change history section', () => { platformType: 'proxmox-pve', tags: ['timeline-tag'], platformData: { sources: ['proxmox'] }, + relationships: [ + { + sourceId: 'node:pve-1', + targetId: 'vm:42', + type: 'runs_on', + confidence: 1, + active: true, + discoverer: 'proxmox_adapter', + observedAt: '2026-03-18T12:00:00Z', + lastSeenAt: '2026-03-18T12:05:00Z', + }, + ], }); render(() => ( @@ -370,11 +388,13 @@ describe('ResourceDetailDrawer change history section', () => { resolveResourceLabel={(resourceId) => resourceId === 'node:pve-1' ? 'PVE Node 1' - : resourceId === 'storage-1' - ? 'Storage 1 alias' - : resourceId === 'vm-child' - ? 'VM Child' - : resourceId + : resourceId === 'vm:42' + ? 'VM 42' + : resourceId === 'storage-1' + ? 'Storage 1 alias' + : resourceId === 'vm-child' + ? 'VM Child' + : resourceId } /> )); @@ -478,10 +498,21 @@ describe('ResourceDetailDrawer change history section', () => { expect(screen.getAllByText('Proxmox adapter 1')).toHaveLength(1); expect(changeHistorySection.querySelectorAll('.mt-1.grid').length).toBe(0); expect(screen.queryByText('Quick links')).toBeNull(); + const relationshipMap = within(screen.getByTestId('resource-relationship-map-section')); + expect(screen.getByText('Relationship map')).toBeInTheDocument(); + expect(relationshipMap.getByText('Canonical relationships')).toBeInTheDocument(); + expect( + relationshipMap.getByRole('link', { + name: 'Open source resource PVE Node 1 in Infrastructure', + }), + ).toHaveAttribute('href', '/infrastructure?resource=node%3Apve-1'); + expect(relationshipMap.getByText('Runs On')).toBeInTheDocument(); + expect(screen.getByText('Depends on')).toBeInTheDocument(); + expect(screen.getByText('Used by')).toBeInTheDocument(); + expect(screen.getByText('Correlations')).toBeInTheDocument(); + expect(screen.getByText('Storage 1 alias')).toBeInTheDocument(); + expect(screen.getByText('VM Child')).toBeInTheDocument(); expect(screen.getByText('Context')).toBeInTheDocument(); - expect(screen.queryByText('Correlations')).toBeNull(); - expect(screen.queryByText('Storage 1 alias')).toBeNull(); - expect(screen.queryByText('VM Child')).toBeNull(); expect(screen.queryByText('Capabilities 1')).toBeNull(); expect(screen.queryByText('Relationships 1')).toBeNull(); expect(screen.queryByText('Analysis')).toBeNull(); @@ -503,15 +534,8 @@ describe('ResourceDetailDrawer change history section', () => { expect(screen.getByText('stable')).toBeInTheDocument(); expect(screen.getByText('Notes')).toBeInTheDocument(); expect(screen.getByText('3')).toBeInTheDocument(); - expect( - screen.getByTestId('resource-correlation-context').querySelector('.rounded.border'), - ).toBeNull(); - fireEvent.click(screen.getByRole('button', { name: 'Show correlations' })); - expect( - screen - .getByRole('button', { name: 'Hide correlations' }) - .parentElement?.querySelector('.mt-0\\.5.text-\\[10px\\].text-muted'), - ).toBeNull(); + expect(screen.queryByTestId('resource-correlation-context')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Show correlations' })).toBeNull(); }); it('keeps default internal cloud-summary posture out of the investigation context drawer block', async () => { diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts index 16f0bc733..cdb91e0e7 100644 --- a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts @@ -1,5 +1,5 @@ import { createMemo, type Accessor } from 'solid-js'; -import type { Resource } from '@/types/resource'; +import type { Resource, ResourceRelationship } from '@/types/resource'; import { requiresGovernedResourceDisplay } from '@/types/resource'; import type { ResourceVMwareMeta } from '@/types/resource'; import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format'; @@ -72,13 +72,18 @@ interface UseResourceDetailDrawerDerivedStateOptions { resolveResourceLabel?: (resourceId: string) => string | null | undefined; debugEnabled: Accessor; resourceIntelligence: Accessor; + resourceRelationships?: Accessor; } export const useResourceDetailDrawerDerivedState = ( options: UseResourceDetailDrawerDerivedStateOptions, ) => { - const { resource, resolveResourceLabel: resolveResourceLabelInput, debugEnabled, resourceIntelligence } = - options; + const { + resource, + resolveResourceLabel: resolveResourceLabelInput, + debugEnabled, + resourceIntelligence, + } = options; const displayName = createMemo(() => getPreferredInfrastructureDisplayName(resource)); const kubernetesClusterName = createMemo(() => getPreferredResourceClusterName(resource) ?? ''); @@ -161,6 +166,9 @@ export const useResourceDetailDrawerDerivedState = ( const resourceDependencies = createMemo(() => resourceIntelligence()?.dependencies ?? []); const resourceDependents = createMemo(() => resourceIntelligence()?.dependents ?? []); const resourceCorrelations = createMemo(() => resourceIntelligence()?.correlations ?? []); + const resourceRelationships = createMemo( + () => options.resourceRelationships?.() ?? resource.relationships ?? [], + ); const hasMeaningfulResourceIntelligence = createMemo(() => { const intel = resourceIntelligence(); if (!intel) return false; @@ -171,14 +179,12 @@ export const useResourceDetailDrawerDerivedState = ( (intel.health.prediction?.trim() ?? '') !== '' || (intel.health.factors?.length ?? 0) > 0 || (intel.note_count ?? 0) > 0 || - (intel.recent_changes?.length ?? 0) > 0 || - resourceDependencies().length > 0 || - resourceDependents().length > 0 || - resourceCorrelations().length > 0 + (intel.recent_changes?.length ?? 0) > 0 ); }); const hasCorrelationContext = createMemo( () => + resourceRelationships().length > 0 || resourceDependencies().length > 0 || resourceDependents().length > 0 || resourceCorrelations().length > 0, @@ -193,11 +199,6 @@ export const useResourceDetailDrawerDerivedState = ( if (intel && hasMeaningfulResourceIntelligence()) { summary.push(formatResourceAnalysisSummary(intel.health.grade, intel.health.score)); } - if (resourceCorrelations().length > 0) { - summary.push( - `${resourceCorrelations().length} correlation${resourceCorrelations().length === 1 ? '' : 's'}`, - ); - } if (resource.policy?.routing.scope && !hasDefaultResourcePolicyPosture(resource.policy)) { summary.push(`Routing ${getResourceRoutingScopeLabel(resource.policy.routing.scope)}`); } @@ -280,8 +281,8 @@ export const useResourceDetailDrawerDerivedState = ( const hasAccessContext = createMemo( () => Boolean(discoveryConfig()) || relatedLinks().length > 0, ); - const hasRuntimeOperationalContext = createMemo( - () => buildHasRuntimeOperationalContext(kubernetesCapabilityBadges()), + const hasRuntimeOperationalContext = createMemo(() => + buildHasRuntimeOperationalContext(kubernetesCapabilityBadges()), ); const sourceSections = createMemo(() => buildSourceSections(platformData())); @@ -355,6 +356,7 @@ export const useResourceDetailDrawerDerivedState = ( resourceDependencies, resourceDependents, resourceCorrelations, + resourceRelationships, hasCorrelationContext, hasMeaningfulResourceIntelligence, hasInvestigationContext, @@ -399,4 +401,6 @@ export const useResourceDetailDrawerDerivedState = ( }; }; -export type ResourceDetailDrawerDerivedState = ReturnType; +export type ResourceDetailDrawerDerivedState = ReturnType< + typeof useResourceDetailDrawerDerivedState +>; diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts index bd29854a1..c5fc68a37 100644 --- a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts @@ -1,9 +1,11 @@ import { createMemo, createSignal } from 'solid-js'; import type { Resource, + ResourceCapability, ResourceChangeKind, ResourceChangeSourceAdapter, ResourceChangeSourceType, + ResourceRelationship, } from '@/types/resource'; import type { ResourceIntelligence } from '@/types/aiIntelligence'; import { AIAPI } from '@/api/ai'; @@ -119,6 +121,12 @@ export const useResourceDetailDrawerHistoryState = ( const resourceTimeline = createMemo( () => resourceFacets()?.recentChanges ?? resource.recentChanges ?? [], ); + const resourceFacetCapabilities = createMemo( + () => resourceFacets()?.capabilities ?? resource.capabilities ?? [], + ); + const resourceFacetRelationships = createMemo( + () => resourceFacets()?.relationships ?? resource.relationships ?? [], + ); const resourceFacetCounts = createMemo( () => resourceFacets()?.counts ?? resource.facetCounts ?? null, ); @@ -203,6 +211,8 @@ export const useResourceDetailDrawerHistoryState = ( setTimelineSourceAdapterFilter, resourceIntelligence, resourceTimeline, + resourceFacetCapabilities, + resourceFacetRelationships, historyFacetCounts, historyRecentChanges, hasTimelineFilters, diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts index a394bfec6..7f73aa34b 100644 --- a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts @@ -1,7 +1,4 @@ -import { - createEffect, - createSignal, -} from 'solid-js'; +import { createEffect, createSignal } from 'solid-js'; import type { Resource } from '@/types/resource'; import { createLocalStorageBooleanSignal, STORAGE_KEYS } from '@/utils/localStorage'; import { useResourceDetailDrawerDockerActionsState } from './useResourceDetailDrawerDockerActionsState'; @@ -24,7 +21,6 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta const [showHistoryFilters, setShowHistoryFilters] = createSignal(false); const [showAccessContext, setShowAccessContext] = createSignal(false); const [showInvestigationContext, setShowInvestigationContext] = createSignal(false); - const [showCorrelationContext, setShowCorrelationContext] = createSignal(false); const [showDiscoveryContext, setShowDiscoveryContext] = createSignal(false); const [showHostDetails, setShowHostDetails] = createSignal(false); const [showServiceDetails, setShowServiceDetails] = createSignal(false); @@ -39,6 +35,7 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta resolveResourceLabel: resolveResourceLabelInput, debugEnabled, resourceIntelligence: history.resourceIntelligence, + resourceRelationships: history.resourceFacetRelationships, }); const dockerActions = useResourceDetailDrawerDockerActionsState({ dockerHostSourceId: derived.dockerHostSourceId, @@ -95,8 +92,6 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta setShowAccessContext, showInvestigationContext, setShowInvestigationContext, - showCorrelationContext, - setShowCorrelationContext, showDiscoveryContext, setShowDiscoveryContext, showHostDetails, diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index 3e9b5b084..d5cc4756b 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -194,6 +194,46 @@ export interface ResourceFacetCounts { recentChangeSourceAdapters?: Partial>; } +export type ResourceRelationshipType = + | 'runs_on' + | 'depends_on' + | 'mounted_to' + | 'exposed_by' + | 'owned_by' + | string; + +export interface ResourceRelationship { + sourceId: string; + targetId: string; + type: ResourceRelationshipType; + confidence: number; + active: boolean; + discoverer: string; + observedAt: string; + lastSeenAt: string; + metadata?: Record; +} + +export interface ResourceCapabilityParam { + name: string; + type: string; + required: boolean; + enum?: string[]; + pattern?: string; + defaultValue?: unknown; + isSensitive: boolean; + description?: string; +} + +export interface ResourceCapability { + name: string; + type: string; + description?: string; + minimumApprovalLevel?: string; + platform?: string; + params?: ResourceCapabilityParam[]; +} + export interface ResourceChange { id: string; observedAt: string; @@ -526,6 +566,8 @@ export interface Resource { canonicalIdentity?: ResourceCanonicalIdentity; policy?: ResourcePolicy; aiSafeSummary?: string; + capabilities?: ResourceCapability[]; + relationships?: ResourceRelationship[]; recentChanges?: ResourceChange[]; facetCounts?: ResourceFacetCounts; diff --git a/frontend-modern/src/utils/__tests__/resourceCorrelationPresentation.test.ts b/frontend-modern/src/utils/__tests__/resourceCorrelationPresentation.test.ts index 47e7f1a6c..edabf5ee7 100644 --- a/frontend-modern/src/utils/__tests__/resourceCorrelationPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceCorrelationPresentation.test.ts @@ -6,7 +6,11 @@ import { formatResourceCorrelationPattern, formatResourceCorrelationSummary, formatResourceCorrelationSummaryText, + formatResourceRelationshipEndpoint, + formatResourceRelationshipSummary, + formatResourceRelationshipType, sortResourceCorrelations, + sortResourceRelationships, } from '@/utils/resourceCorrelationPresentation'; describe('resourceCorrelationPresentation utils', () => { @@ -25,6 +29,17 @@ describe('resourceCorrelationPresentation utils', () => { description: 'Disk pressure often precedes restarts', } as const; + const relationship = { + sourceId: 'node:pve-1', + targetId: 'vm-42', + type: 'runs_on', + confidence: 0.98, + active: true, + discoverer: 'proxmox_adapter', + observedAt: '2026-03-18T12:00:00Z', + lastSeenAt: '2026-03-18T12:05:00Z', + } as const; + it('formats correlation endpoints and headline labels', () => { expect(formatResourceCorrelationEndpoint(correlation, 'source')).toBe('Storage 1'); expect(formatResourceCorrelationEndpoint(correlation, 'target')).toBe('VM 42'); @@ -87,14 +102,47 @@ describe('resourceCorrelationPresentation utils', () => { expect(sorted.map((item) => item.source_id)).toEqual(['storage-3', 'storage-1', 'storage-2']); }); + it('formats and sorts canonical resource relationships', () => { + expect(formatResourceRelationshipEndpoint(relationship, 'source')).toBe('node:pve-1'); + expect(formatResourceRelationshipEndpoint(relationship, 'target')).toBe('vm-42'); + expect(formatResourceRelationshipType(relationship)).toBe('Runs On'); + expect(formatResourceRelationshipSummary(relationship)).toBe( + '98% confidence · Proxmox Adapter', + ); + + const sorted = sortResourceRelationships([ + { + ...relationship, + sourceId: 'historical', + active: false, + confidence: 1, + lastSeenAt: '2026-03-18T13:00:00Z', + }, + { + ...relationship, + sourceId: 'lower-confidence', + confidence: 0.75, + lastSeenAt: '2026-03-18T13:00:00Z', + }, + relationship, + ]); + + expect(sorted.map((item) => item.sourceId)).toEqual([ + 'node:pve-1', + 'lower-confidence', + 'historical', + ]); + }); + it('formats canonical correlation summary text', () => { expect( formatResourceCorrelationSummaryText({ + relationshipsCount: 1, dependenciesCount: 2, dependentsCount: 1, correlationsCount: 3, }), - ).toBe('2 dependencies · 1 dependent · 3 correlations'); + ).toBe('1 canonical relationship · 2 dependencies · 1 dependent · 3 correlations'); expect( formatResourceCorrelationSummaryText({ dependenciesCount: 0, diff --git a/frontend-modern/src/utils/resourceCorrelationPresentation.ts b/frontend-modern/src/utils/resourceCorrelationPresentation.ts index dac0831e8..feba4c2f7 100644 --- a/frontend-modern/src/utils/resourceCorrelationPresentation.ts +++ b/frontend-modern/src/utils/resourceCorrelationPresentation.ts @@ -1,4 +1,5 @@ import type { ResourceCorrelation } from '@/types/aiIntelligence'; +import type { ResourceRelationship } from '@/types/resource'; import { formatDurationMs } from '@/utils/patrolFormat'; import { formatConfidencePercentage } from '@/utils/confidencePresentation'; import { asTrimmedString } from '@/utils/stringUtils'; @@ -85,7 +86,59 @@ export function sortResourceCorrelations( const leftTime = Date.parse(left.last_seen || ''); const rightTime = Date.parse(right.last_seen || ''); - return (Number.isFinite(rightTime) ? rightTime : 0) - (Number.isFinite(leftTime) ? leftTime : 0); + return ( + (Number.isFinite(rightTime) ? rightTime : 0) - (Number.isFinite(leftTime) ? leftTime : 0) + ); + }); +} + +export function formatResourceRelationshipType(relationship: ResourceRelationship): string { + return humanizeCorrelationToken(relationship.type); +} + +export function formatResourceRelationshipEndpoint( + relationship: ResourceRelationship, + role: 'source' | 'target', +): string { + return ( + asTrimmedString(role === 'source' ? relationship.sourceId : relationship.targetId) || + 'Unknown resource' + ); +} + +export function formatResourceRelationshipSummary(relationship: ResourceRelationship): string { + const parts: string[] = []; + + if (typeof relationship.confidence === 'number' && Number.isFinite(relationship.confidence)) { + parts.push(`${formatConfidencePercentage(relationship.confidence)} confidence`); + } + + const discoverer = humanizeCorrelationToken(relationship.discoverer); + if (discoverer && discoverer !== 'Correlation') { + parts.push(discoverer); + } + + if (relationship.active === false) { + parts.push('Historical'); + } + + return parts.join(' · '); +} + +export function sortResourceRelationships( + relationships: readonly ResourceRelationship[], +): ResourceRelationship[] { + return [...relationships].sort((left, right) => { + if (left.active !== right.active) return left.active ? -1 : 1; + + const confidenceDiff = (right.confidence || 0) - (left.confidence || 0); + if (confidenceDiff !== 0) return confidenceDiff; + + const leftTime = Date.parse(left.lastSeenAt || left.observedAt || ''); + const rightTime = Date.parse(right.lastSeenAt || right.observedAt || ''); + return ( + (Number.isFinite(rightTime) ? rightTime : 0) - (Number.isFinite(leftTime) ? leftTime : 0) + ); }); } @@ -96,6 +149,7 @@ const formatSummaryParts = (parts: Array): string => parts.filter((part): part is string => Boolean(part && part.trim())).join(' · '); export function formatResourceCorrelationSummaryText(options: { + relationshipsCount?: number; dependenciesCount: number; dependentsCount: number; correlationsCount: number; @@ -104,6 +158,13 @@ export function formatResourceCorrelationSummaryText(options: { return ( options.summaryText?.trim() || formatSummaryParts([ + options.relationshipsCount && options.relationshipsCount > 0 + ? formatPluralCount( + options.relationshipsCount, + 'canonical relationship', + 'canonical relationships', + ) + : null, options.dependenciesCount > 0 ? formatPluralCount(options.dependenciesCount, 'dependency', 'dependencies') : null, diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index a8d7f2a55..043a0ec87 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -10931,8 +10931,10 @@ func TestContract_ResourceTimelineAnomalyJSONSnapshot(t *testing.T) { func TestContract_ResourceFacetsJSONSnapshot(t *testing.T) { now := time.Date(2026, 3, 18, 17, 0, 0, 0, time.UTC) payload := struct { - ResourceID string `json:"resourceId"` - RecentChanges []unifiedresources.ResourceChange `json:"recentChanges"` + ResourceID string `json:"resourceId"` + Capabilities []unifiedresources.ResourceCapability `json:"capabilities,omitempty"` + Relationships []unifiedresources.ResourceRelationship `json:"relationships,omitempty"` + RecentChanges []unifiedresources.ResourceChange `json:"recentChanges"` Counts struct { RecentChanges int `json:"recentChanges"` RecentChangeKinds map[unifiedresources.ChangeKind]int `json:"recentChangeKinds"` @@ -10941,6 +10943,30 @@ func TestContract_ResourceFacetsJSONSnapshot(t *testing.T) { } `json:"counts"` }{ ResourceID: "vm:42", + Capabilities: []unifiedresources.ResourceCapability{ + { + Name: "restart", + Type: unifiedresources.CapabilityTypeCommon, + Description: "Restart the VM", + MinimumApprovalLevel: unifiedresources.ApprovalAdmin, + }, + }, + Relationships: []unifiedresources.ResourceRelationship{ + { + SourceID: "vm:42", + TargetID: "node-1", + Type: unifiedresources.RelRunsOn, + Confidence: 1, + Active: true, + Discoverer: "proxmox_adapter", + ObservedAt: now, + LastSeenAt: now, + Metadata: map[string]any{ + "source": "live", + "cluster": "pve-prod", + }, + }, + }, RecentChanges: []unifiedresources.ResourceChange{ { ID: "chg-42", @@ -10986,6 +11012,27 @@ func TestContract_ResourceFacetsJSONSnapshot(t *testing.T) { const want = `{ "resourceId":"vm:42", + "capabilities":[ + { + "name":"restart", + "type":"common", + "description":"Restart the VM", + "minimumApprovalLevel":"admin" + } + ], + "relationships":[ + { + "sourceId":"vm:42", + "targetId":"node-1", + "type":"runs_on", + "confidence":1, + "active":true, + "discoverer":"proxmox_adapter", + "observedAt":"2026-03-18T17:00:00Z", + "lastSeenAt":"2026-03-18T17:00:00Z", + "metadata":{"cluster":"pve-prod","source":"live"} + } + ], "recentChanges":[ { "id":"chg-42", @@ -11022,6 +11069,47 @@ func TestContract_ResourceFacetsJSONSnapshot(t *testing.T) { assertJSONSnapshot(t, got, want) } +func TestContract_ResourceFacetsDeriveCanonicalParentRelationship(t *testing.T) { + now := time.Date(2026, 3, 18, 17, 0, 0, 0, time.UTC) + parentID := "k8s-cluster-1" + payload := struct { + ResourceID string `json:"resourceId"` + Relationships []unifiedresources.ResourceRelationship `json:"relationships,omitempty"` + }{ + ResourceID: "agent-1", + Relationships: unifiedresources.ResourceRelationshipsWithCanonicalParent(unifiedresources.Resource{ + ID: "agent-1", + Type: unifiedresources.ResourceTypeAgent, + ParentID: &parentID, + LastSeen: now, + }), + } + + got, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal derived parent relationship facets response: %v", err) + } + + const want = `{ + "resourceId":"agent-1", + "relationships":[ + { + "sourceId":"agent-1", + "targetId":"k8s-cluster-1", + "type":"owned_by", + "confidence":1, + "active":true, + "discoverer":"resource_registry", + "observedAt":"2026-03-18T17:00:00Z", + "lastSeenAt":"2026-03-18T17:00:00Z", + "metadata":{"source":"parentId"} + } + ] + }` + + assertJSONSnapshot(t, got, want) +} + func TestContract_ResourceTimelineEndpointsIncludeRelatedChanges(t *testing.T) { now := time.Date(2026, 4, 25, 22, 15, 0, 0, time.UTC) h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) diff --git a/internal/api/resources.go b/internal/api/resources.go index 1d1dadf65..ec1ac9024 100644 --- a/internal/api/resources.go +++ b/internal/api/resources.go @@ -303,9 +303,11 @@ func (h *ResourceHandlers) HandleGetResource(w http.ResponseWriter, r *http.Requ type resourceFacetCountsResponse = unified.ResourceFacetCounts type resourceFacetBundleResponse struct { - ResourceID string `json:"resourceId"` - RecentChanges []unified.ResourceChange `json:"recentChanges"` - Counts resourceFacetCountsResponse `json:"counts"` + ResourceID string `json:"resourceId"` + Capabilities []unified.ResourceCapability `json:"capabilities,omitempty"` + Relationships []unified.ResourceRelationship `json:"relationships,omitempty"` + RecentChanges []unified.ResourceChange `json:"recentChanges"` + Counts resourceFacetCountsResponse `json:"counts"` } // HandleResourceRoutes dispatches nested resource routes. @@ -369,7 +371,7 @@ func (h *ResourceHandlers) HandleGetResourceFacets(w http.ResponseWriter, r *htt return } - _, ok := registry.Get(resourceID) + resource, ok := registry.Get(resourceID) if !ok { http.Error(w, "Resource not found", http.StatusNotFound) return @@ -429,6 +431,8 @@ func (h *ResourceHandlers) HandleGetResourceFacets(w http.ResponseWriter, r *htt w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resourceFacetBundleResponse{ ResourceID: resourceID, + Capabilities: append([]unified.ResourceCapability(nil), resource.Capabilities...), + Relationships: unified.ResourceRelationshipsWithCanonicalParent(*resource), RecentChanges: recentChanges, Counts: resourceFacetCountsResponse{ RecentChanges: changeCount, diff --git a/internal/api/resources_test.go b/internal/api/resources_test.go index 87ae8d9af..f9c32732b 100644 --- a/internal/api/resources_test.go +++ b/internal/api/resources_test.go @@ -1044,8 +1044,10 @@ func TestResourceGetFacetsAndTimeline(t *testing.T) { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } var payload struct { - ResourceID string `json:"resourceId"` - RecentChanges []unified.ResourceChange `json:"recentChanges"` + ResourceID string `json:"resourceId"` + Capabilities []unified.ResourceCapability `json:"capabilities"` + Relationships []unified.ResourceRelationship `json:"relationships"` + RecentChanges []unified.ResourceChange `json:"recentChanges"` Counts struct { RecentChanges int `json:"recentChanges"` RecentChangeKinds map[unified.ChangeKind]int `json:"recentChangeKinds"` @@ -1059,6 +1061,12 @@ func TestResourceGetFacetsAndTimeline(t *testing.T) { if payload.ResourceID != "vm:42" || payload.Counts.RecentChanges != 3 || len(payload.RecentChanges) != 1 { t.Fatalf("unexpected facets payload: %#v", payload) } + if len(payload.Capabilities) != 1 || payload.Capabilities[0].Name != "restart" { + t.Fatalf("unexpected facets capabilities: %#v", payload.Capabilities) + } + if len(payload.Relationships) != 1 || payload.Relationships[0].TargetID != "node-1" { + t.Fatalf("unexpected facets relationships: %#v", payload.Relationships) + } if got := payload.Counts.RecentChangeKinds; len(got) != 2 || got[unified.ChangeRestart] != 1 || got[unified.ChangeAnomaly] != 2 { t.Fatalf("unexpected recent change kind counts: %#v", got) } diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index cd49c77db..6671a8278 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -848,6 +848,11 @@ func TestResourceRelationshipModelUsesCanonicalEdgeComment(t *testing.T) { requiredSnippets := []string{ "// ResourceRelationship represents a typed relationship edge between two unified resources.", "type ResourceRelationship struct {", + "const parentRelationshipDiscoverer = \"resource_registry\"", + "func ResourceRelationshipsWithCanonicalParent(resource Resource) []ResourceRelationship", + "relationshipType := parentRelationshipType(resource.Type)", + "Metadata: map[string]any{", + "func parentRelationshipType(resourceType ResourceType) RelationshipType", } for _, snippet := range requiredSnippets { if !strings.Contains(source, snippet) { diff --git a/internal/unifiedresources/relationship_presentation_test.go b/internal/unifiedresources/relationship_presentation_test.go index f88abac7e..ef165ce7a 100644 --- a/internal/unifiedresources/relationship_presentation_test.go +++ b/internal/unifiedresources/relationship_presentation_test.go @@ -1,6 +1,9 @@ package unifiedresources -import "testing" +import ( + "testing" + "time" +) func TestRelationshipTypeLabel(t *testing.T) { cases := map[RelationshipType]string{ @@ -110,6 +113,51 @@ func TestFormatResourceRelationshipContext(t *testing.T) { } } +func TestResourceRelationshipsWithCanonicalParent(t *testing.T) { + parentID := "k8s-cluster-1" + now := time.Date(2026, 3, 18, 17, 0, 0, 0, time.UTC) + resource := Resource{ + ID: "agent-1", + Type: ResourceTypeAgent, + ParentID: &parentID, + LastSeen: now, + } + + relationships := ResourceRelationshipsWithCanonicalParent(resource) + if len(relationships) != 1 { + t.Fatalf("expected one canonical parent relationship, got %#v", relationships) + } + relationship := relationships[0] + if relationship.SourceID != "agent-1" || relationship.TargetID != parentID { + t.Fatalf("unexpected relationship endpoints: %#v", relationship) + } + if relationship.Type != RelOwnedBy { + t.Fatalf("relationship type = %q, want %q", relationship.Type, RelOwnedBy) + } + if relationship.Discoverer != parentRelationshipDiscoverer { + t.Fatalf("discoverer = %q, want %q", relationship.Discoverer, parentRelationshipDiscoverer) + } + if relationship.Metadata["source"] != "parentId" { + t.Fatalf("expected parentId provenance metadata, got %#v", relationship.Metadata) + } + + relationships = ResourceRelationshipsWithCanonicalParent(Resource{ + ID: "agent-1", + Type: ResourceTypeAgent, + ParentID: &parentID, + Relationships: []ResourceRelationship{ + { + SourceID: "agent-1", + TargetID: parentID, + Type: RelOwnedBy, + }, + }, + }) + if len(relationships) != 1 { + t.Fatalf("expected existing canonical parent relationship to be reused, got %#v", relationships) + } +} + func contains(s, substr string) bool { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { diff --git a/internal/unifiedresources/relationships.go b/internal/unifiedresources/relationships.go index eb1eb8df7..54020a3e0 100644 --- a/internal/unifiedresources/relationships.go +++ b/internal/unifiedresources/relationships.go @@ -2,6 +2,8 @@ package unifiedresources import "time" +const parentRelationshipDiscoverer = "resource_registry" + // RelationshipType defines the connection semantics between two resources. type RelationshipType string @@ -27,3 +29,70 @@ type ResourceRelationship struct { LastSeenAt time.Time `json:"lastSeenAt"` Metadata map[string]any `json:"metadata,omitempty"` } + +// ResourceRelationshipsWithCanonicalParent returns explicit resource +// relationships plus a canonical relationship derived from ParentID when the +// resource has a parent but no equivalent typed edge. +func ResourceRelationshipsWithCanonicalParent(resource Resource) []ResourceRelationship { + relationships := append([]ResourceRelationship(nil), resource.Relationships...) + + sourceID := CanonicalResourceID(resource.ID) + parentID := "" + if resource.ParentID != nil { + parentID = CanonicalResourceID(*resource.ParentID) + } + if sourceID == "" || parentID == "" || sourceID == parentID { + return relationships + } + + relationshipType := parentRelationshipType(resource.Type) + if relationshipType == "" { + return relationships + } + + for _, relationship := range relationships { + if CanonicalResourceID(relationship.SourceID) == sourceID && + CanonicalResourceID(relationship.TargetID) == parentID && + relationship.Type == relationshipType { + return relationships + } + } + + observedAt := resource.UpdatedAt + if observedAt.IsZero() { + observedAt = resource.LastSeen + } + + return append(relationships, ResourceRelationship{ + SourceID: sourceID, + TargetID: parentID, + Type: relationshipType, + Confidence: 1, + Active: true, + Discoverer: parentRelationshipDiscoverer, + ObservedAt: observedAt, + LastSeenAt: observedAt, + Metadata: map[string]any{ + "source": "parentId", + }, + }) +} + +func parentRelationshipType(resourceType ResourceType) RelationshipType { + switch CanonicalResourceType(resourceType) { + case ResourceTypeVM, + ResourceTypeSystemContainer, + ResourceTypeAppContainer, + ResourceTypeDockerService, + ResourceTypePBS, + ResourceTypePMG, + ResourceTypeCeph: + return RelRunsOn + case ResourceTypeStorage, ResourceTypePhysicalDisk: + return RelMountedTo + case ResourceTypeAgent, ResourceTypeK8sNode, ResourceTypePod, ResourceTypeK8sDeployment: + return RelOwnedBy + default: + return RelOwnedBy + } +}