mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-20 01:01:20 +00:00
Surface resource relationship map
This commit is contained in:
parent
535e623ad0
commit
cc470635b9
25 changed files with 800 additions and 124 deletions
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue