Surface resource relationship map

This commit is contained in:
rcourtman 2026-04-29 15:20:36 +01:00
parent 535e623ad0
commit cc470635b9
25 changed files with 800 additions and 124 deletions

View file

@ -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.

View file

@ -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"
]
}
]
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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.

View file

@ -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,

View file

@ -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;
}

View file

@ -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<ResourceCorrelationSummaryProps> = (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<ResourceCorrelationSummaryPro
return (
<Show when={hasContent()}>
<div class={`rounded-md border border-border-subtle bg-base p-4 ${className()}`.trim()}>
<div
data-testid={props.dataTestId}
class={`rounded-md border border-border-subtle bg-base p-4 ${className()}`.trim()}
>
<div class="flex flex-wrap items-start justify-between gap-2">
<div>
<h3 class="text-sm font-semibold text-base-content">
@ -56,6 +70,80 @@ export const ResourceCorrelationSummary: Component<ResourceCorrelationSummaryPro
</div>
</div>
<Show when={relationships().length > 0}>
<div class="mt-3" data-testid="resource-canonical-relationships">
<div class="text-[9px] uppercase tracking-wide text-muted">Canonical relationships</div>
<div class="mt-1 space-y-1.5">
<For each={relationships().slice(0, maxCorrelations())}>
{(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 (
<div class="rounded bg-surface px-2 py-1">
<div class="flex flex-wrap items-center gap-1 text-[10px] text-base-content">
{sourceHref ? (
<a
class="inline-flex items-center rounded bg-surface-alt px-1.5 py-0.5 text-[10px] text-blue-700 hover:underline dark:text-blue-300"
href={sourceHref}
aria-label={`Open source resource ${sourceLabel} in Infrastructure`}
>
{sourceLabel}
</a>
) : (
<span class="inline-flex items-center rounded bg-surface-alt px-1.5 py-0.5 text-[10px]">
{sourceLabel}
</span>
)}
<span class="text-muted"></span>
{targetHref ? (
<a
class="inline-flex items-center rounded bg-surface-alt px-1.5 py-0.5 text-[10px] text-blue-700 hover:underline dark:text-blue-300"
href={targetHref}
aria-label={`Open target resource ${targetLabel} in Infrastructure`}
>
{targetLabel}
</a>
) : (
<span class="inline-flex items-center rounded bg-surface-alt px-1.5 py-0.5 text-[10px]">
{targetLabel}
</span>
)}
<span class="inline-flex items-center rounded bg-surface-alt px-1.5 py-0.5 text-[9px] uppercase tracking-wide text-muted">
{typeLabel}
</span>
</div>
<Show when={summary || lastSeenLabel}>
<div class="mt-0.5 text-[10px] text-muted">
{summary}
<Show when={lastSeenLabel}>
<>
{summary ? ' · ' : ''}last seen {lastSeenLabel}
</>
</Show>
</div>
</Show>
</div>
);
}}
</For>
</div>
</div>
</Show>
<Show when={dependencies().length > 0}>
<div class="mt-3">
<div class="text-[9px] uppercase tracking-wide text-muted">Depends on</div>
@ -123,12 +211,13 @@ export const ResourceCorrelationSummary: Component<ResourceCorrelationSummaryPro
const sourceLabel = formatResourceCorrelationEndpoint(correlation, 'source');
const targetLabel = formatResourceCorrelationEndpoint(correlation, 'target');
const patternLabel = formatResourceCorrelationPattern(correlation);
const lastSeenLabel = props.showLastSeen && correlation.last_seen
? formatRelativeTime(correlation.last_seen, {
compact: true,
emptyText: 'just now',
})
: '';
const lastSeenLabel =
props.showLastSeen && correlation.last_seen
? formatRelativeTime(correlation.last_seen, {
compact: true,
emptyText: 'just now',
})
: '';
return (
<div class="rounded bg-surface px-2 py-1" title={headline}>
<div class="flex flex-wrap items-center gap-1 text-[10px] text-base-content">
@ -166,7 +255,7 @@ export const ResourceCorrelationSummary: Component<ResourceCorrelationSummaryPro
<div class="mt-0.5 text-[10px] text-muted">
{summary}
<Show when={lastSeenLabel}>
<>{' '}· last seen {lastSeenLabel}</>
<> · last seen {lastSeenLabel}</>
</Show>
</div>
<Show when={correlation.description}>

View file

@ -522,6 +522,19 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
</Show>
</div>
<Show when={drawer.hasCorrelationContext()}>
<ResourceCorrelationSummary
dataTestId="resource-relationship-map-section"
title="Relationship map"
relationships={drawer.resourceRelationships()}
dependencies={drawer.resourceDependencies()}
dependents={drawer.resourceDependents()}
correlations={drawer.resourceCorrelations()}
resolveResourceLabel={drawer.resolveResourceLabel}
showLastSeen
/>
</Show>
<Show when={drawer.actionAuditAvailable()}>
<ResourceActionHistory
audits={drawer.sortedActionAudits()}
@ -683,37 +696,6 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
maxChanges={1}
compact
/>
<Show when={drawer.hasCorrelationContext()}>
<div data-testid="resource-correlation-context" class="space-y-1.5">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-[10px] font-medium uppercase tracking-wide text-base-content">
Correlations
</span>
<button
type="button"
onClick={() => drawer.setShowCorrelationContext((value) => !value)}
class="inline-flex items-center rounded-md border border-border bg-surface px-2.5 py-1 text-[10px] font-medium text-base-content transition-colors hover:bg-surface-hover"
>
{drawer.showCorrelationContext()
? 'Hide correlations'
: 'Show correlations'}
</button>
</div>
<Show when={drawer.showCorrelationContext()}>
<div class="pt-1">
<ResourceCorrelationSummary
title="Correlations"
dependencies={drawer.resourceDependencies()}
dependents={drawer.resourceDependents()}
correlations={drawer.resourceCorrelations()}
resolveResourceLabel={drawer.resolveResourceLabel}
showLastSeen
/>
</div>
</Show>
</div>
</Show>
</div>
)}
</Show>

View file

@ -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(() => (
<ResourceCorrelationSummary
title="Correlation context"
relationships={[
{
sourceId: 'node:pve-1',
targetId: 'vm-child',
type: 'runs_on',
confidence: 1,
active: true,
discoverer: 'proxmox_adapter',
observedAt: '2026-03-18T12:00:00Z',
lastSeenAt: '2026-03-18T12:05:00Z',
},
]}
dependencies={['storage-1']}
dependents={['vm-child']}
resolveResourceLabel={(resourceId) =>
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);
});
});

View file

@ -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 () => {

View file

@ -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<boolean>;
resourceIntelligence: Accessor<ResourceIntelligence | null>;
resourceRelationships?: Accessor<readonly ResourceRelationship[] | null | undefined>;
}
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<typeof useResourceDetailDrawerDerivedState>;
export type ResourceDetailDrawerDerivedState = ReturnType<
typeof useResourceDetailDrawerDerivedState
>;

View file

@ -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<readonly ResourceCapability[]>(
() => resourceFacets()?.capabilities ?? resource.capabilities ?? [],
);
const resourceFacetRelationships = createMemo<readonly ResourceRelationship[]>(
() => 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,

View file

@ -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,

View file

@ -194,6 +194,46 @@ export interface ResourceFacetCounts {
recentChangeSourceAdapters?: Partial<Record<ResourceFacetSourceAdapter, number>>;
}
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<string, unknown>;
}
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;

View file

@ -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,

View file

@ -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 | null | undefined>): 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,

View file

@ -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()})

View file

@ -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,

View file

@ -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)
}

View file

@ -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) {

View file

@ -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 {

View file

@ -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
}
}