mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Add discovery readiness to Assistant context
This commit is contained in:
parent
29ac5945bc
commit
a43f7cbe7f
58 changed files with 2366 additions and 114 deletions
|
|
@ -106,6 +106,11 @@ uses minimal Docker summary commands rather than unbounded guest scraping. A
|
|||
local `--enable-docker=false` or `PULSE_ENABLE_DOCKER=false` remains a hard
|
||||
Unified Agent opt-out that auto-detection and remote profile settings cannot
|
||||
reverse.
|
||||
Proxmox workload actions may use the parent node `pulse-agent` only when the
|
||||
shared `/api/resources` payload carries a backend-authored Proxmox workload
|
||||
target linked to that node agent. Suppressing an inside-guest install cue for
|
||||
that case means governed actions have a node-agent path; it must not be
|
||||
presented as proof that a guest-local agent is installed.
|
||||
|
||||
1. `frontend-modern/src/api/agentProfiles.ts` shared with `api-contracts`: the agent profiles frontend client is both an agent lifecycle control surface and a canonical API payload contract boundary.
|
||||
2. `frontend-modern/src/api/nodes.ts` shared with `api-contracts`: the shared Proxmox node client is both an agent lifecycle setup/install control surface and a canonical API payload contract boundary.
|
||||
|
|
|
|||
|
|
@ -197,9 +197,15 @@ runtime cost control, and shared AI transport surfaces.
|
|||
infrastructure identifier. The live `resource-context` eval is the required
|
||||
regression proof for this path: the model must not ask which resource the
|
||||
user means, must not call discovery just to identify the attached resource,
|
||||
must use the safe handle for scoped reads, must refuse raw
|
||||
must report attached discovery readiness from context without a discovery
|
||||
tool call, must use the safe handle for scoped reads, must refuse raw
|
||||
provider/config/environment/secret-bearing context expansion, and must not
|
||||
leak configured forbidden resource details in content or tool inputs.
|
||||
Resource-context model packs and drawer handoff briefings must carry the
|
||||
canonical discovery readiness state (`fresh`, `stale`, `missing`, `running`,
|
||||
`failed`, `unavailable`, or `unsupported`) with provenance and freshness
|
||||
metadata so Assistant can explain whether it is grounded in current
|
||||
discovery data before choosing any tool.
|
||||
Patrol deterministic triage signals are prioritized evidence seeds for the
|
||||
configured model; they must not be described as a Pulse-authored final
|
||||
diagnosis, proof that unflagged resources are healthy, or a reason to
|
||||
|
|
|
|||
|
|
@ -159,6 +159,13 @@ API surface. Docker node and Swarm node aliases may normalize to the
|
|||
canonical `docker-swarm-node` token, and Swarm secret/config aliases may
|
||||
normalize to `docker-secret` and `docker-config`, but unsupported legacy aliases
|
||||
should continue to fail closed instead of silently widening platform queries.
|
||||
For Proxmox-authored VM and system-container resources, `/api/resources`
|
||||
also owns the workload action/discovery target coordinates. The payload may
|
||||
emit `discoveryTarget` for those workloads only when unified resources has
|
||||
linked the Proxmox node parent to a stable Pulse agent ID; `agentId` is that
|
||||
linked agent ID, not the Proxmox node display name. Clients must not infer a
|
||||
Proxmox workload action target from `node` plus `vmid` when the backend omits
|
||||
that target.
|
||||
|
||||
Discovery read endpoints are a canonical API payload boundary even when Pulse
|
||||
is running in mock mode. `/api/discovery`, typed discovery detail/progress
|
||||
|
|
|
|||
|
|
@ -174,6 +174,15 @@ update-plan readiness verdict inline with the update action, and a blocked
|
|||
readiness status must make automatic install visibly unavailable until the
|
||||
blocking check is resolved.
|
||||
|
||||
`frontend-modern/src/components/shared/DiscoveryReadinessBadge.tsx` is the
|
||||
shared presentation primitive for discovery freshness/readiness indicators.
|
||||
It may render the canonical presentation model from
|
||||
`frontend-modern/src/utils/resourceDiscoveryReadiness.ts`, but it must remain
|
||||
presentational: no local storage, network reads, discovery fetches, or
|
||||
resource-target inference belongs inside the badge. Workload rows, drawers,
|
||||
and Assistant handoff surfaces must share that primitive instead of inventing
|
||||
local freshness chips.
|
||||
|
||||
Platform page subnavigation is a shared frontend primitive. Docker / Podman
|
||||
and Kubernetes platform pages may add native API-backed sections, but the tabs
|
||||
must use `PlatformSectionTabs`, canonical table alignment helpers, and shared
|
||||
|
|
|
|||
|
|
@ -181,7 +181,10 @@ regression protection.
|
|||
canonical workload identity and Discovery coordinates from already-loaded row
|
||||
data, but it must not fetch the agent resource-context bundle or hydrate rich
|
||||
Assistant context while a row is merely rendered, expanded, filtered, sorted,
|
||||
or windowed.
|
||||
or windowed. The same bounded-row rule applies to workload action-agent
|
||||
readiness: drawer rendering may read an already-loaded `discoveryTarget` and
|
||||
local agent fields, but it must not infer Proxmox action ownership by
|
||||
scanning nodes, fetching discovery, or probing agents during render.
|
||||
The `/api/version.agentUpdateTargetVersion` projection follows that same
|
||||
rule: it may mirror the cached deployable agent target exposed to agents,
|
||||
but it must not inspect attached agents, download artifacts, or resolve
|
||||
|
|
|
|||
|
|
@ -164,6 +164,12 @@ controls as normal product settings.
|
|||
must keep normal API auth, resource-policy redaction, and inside-guest
|
||||
runtime collection limits intact rather than expanding what collectors are
|
||||
allowed to gather.
|
||||
11. Change Agent context, discovery-readiness, or action-related route wiring
|
||||
through `internal/api/router.go` without weakening the existing
|
||||
`RequireAuth` and scope checks, resource-policy redaction pass, or
|
||||
read-only Agent-context boundary. Router glue may connect providers, but
|
||||
it must not become an alternate command path, raw provider-command path,
|
||||
config path, environment path, or secret-bearing metadata path.
|
||||
|
||||
## Forbidden Paths
|
||||
|
||||
|
|
|
|||
|
|
@ -198,7 +198,11 @@ API boundary when it helps explain protected workloads or storage-adjacent
|
|||
services, including mock-mode config/data/log path examples. That context is
|
||||
read-only evidence: it must not become a storage/recovery-owned command path,
|
||||
secret source, restore entitlement, or frontend-only fixture separate from the
|
||||
canonical `/api/discovery` payload.
|
||||
canonical `/api/discovery` payload. For Proxmox VM and system-container
|
||||
resources, a node-agent-backed workload `discoveryTarget` is shared API and
|
||||
unified-resource evidence only; storage/recovery consumers may use it to reach
|
||||
the canonical discovery/action path, but must not treat it as backup
|
||||
visibility, restore authority, or storage-local command ownership.
|
||||
The agent resource-context endpoint follows the same adjacent-evidence rule for
|
||||
storage/recovery consumers: bounded context sections, provenance, redactions,
|
||||
and recent action counts may help explain a workload or protected service, but
|
||||
|
|
|
|||
|
|
@ -187,6 +187,26 @@ action, finding, and context-pack lookups. Resource drawer and workload drawer
|
|||
Assistant handoffs may pass stable source IDs such as Proxmox
|
||||
`instance:node:vmid`, but they must not expose generated registry IDs as the
|
||||
browser-side contract or rebuild alias matching in frontend code.
|
||||
Proxmox VM and system-container action/discovery ownership is derived from the
|
||||
canonical parent node resource, not from guest-local heuristics. When the
|
||||
registry or presentation coalescer merges a Proxmox-only node row with an
|
||||
agent-backed node row, Proxmox children must be reparented to the displayed
|
||||
agent-backed resource and their `ProxmoxData.LinkedAgentID` must inherit the
|
||||
parent Pulse agent ID. That linked agent ID is the only source for Proxmox
|
||||
workload `discoveryTarget.agentId`; node names and VMIDs alone are not enough
|
||||
to authorize browser action targets.
|
||||
|
||||
Service-discovery readiness is a unified-resource payload contract, not a
|
||||
drawer-local decoration. Resource list/detail payloads that expose a
|
||||
`discoveryTarget` must also carry the backend-authored `discoveryReadiness`
|
||||
projection when the discovery owner is available. That projection is
|
||||
metadata-only (`fresh`, `stale`, `missing`, `running`, `failed`, `unavailable`,
|
||||
or `unsupported`, with provenance, generated/observed freshness, target
|
||||
coordinates, optional service/category, and bounded fact count) and must not
|
||||
include raw command output, provider commands, environment variables, config
|
||||
paths, or secret-bearing metadata. Workload and resource drawers, Assistant
|
||||
handoffs, and optional table columns consume this field instead of inferring
|
||||
freshness from independent discovery reads.
|
||||
Across platform/runtime pages, workflow tabs are evidence-gated from the
|
||||
canonical model that owns their rows. `Overview` is the stable landing tab;
|
||||
supporting tabs appear only when their native inventory or signal exists, and
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@ describe('AgentContextAPI', () => {
|
|||
canonicalId: 'system-container-6adaf34f529d241a',
|
||||
resourceType: 'system-container',
|
||||
resourceName: 'homeassistant',
|
||||
discoveryReadiness: {
|
||||
state: 'missing',
|
||||
reason: 'Discovery has not run for this resource.',
|
||||
resourceType: 'system-container',
|
||||
targetId: 'agent-delly',
|
||||
resourceId: '101',
|
||||
generatedAt: '2026-06-04T15:00:00Z',
|
||||
},
|
||||
activeFindings: [],
|
||||
pendingApprovals: [],
|
||||
recentActions: [],
|
||||
|
|
@ -25,10 +33,15 @@ describe('AgentContextAPI', () => {
|
|||
generatedAt: '2026-06-04T15:00:00Z',
|
||||
});
|
||||
|
||||
await AgentContextAPI.getResourceContext('delly:delly:101');
|
||||
const context = await AgentContextAPI.getResourceContext('delly:delly:101');
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith(
|
||||
'/api/agent/resource-context/delly%3Adelly%3A101',
|
||||
);
|
||||
expect(context.discoveryReadiness).toMatchObject({
|
||||
state: 'missing',
|
||||
targetId: 'agent-delly',
|
||||
resourceId: '101',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
import type { ResourceDiscoveryReadiness } from '@/types/resource';
|
||||
|
||||
export interface AgentResourceContextFact {
|
||||
label: string;
|
||||
|
|
@ -69,6 +70,7 @@ export interface AgentResourceContext {
|
|||
resourceType: string;
|
||||
resourceName: string;
|
||||
technology?: string;
|
||||
discoveryReadiness?: ResourceDiscoveryReadiness;
|
||||
activeFindings: AgentResourceFindingSnapshot[];
|
||||
pendingApprovals: AgentResourceApprovalSummary[];
|
||||
recentActions: AgentResourceActionSummary[];
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ const resource: Resource = {
|
|||
agentId: 'agent:pve-1',
|
||||
resourceId: 'homeassistant',
|
||||
},
|
||||
discoveryReadiness: {
|
||||
state: 'fresh',
|
||||
source: 'service-discovery',
|
||||
serviceName: 'Home Assistant',
|
||||
factCount: 5,
|
||||
},
|
||||
};
|
||||
|
||||
describe('buildResourceAssistantContext', () => {
|
||||
|
|
@ -41,5 +47,11 @@ describe('buildResourceAssistantContext', () => {
|
|||
expect(context.briefing?.detailLines).toContain('Resource ID: app-container:homeassistant');
|
||||
expect(context.briefing?.detailLines).toContain('Parent: ha-lxc');
|
||||
expect(context.briefing?.detailLines).toContain('Discovery: app-container:homeassistant');
|
||||
expect(context.briefing?.detailLines).toContain(
|
||||
'Discovery data: Discovery fresh, service Home Assistant, 5 facts',
|
||||
);
|
||||
expect(context.briefing?.statusLabel).toBe(
|
||||
'Read-only context attached · Discovery fresh',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -142,6 +142,25 @@ function makeGuest(overrides: Partial<WorkloadGuest> = {}): WorkloadGuest {
|
|||
} as WorkloadGuest;
|
||||
}
|
||||
|
||||
function makeGuestWithDiscoveryTarget(overrides: Partial<WorkloadGuest> = {}): WorkloadGuest {
|
||||
return makeGuest({
|
||||
discoveryTarget: {
|
||||
resourceType: 'vm',
|
||||
agentId: 'agent-node1',
|
||||
resourceId: '100',
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function getDiscoveryPanel(): HTMLElement {
|
||||
const panel = screen.getByTestId('discovery-tab').closest('[style*="overflow-anchor"]');
|
||||
if (!(panel instanceof HTMLElement)) {
|
||||
throw new Error('Discovery tab panel not found');
|
||||
}
|
||||
return panel;
|
||||
}
|
||||
|
||||
const makeHistoryPoints = (base: number) => [
|
||||
{ timestamp: 1, value: base, min: base, max: base },
|
||||
{ timestamp: 2, value: base + 5, min: base + 5, max: base + 5 },
|
||||
|
|
@ -194,6 +213,11 @@ describe('GuestDrawer', () => {
|
|||
workloadType: 'system-container',
|
||||
platformType: 'proxmox',
|
||||
status: 'running',
|
||||
discoveryTarget: {
|
||||
resourceType: 'system-container',
|
||||
agentId: 'agent-pve-a',
|
||||
resourceId: '101',
|
||||
},
|
||||
});
|
||||
|
||||
render(() => <GuestDrawer guest={guest} onClose={vi.fn()} />);
|
||||
|
|
@ -274,10 +298,10 @@ describe('GuestDrawer', () => {
|
|||
|
||||
describe('tab switching', () => {
|
||||
it('renders Overview and Discovery tabs', () => {
|
||||
render(() => <GuestDrawer guest={makeGuest()} onClose={vi.fn()} />);
|
||||
expect(screen.getByText('Overview')).toBeInTheDocument();
|
||||
expect(screen.getByText('History')).toBeInTheDocument();
|
||||
expect(screen.getByText('Discovery')).toBeInTheDocument();
|
||||
render(() => <GuestDrawer guest={makeGuestWithDiscoveryTarget()} onClose={vi.fn()} />);
|
||||
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'History' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Discovery' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Discovery when the workload has no canonical discovery target', () => {
|
||||
|
|
@ -302,7 +326,9 @@ describe('GuestDrawer', () => {
|
|||
});
|
||||
|
||||
it('starts on the Overview tab (discovery content hidden)', () => {
|
||||
const { container } = render(() => <GuestDrawer guest={makeGuest()} onClose={vi.fn()} />);
|
||||
const { container } = render(() => (
|
||||
<GuestDrawer guest={makeGuestWithDiscoveryTarget()} onClose={vi.fn()} />
|
||||
));
|
||||
const panels = container.querySelectorAll('[style*="overflow-anchor"]');
|
||||
expect(panels[0]).not.toHaveClass('hidden');
|
||||
expect(panels[1]).toHaveClass('hidden');
|
||||
|
|
@ -331,7 +357,7 @@ describe('GuestDrawer', () => {
|
|||
suggested_url_source_detail: 'detected 3000/tcp',
|
||||
} as unknown as import('@/types/discovery').ResourceDiscovery);
|
||||
|
||||
render(() => <GuestDrawer guest={makeGuest()} onClose={vi.fn()} />);
|
||||
render(() => <GuestDrawer guest={makeGuestWithDiscoveryTarget()} onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Identified Service')).toBeInTheDocument();
|
||||
|
|
@ -349,7 +375,7 @@ describe('GuestDrawer', () => {
|
|||
|
||||
it('hides the Identified Service card when the discovery record is null or empty', async () => {
|
||||
discoveryApiMocks.getDiscovery.mockResolvedValueOnce(null);
|
||||
render(() => <GuestDrawer guest={makeGuest()} onClose={vi.fn()} />);
|
||||
render(() => <GuestDrawer guest={makeGuestWithDiscoveryTarget()} onClose={vi.fn()} />);
|
||||
await waitFor(() => {
|
||||
expect(discoveryApiMocks.getDiscovery).toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -394,7 +420,7 @@ describe('GuestDrawer', () => {
|
|||
suggested_url_diagnostic: 'no host or IP candidate available',
|
||||
} as unknown as import('@/types/discovery').ResourceDiscovery);
|
||||
|
||||
render(() => <GuestDrawer guest={makeGuest()} onClose={vi.fn()} />);
|
||||
render(() => <GuestDrawer guest={makeGuestWithDiscoveryTarget()} onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(discoveryApiMocks.getDiscovery).toHaveBeenCalled();
|
||||
|
|
@ -410,22 +436,24 @@ describe('GuestDrawer', () => {
|
|||
|
||||
render(() => (
|
||||
<Suspense fallback={<div data-testid="parent-suspense-fallback">Loading page</div>}>
|
||||
<GuestDrawer guest={makeGuest()} onClose={vi.fn()} />
|
||||
<GuestDrawer guest={makeGuestWithDiscoveryTarget()} onClose={vi.fn()} />
|
||||
</Suspense>
|
||||
));
|
||||
|
||||
expect(screen.queryByTestId('parent-suspense-fallback')).toBeNull();
|
||||
expect(screen.getByText('Overview')).toBeInTheDocument();
|
||||
expect(screen.getByText('History')).toBeInTheDocument();
|
||||
expect(screen.getByText('Discovery')).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Discovery' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to Discovery tab on click', async () => {
|
||||
const { container } = render(() => <GuestDrawer guest={makeGuest()} onClose={vi.fn()} />);
|
||||
await fireEvent.click(screen.getByText('Discovery'));
|
||||
const { container } = render(() => (
|
||||
<GuestDrawer guest={makeGuestWithDiscoveryTarget()} onClose={vi.fn()} />
|
||||
));
|
||||
await fireEvent.click(screen.getByRole('tab', { name: 'Discovery' }));
|
||||
const panels = container.querySelectorAll('[style*="overflow-anchor"]');
|
||||
expect(panels[0]).toHaveClass('hidden');
|
||||
expect(panels[1]).not.toHaveClass('hidden');
|
||||
expect(getDiscoveryPanel()).not.toHaveClass('hidden');
|
||||
});
|
||||
|
||||
it('renders persistent metric charts on the History tab', async () => {
|
||||
|
|
@ -504,12 +532,14 @@ describe('GuestDrawer', () => {
|
|||
});
|
||||
|
||||
it('switches back to Overview tab', async () => {
|
||||
const { container } = render(() => <GuestDrawer guest={makeGuest()} onClose={vi.fn()} />);
|
||||
await fireEvent.click(screen.getByText('Discovery'));
|
||||
await fireEvent.click(screen.getByText('Overview'));
|
||||
const { container } = render(() => (
|
||||
<GuestDrawer guest={makeGuestWithDiscoveryTarget()} onClose={vi.fn()} />
|
||||
));
|
||||
await fireEvent.click(screen.getByRole('tab', { name: 'Discovery' }));
|
||||
await fireEvent.click(screen.getByRole('tab', { name: 'Overview' }));
|
||||
const panels = container.querySelectorAll('[style*="overflow-anchor"]');
|
||||
expect(panels[0]).not.toHaveClass('hidden');
|
||||
expect(panels[1]).toHaveClass('hidden');
|
||||
expect(getDiscoveryPanel()).toHaveClass('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -548,7 +578,7 @@ describe('GuestDrawer', () => {
|
|||
render(() => (
|
||||
<GuestDrawer guest={makeGuest({ agentVersion: '5.2.0', type: 'qemu' })} onClose={vi.fn()} />
|
||||
));
|
||||
expect(screen.getByText('Agent')).toBeInTheDocument();
|
||||
expect(screen.getByText('Guest agent')).toBeInTheDocument();
|
||||
expect(screen.getByText('QEMU 5.2.0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -556,7 +586,7 @@ describe('GuestDrawer', () => {
|
|||
render(() => (
|
||||
<GuestDrawer guest={makeGuest({ agentVersion: '1.0.0', type: 'lxc' })} onClose={vi.fn()} />
|
||||
));
|
||||
expect(screen.getByText('Agent')).toBeInTheDocument();
|
||||
expect(screen.getByText('Guest agent')).toBeInTheDocument();
|
||||
expect(screen.getByText('1.0.0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -564,13 +594,39 @@ describe('GuestDrawer', () => {
|
|||
render(() => (
|
||||
<GuestDrawer guest={makeGuest({ agentVersion: undefined })} onClose={vi.fn()} />
|
||||
));
|
||||
expect(screen.getByText('Agent')).toBeInTheDocument();
|
||||
expect(screen.getByText('Actions')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Add agent for AI actions' })).toHaveAttribute(
|
||||
'href',
|
||||
'/settings/infrastructure?add=agent',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows connected node agent actions when the workload has an explicit action target', () => {
|
||||
render(() => (
|
||||
<GuestDrawer
|
||||
guest={makeGuest({
|
||||
agentVersion: undefined,
|
||||
node: 'delly',
|
||||
type: 'lxc',
|
||||
workloadType: 'system-container',
|
||||
discoveryTarget: {
|
||||
resourceType: 'system-container',
|
||||
agentId: 'agent-delly',
|
||||
resourceId: '124',
|
||||
},
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('Actions')).toBeInTheDocument();
|
||||
expect(screen.getByText('Node agent connected')).toHaveAttribute(
|
||||
'title',
|
||||
'Discovery and governed actions use the Pulse Agent connected to delly.',
|
||||
);
|
||||
expect(screen.queryByRole('link', { name: 'Add agent for AI actions' })).toBeNull();
|
||||
});
|
||||
|
||||
it('hides agent guidance for app containers without an in-guest agent target', () => {
|
||||
render(() => (
|
||||
<GuestDrawer
|
||||
|
|
@ -582,7 +638,8 @@ describe('GuestDrawer', () => {
|
|||
onClose={vi.fn()}
|
||||
/>
|
||||
));
|
||||
expect(screen.queryByText('Agent')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Guest agent')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Actions')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides agent guidance when the parent node is offline', () => {
|
||||
|
|
@ -950,16 +1007,35 @@ describe('GuestDrawer', () => {
|
|||
it('passes correct resourceType and agentId for VM', () => {
|
||||
render(() => (
|
||||
<GuestDrawer
|
||||
guest={makeGuest({ type: 'qemu', node: 'pve1', vmid: 101 })}
|
||||
guest={makeGuest({
|
||||
type: 'qemu',
|
||||
node: 'pve1',
|
||||
vmid: 101,
|
||||
discoveryTarget: {
|
||||
resourceType: 'vm',
|
||||
agentId: 'agent-pve1',
|
||||
resourceId: '101',
|
||||
},
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
));
|
||||
expect(screen.getByTestId('disc-resource-type').textContent).toBe('vm');
|
||||
expect(screen.getByTestId('disc-agent-id').textContent).toBe('pve1');
|
||||
expect(screen.getByTestId('disc-agent-id').textContent).toBe('agent-pve1');
|
||||
expect(screen.getByTestId('disc-resource-id').textContent).toBe('101');
|
||||
expect(screen.getByTestId('disc-manual-run-action').textContent).toBe('true');
|
||||
});
|
||||
|
||||
it('does not infer a Proxmox workload discovery target from the node name', () => {
|
||||
render(() => (
|
||||
<GuestDrawer
|
||||
guest={makeGuest({ type: 'qemu', node: 'pve1', vmid: 101 })}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
));
|
||||
expect(screen.queryByTestId('discovery-tab')).toBeNull();
|
||||
});
|
||||
|
||||
it('passes correct resourceType and agentId for app-container', () => {
|
||||
render(() => (
|
||||
<GuestDrawer
|
||||
|
|
@ -1017,7 +1093,7 @@ describe('GuestDrawer', () => {
|
|||
onClose={vi.fn()}
|
||||
/>
|
||||
));
|
||||
expect(screen.getByText('Discovery')).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Discovery' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('disc-resource-type').textContent).toBe('app-container');
|
||||
expect(screen.getByTestId('disc-agent-id').textContent).toBe('truenas-helper');
|
||||
expect(screen.getByTestId('disc-resource-id').textContent).toBe('nextcloud');
|
||||
|
|
@ -1042,12 +1118,21 @@ describe('GuestDrawer', () => {
|
|||
it('passes system-container as resourceType for LXC', () => {
|
||||
render(() => (
|
||||
<GuestDrawer
|
||||
guest={makeGuest({ type: 'lxc', node: 'pve2', vmid: 200 })}
|
||||
guest={makeGuest({
|
||||
type: 'lxc',
|
||||
node: 'pve2',
|
||||
vmid: 200,
|
||||
discoveryTarget: {
|
||||
resourceType: 'system-container',
|
||||
agentId: 'agent-pve2',
|
||||
resourceId: '200',
|
||||
},
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
));
|
||||
expect(screen.getByTestId('disc-resource-type').textContent).toBe('system-container');
|
||||
expect(screen.getByTestId('disc-agent-id').textContent).toBe('pve2');
|
||||
expect(screen.getByTestId('disc-agent-id').textContent).toBe('agent-pve2');
|
||||
expect(screen.getByTestId('disc-resource-id').textContent).toBe('200');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import MessageSquareIcon from 'lucide-solid/icons/message-square';
|
|||
import XIcon from 'lucide-solid/icons/x';
|
||||
import { DiscoveryTab } from '../Discovery/DiscoveryTab';
|
||||
import { DrawerSubjectHeading } from '@/components/shared/DrawerSubjectHeading';
|
||||
import { DiscoveryReadinessBadge } from '@/components/shared/DiscoveryReadinessBadge';
|
||||
import { Subtabs, type SubtabOption } from '@/components/shared/Subtabs';
|
||||
import { getSimpleStatusIndicator } from '@/utils/status';
|
||||
import { getGuestDrawerHistoryFallbackMetrics, type GuestDrawerProps } from './guestDrawerModel';
|
||||
|
|
@ -22,6 +23,7 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
|||
discoveryAgentId,
|
||||
discoveryIdentifiedSummary,
|
||||
discoveryLoadingState,
|
||||
discoveryReadinessPresentation,
|
||||
discoveryResourceId,
|
||||
discoveryResourceType,
|
||||
diskThresholds,
|
||||
|
|
@ -32,6 +34,7 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
|||
hasHistorySupport,
|
||||
hasNetworkInterfaces,
|
||||
hasOsInfo,
|
||||
hasWorkloadActionAgent,
|
||||
historyRange,
|
||||
historyTarget,
|
||||
ipAddresses,
|
||||
|
|
@ -45,6 +48,7 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
|||
showInGuestAgentInstallCue,
|
||||
switchTab,
|
||||
webInterfaceTargetLabel,
|
||||
workloadActionAgentTitle,
|
||||
} = useGuestDrawerState(props);
|
||||
const headingId = () => `guest-drawer-heading-${guestId()}`;
|
||||
const historyFallbackMetrics = createMemo(() =>
|
||||
|
|
@ -96,6 +100,16 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={discoveryReadinessPresentation()}>
|
||||
{(presentation) => (
|
||||
<div class="flex items-center gap-2 text-xs text-muted">
|
||||
<DiscoveryReadinessBadge presentation={presentation()} />
|
||||
<span class="truncate" title={presentation().detail || presentation().title}>
|
||||
{presentation().detail || presentation().statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Subtabs
|
||||
class="mb-1"
|
||||
ariaLabel="Guest drawer sections"
|
||||
|
|
@ -138,8 +152,11 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
|||
backupPresentation={backupPresentation()}
|
||||
diskThresholds={diskThresholds()}
|
||||
discoveryIdentifiedSummary={discoveryIdentifiedSummary()}
|
||||
discoveryReadinessPresentation={discoveryReadinessPresentation()}
|
||||
hasWorkloadActionAgent={hasWorkloadActionAgent()}
|
||||
showInGuestAgentInstallCue={showInGuestAgentInstallCue()}
|
||||
webInterfaceTargetLabel={webInterfaceTargetLabel()}
|
||||
workloadActionAgentTitle={workloadActionAgentTitle()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import { TerminalSquare } from 'lucide-solid';
|
|||
import { formatDiscoveryAge } from '@/api/discovery';
|
||||
import { buildInfrastructureOnboardingPath } from '@/components/Settings/infrastructureWorkspaceModel';
|
||||
import { DiscoveryProvenanceMarker } from '@/components/shared/DiscoveryProvenanceMarker';
|
||||
import { DiscoveryReadinessBadge } from '@/components/shared/DiscoveryReadinessBadge';
|
||||
import { WebInterfaceUrlField } from '@/components/shared/WebInterfaceUrlField';
|
||||
import type { DiscoveryIdentifiedSummary } from '@/utils/discoveryPresentation';
|
||||
import type { DiscoveryReadinessPresentation } from '@/utils/resourceDiscoveryReadiness';
|
||||
import { formatBytes, formatUptime } from '@/utils/format';
|
||||
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
|
||||
|
||||
|
|
@ -14,6 +16,7 @@ import { getGuestDrawerMemoryRows, isGuestDrawerVM } from './guestDrawerModel';
|
|||
import {
|
||||
IN_GUEST_AGENT_INSTALL_ACTION_LABEL,
|
||||
IN_GUEST_AGENT_INSTALL_TITLE,
|
||||
WORKLOAD_ACTION_AGENT_LABEL,
|
||||
} from './workloadAgentReadiness';
|
||||
|
||||
import type { GuestDrawerProps } from './guestDrawerModel';
|
||||
|
|
@ -28,6 +31,7 @@ interface GuestDrawerOverviewProps {
|
|||
hasFilesystemDetails: boolean;
|
||||
hasNetworkInterfaces: boolean;
|
||||
hasOsInfo: boolean;
|
||||
hasWorkloadActionAgent: boolean;
|
||||
showInGuestAgentInstallCue: boolean;
|
||||
ipAddresses: string[];
|
||||
networkInterfaces: NonNullable<GuestDrawerProps['guest']['networkInterfaces']>;
|
||||
|
|
@ -41,7 +45,9 @@ interface GuestDrawerOverviewProps {
|
|||
} | null;
|
||||
diskThresholds?: MetricDisplayThresholds | null;
|
||||
discoveryIdentifiedSummary?: DiscoveryIdentifiedSummary | null;
|
||||
discoveryReadinessPresentation?: DiscoveryReadinessPresentation | null;
|
||||
webInterfaceTargetLabel: string;
|
||||
workloadActionAgentTitle: string;
|
||||
}
|
||||
|
||||
export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
||||
|
|
@ -126,6 +132,29 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
|||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.discoveryReadinessPresentation}>
|
||||
{(presentation) => (
|
||||
<div class="rounded border border-border bg-surface p-3 shadow-sm">
|
||||
<div class="flex items-center justify-between gap-2 mb-2">
|
||||
<h3 class="truncate text-[11px] font-medium uppercase tracking-wide text-base-content">
|
||||
AI Context
|
||||
</h3>
|
||||
<DiscoveryReadinessBadge presentation={presentation()} compact />
|
||||
</div>
|
||||
<div class="space-y-1.5 text-[11px]">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-muted">Discovery</span>
|
||||
<span class="font-medium text-base-content truncate ml-2">
|
||||
{presentation().shortLabel}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={presentation().detail}>
|
||||
<p class="text-[10px] leading-4 text-muted">{presentation().detail}</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div class="rounded border border-border bg-surface p-3 shadow-sm">
|
||||
<h3 class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-2">
|
||||
System
|
||||
|
|
@ -153,15 +182,26 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
|||
</Show>
|
||||
<Show when={props.hasAgentInfo}>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-muted">Agent</span>
|
||||
<span class="text-muted">Guest agent</span>
|
||||
<span class="font-medium text-base-content truncate ml-2" title={props.agentTitle}>
|
||||
{props.agentLabel}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.hasWorkloadActionAgent}>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-muted">Actions</span>
|
||||
<span
|
||||
class="font-medium text-base-content truncate ml-2"
|
||||
title={props.workloadActionAgentTitle}
|
||||
>
|
||||
{WORKLOAD_ACTION_AGENT_LABEL}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.showInGuestAgentInstallCue}>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-muted">Agent</span>
|
||||
<span class="text-muted">Actions</span>
|
||||
<a
|
||||
href={buildInfrastructureOnboardingPath('agent')}
|
||||
class="inline-flex items-center gap-1 text-right text-[11px] font-semibold text-amber-700 underline-offset-2 hover:underline dark:text-amber-300"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
createSummaryInteractiveRowPreviewHandlers,
|
||||
} from '@/components/shared/summaryInteractionA11y';
|
||||
import { SummaryRowActionButton } from '@/components/shared/SummaryRowActionButton';
|
||||
import { DiscoveryReadinessBadge } from '@/components/shared/DiscoveryReadinessBadge';
|
||||
import { getWorkloadGuestDiskStatusMessage } from '@/utils/workloadGuestPresentation';
|
||||
import type { GuestRowProps } from './guestRowModel';
|
||||
import { useGuestRowState } from './useGuestRowState';
|
||||
|
|
@ -51,6 +52,7 @@ export function GuestRow(props: GuestRowProps) {
|
|||
customUrl,
|
||||
diskAnomaly,
|
||||
diskIOEmphasis,
|
||||
discoveryReadinessPresentation,
|
||||
diskRead,
|
||||
diskThresholds,
|
||||
diskWrite,
|
||||
|
|
@ -552,6 +554,26 @@ export function GuestRow(props: GuestRowProps) {
|
|||
</td>
|
||||
</Show>
|
||||
|
||||
{/* AI context readiness */}
|
||||
<Show when={isColVisible('aiContext')}>
|
||||
<td class="px-1.5 sm:px-2 py-0.5 align-middle">
|
||||
<div class="flex justify-center">
|
||||
<Show
|
||||
when={discoveryReadinessPresentation()}
|
||||
fallback={
|
||||
<span class="text-xs text-slate-400" aria-hidden="true">
|
||||
—
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{(presentation) => (
|
||||
<DiscoveryReadinessBadge presentation={presentation()} compact />
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</td>
|
||||
</Show>
|
||||
|
||||
{/* Backup Status */}
|
||||
<Show when={isColVisible('backup')}>
|
||||
<td class="px-1.5 sm:px-2 py-0.5 align-middle">
|
||||
|
|
|
|||
|
|
@ -859,8 +859,8 @@ describe('GuestRow', () => {
|
|||
describe('GUEST_COLUMNS', () => {
|
||||
it('has the expected number of columns', () => {
|
||||
// name, runtime, type, info, vmid, cpu, memory, disk, ip, uptime, node,
|
||||
// image, namespace, context, backup, tags, os, netIo, diskIo, update
|
||||
expect(GUEST_COLUMNS.length).toBe(20);
|
||||
// image, namespace, context, aiContext, backup, tags, os, netIo, diskIo, update
|
||||
expect(GUEST_COLUMNS.length).toBe(21);
|
||||
});
|
||||
|
||||
it('has name as the first column', () => {
|
||||
|
|
@ -887,6 +887,7 @@ describe('GUEST_COLUMNS', () => {
|
|||
expect(toggleableIds).toContain('ip');
|
||||
expect(toggleableIds).toContain('uptime');
|
||||
expect(toggleableIds).toContain('node');
|
||||
expect(toggleableIds).toContain('aiContext');
|
||||
expect(toggleableIds).toContain('backup');
|
||||
expect(toggleableIds).toContain('tags');
|
||||
expect(toggleableIds).toContain('os');
|
||||
|
|
@ -898,6 +899,7 @@ describe('GUEST_COLUMNS', () => {
|
|||
const nameColumn = GUEST_COLUMNS.find((column) => column.id === 'name');
|
||||
const runtimeColumn = GUEST_COLUMNS.find((column) => column.id === 'runtime');
|
||||
const typeColumn = GUEST_COLUMNS.find((column) => column.id === 'type');
|
||||
const aiContextColumn = GUEST_COLUMNS.find((column) => column.id === 'aiContext');
|
||||
const netIoColumn = GUEST_COLUMNS.find((column) => column.id === 'netIo');
|
||||
const diskIoColumn = GUEST_COLUMNS.find((column) => column.id === 'diskIo');
|
||||
const updateColumn = GUEST_COLUMNS.find((column) => column.id === 'update');
|
||||
|
|
@ -909,6 +911,8 @@ describe('GUEST_COLUMNS', () => {
|
|||
expect(runtimeColumn?.minWidth).toBe('96px');
|
||||
expect(runtimeColumn?.maxWidth).toBe('112px');
|
||||
expect(typeColumn?.width).toBe('60px');
|
||||
expect(aiContextColumn?.defaultHidden).toBe(true);
|
||||
expect(aiContextColumn?.width).toBe('92px');
|
||||
expect(netIoColumn?.width).toBe('170px');
|
||||
expect(netIoColumn?.minWidth).toBe('170px');
|
||||
expect(diskIoColumn?.width).toBe('170px');
|
||||
|
|
@ -950,6 +954,7 @@ describe('GUEST_COLUMNS', () => {
|
|||
'memory',
|
||||
'disk',
|
||||
'uptime',
|
||||
'aiContext',
|
||||
'backup',
|
||||
]);
|
||||
|
||||
|
|
@ -968,7 +973,7 @@ describe('GUEST_COLUMNS', () => {
|
|||
'compact',
|
||||
compactColumns.map((column) => column.id),
|
||||
),
|
||||
).toEqual({ width: '27.6596%' });
|
||||
).toEqual({ width: '25.2427%' });
|
||||
});
|
||||
|
||||
it('normalizes compact widths for workload view modes with different column sets', () => {
|
||||
|
|
@ -976,9 +981,17 @@ describe('GUEST_COLUMNS', () => {
|
|||
const compactPodColumns = getWorkloadVisibleColumnsForLayout(podColumns, 'compact');
|
||||
const compactPodColumnIds = compactPodColumns.map((column) => column.id);
|
||||
|
||||
expect(compactPodColumnIds).toEqual(['name', 'cpu', 'memory', 'image', 'namespace', 'context']);
|
||||
expect(compactPodColumnIds).toEqual([
|
||||
'name',
|
||||
'cpu',
|
||||
'memory',
|
||||
'image',
|
||||
'namespace',
|
||||
'context',
|
||||
'aiContext',
|
||||
]);
|
||||
expect(getGuestColumnWidthStyle('name', false, 'compact', compactPodColumnIds)).toEqual({
|
||||
width: '27.3684%',
|
||||
width: '25%',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1035,6 +1048,12 @@ describe('VIEW_MODE_COLUMNS', () => {
|
|||
expect(VIEW_MODE_COLUMNS.all!.has('info')).toBe(true);
|
||||
});
|
||||
|
||||
it('all view modes expose the hidden AI Context column', () => {
|
||||
for (const [, cols] of Object.entries(VIEW_MODE_COLUMNS)) {
|
||||
if (cols) expect(cols.has('aiContext')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('all mode does not include vmid (uses info instead)', () => {
|
||||
expect(VIEW_MODE_COLUMNS.all!.has('vmid')).toBe(false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -218,6 +218,11 @@ describe('workloadTopology', () => {
|
|||
type: 'vm',
|
||||
workloadType: 'vm',
|
||||
node: 'pve1',
|
||||
discoveryTarget: {
|
||||
resourceType: 'vm',
|
||||
agentId: 'agent-pve1',
|
||||
resourceId: '103',
|
||||
},
|
||||
});
|
||||
|
||||
expect(getDiscoveryHostIdForWorkload(docker)).toBe('docker-host-1');
|
||||
|
|
@ -230,7 +235,7 @@ describe('workloadTopology', () => {
|
|||
expect(getDiscoveryHostIdForWorkload(k8s)).toBe('k8s-agent-1');
|
||||
expect(getDiscoveryResourceIdForWorkload(k8s)).toBe('pod-uid-1');
|
||||
|
||||
expect(getDiscoveryHostIdForWorkload(vm)).toBe('pve1');
|
||||
expect(getDiscoveryHostIdForWorkload(vm)).toBe('agent-pve1');
|
||||
expect(getDiscoveryResourceIdForWorkload(vm)).toBe('103');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -41,5 +41,6 @@ export const buildGuestAssistantContext = (guest: WorkloadGuest): AIChatContext
|
|||
parentName: guest.node,
|
||||
primaryIdentity: guest.id,
|
||||
discoveryTarget,
|
||||
discoveryReadiness: guest.discoveryReadiness,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { JSX } from 'solid-js';
|
||||
import BrainCircuitIcon from 'lucide-solid/icons/brain-circuit';
|
||||
|
||||
import type { ColumnDef } from '@/hooks/useColumnVisibility';
|
||||
import type { SummaryGroupMemberInteractionState } from '@/components/shared/summaryCardInteraction';
|
||||
|
|
@ -36,6 +37,7 @@ const WORKLOAD_COLUMN_MIN_LAYOUT: Record<string, WorkloadTableLayoutMode> = {
|
|||
image: 'compact',
|
||||
namespace: 'compact',
|
||||
context: 'compact',
|
||||
aiContext: 'compact',
|
||||
update: 'compact',
|
||||
ip: 'wide',
|
||||
node: 'wide',
|
||||
|
|
@ -290,6 +292,16 @@ export const GUEST_COLUMNS: ColumnDef[] = [
|
|||
sortKey: 'contextLabel',
|
||||
kind: 'text',
|
||||
},
|
||||
{
|
||||
id: 'aiContext',
|
||||
label: 'AI Context',
|
||||
icon: <BrainCircuitIcon class="w-3.5 h-3.5 block" aria-hidden="true" />,
|
||||
width: '92px',
|
||||
minWidth: '84px',
|
||||
toggleable: true,
|
||||
defaultHidden: true,
|
||||
kind: 'badge',
|
||||
},
|
||||
{
|
||||
id: 'backup',
|
||||
label: 'Backup',
|
||||
|
|
@ -399,6 +411,7 @@ const GUEST_COLUMN_RESPONSIVE_WEIGHTS: Record<
|
|||
image: 18,
|
||||
namespace: 11,
|
||||
context: 13,
|
||||
aiContext: 9,
|
||||
update: 10,
|
||||
},
|
||||
};
|
||||
|
|
@ -505,6 +518,7 @@ export const VIEW_MODE_COLUMNS: Record<ViewMode, Set<string> | null> = {
|
|||
'uptime',
|
||||
'node',
|
||||
'backup',
|
||||
'aiContext',
|
||||
'tags',
|
||||
'os',
|
||||
'diskIo',
|
||||
|
|
@ -520,6 +534,7 @@ export const VIEW_MODE_COLUMNS: Record<ViewMode, Set<string> | null> = {
|
|||
'uptime',
|
||||
'node',
|
||||
'backup',
|
||||
'aiContext',
|
||||
'tags',
|
||||
'os',
|
||||
'diskIo',
|
||||
|
|
@ -535,6 +550,7 @@ export const VIEW_MODE_COLUMNS: Record<ViewMode, Set<string> | null> = {
|
|||
'uptime',
|
||||
'node',
|
||||
'backup',
|
||||
'aiContext',
|
||||
'tags',
|
||||
'os',
|
||||
'diskIo',
|
||||
|
|
@ -556,6 +572,7 @@ export const VIEW_MODE_COLUMNS: Record<ViewMode, Set<string> | null> = {
|
|||
'os',
|
||||
'image',
|
||||
'context',
|
||||
'aiContext',
|
||||
'update',
|
||||
'diskIo',
|
||||
'netIo',
|
||||
|
|
@ -569,10 +586,11 @@ export const VIEW_MODE_COLUMNS: Record<ViewMode, Set<string> | null> = {
|
|||
'uptime',
|
||||
'image',
|
||||
'context',
|
||||
'aiContext',
|
||||
'tags',
|
||||
'update',
|
||||
'diskIo',
|
||||
'netIo',
|
||||
]),
|
||||
pod: new Set(['name', 'cpu', 'memory', 'image', 'namespace', 'context']),
|
||||
pod: new Set(['name', 'cpu', 'memory', 'image', 'namespace', 'context', 'aiContext']),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
getDiscoveryIdentifiedSummary,
|
||||
getDiscoveryLoadingState,
|
||||
} from '@/utils/discoveryPresentation';
|
||||
import { getDiscoveryReadinessPresentation } from '@/utils/resourceDiscoveryReadiness';
|
||||
import type { DisplayMetricType } from '@/utils/metricThresholds';
|
||||
import {
|
||||
getCanonicalWorkloadId,
|
||||
|
|
@ -42,7 +43,11 @@ import {
|
|||
type GuestDrawerTab,
|
||||
} from './guestDrawerModel';
|
||||
import { buildGuestAssistantContext } from './guestAssistantContextModel';
|
||||
import { shouldShowInGuestAgentInstallCue } from './workloadAgentReadiness';
|
||||
import {
|
||||
getWorkloadActionAgentTitle,
|
||||
hasExplicitWorkloadActionAgent,
|
||||
shouldShowInGuestAgentInstallCue,
|
||||
} from './workloadAgentReadiness';
|
||||
|
||||
interface GuestDiscoverySourceKey {
|
||||
type: DiscoveryResourceType;
|
||||
|
|
@ -85,6 +90,8 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
|
|||
const agentLabel = createMemo(() => getGuestDrawerAgentLabel(props.guest));
|
||||
const agentTitle = createMemo(() => getGuestDrawerAgentTitle(props.guest));
|
||||
const hasAgentInfo = createMemo(() => agentLabel().length > 0);
|
||||
const hasWorkloadActionAgent = createMemo(() => hasExplicitWorkloadActionAgent(props.guest));
|
||||
const workloadActionAgentTitle = createMemo(() => getWorkloadActionAgentTitle(props.guest));
|
||||
const showInGuestAgentInstallCue = createMemo(() =>
|
||||
shouldShowInGuestAgentInstallCue(props.guest, props.parentNodeOnline !== false),
|
||||
);
|
||||
|
|
@ -135,6 +142,9 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
|
|||
const discoveryIdentifiedSummary = createMemo(() =>
|
||||
getDiscoveryIdentifiedSummary(discoveryRecord.value()),
|
||||
);
|
||||
const discoveryReadinessPresentation = createMemo(() =>
|
||||
getDiscoveryReadinessPresentation(props.guest.discoveryReadiness, hasDiscoverySupport()),
|
||||
);
|
||||
|
||||
const switchTab = (tab: GuestDrawerTab) => {
|
||||
setActiveTab(tab);
|
||||
|
|
@ -187,6 +197,7 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
|
|||
agentContextCopied,
|
||||
discoveryAgentId,
|
||||
discoveryIdentifiedSummary,
|
||||
discoveryReadinessPresentation,
|
||||
discoveryLoadingState: getDiscoveryLoadingState(),
|
||||
discoveryResourceId,
|
||||
discoveryResourceType,
|
||||
|
|
@ -199,6 +210,7 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
|
|||
hasHistorySupport,
|
||||
hasNetworkInterfaces,
|
||||
hasOsInfo,
|
||||
hasWorkloadActionAgent,
|
||||
historyTarget,
|
||||
historyRange,
|
||||
ipAddresses,
|
||||
|
|
@ -207,6 +219,7 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
|
|||
osName,
|
||||
osVersion,
|
||||
showInGuestAgentInstallCue,
|
||||
workloadActionAgentTitle,
|
||||
assistantAvailable,
|
||||
openAssistantForGuest,
|
||||
copyAgentContext,
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ import { getContainerRuntimeBadgeForRuntime } from '@/utils/resourceBadgePresent
|
|||
import {
|
||||
getCanonicalWorkloadId,
|
||||
getWorkloadMetricsKind,
|
||||
hasDiscoverySupportForWorkload,
|
||||
isDockerManagedAppContainer,
|
||||
resolveWorkloadType,
|
||||
} from '@/utils/workloads';
|
||||
import { getDiscoveryReadinessPresentation } from '@/utils/resourceDiscoveryReadiness';
|
||||
import { getWorkloadTypeBadge } from '@/components/shared/workloadTypeBadges';
|
||||
|
||||
import {
|
||||
|
|
@ -92,6 +94,12 @@ export function useGuestRowState(props: GuestRowProps) {
|
|||
const namespace = createMemo(() => props.guest.namespace?.trim() ?? '');
|
||||
const contextLabel = createMemo(() => props.guest.contextLabel?.trim() ?? '');
|
||||
const clusterName = createMemo(() => props.guest.clusterName?.trim() ?? '');
|
||||
const discoveryReadinessPresentation = createMemo(() =>
|
||||
getDiscoveryReadinessPresentation(
|
||||
props.guest.discoveryReadiness,
|
||||
hasDiscoverySupportForWorkload(props.guest),
|
||||
),
|
||||
);
|
||||
const isPveWorkload = createMemo(() => {
|
||||
const type = workloadType();
|
||||
return type === 'vm' || type === 'system-container';
|
||||
|
|
@ -275,6 +283,7 @@ export function useGuestRowState(props: GuestRowProps) {
|
|||
diskAnomaly,
|
||||
diskIOEmphasis,
|
||||
diskPercent,
|
||||
discoveryReadinessPresentation,
|
||||
diskRead,
|
||||
diskThresholds,
|
||||
diskWrite,
|
||||
|
|
|
|||
|
|
@ -190,6 +190,8 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
|
|||
workloadColumns,
|
||||
defaultHiddenColumnIds,
|
||||
relevantColumns,
|
||||
{},
|
||||
['aiContext'],
|
||||
);
|
||||
|
||||
const visibleColumns = columnVisibility.visibleColumns;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,18 @@ const isOciSystemContainer = (guest: WorkloadGuest): boolean =>
|
|||
export const hasInGuestPulseAgent = (guest: WorkloadGuest): boolean =>
|
||||
(guest.agentVersion || '').trim().length > 0;
|
||||
|
||||
export const hasExplicitWorkloadActionAgent = (guest: WorkloadGuest): boolean => {
|
||||
const target = guest.discoveryTarget;
|
||||
if (!target) return false;
|
||||
const workloadType = resolveWorkloadType(guest);
|
||||
if (workloadType !== 'vm' && workloadType !== 'system-container') return false;
|
||||
return (
|
||||
(target.agentId || '').trim().length > 0 &&
|
||||
(target.resourceId || '').trim().length > 0 &&
|
||||
(target.resourceType === 'vm' || target.resourceType === 'system-container')
|
||||
);
|
||||
};
|
||||
|
||||
export const isInGuestPulseAgentEligibleWorkload = (guest: WorkloadGuest): boolean => {
|
||||
if ((guest as { template?: boolean }).template === true) return false;
|
||||
|
||||
|
|
@ -23,9 +35,17 @@ export const shouldShowInGuestAgentInstallCue = (
|
|||
): boolean =>
|
||||
isInGuestPulseAgentEligibleWorkload(guest) &&
|
||||
!hasInGuestPulseAgent(guest) &&
|
||||
!hasExplicitWorkloadActionAgent(guest) &&
|
||||
isGuestRunning(guest, parentNodeOnline);
|
||||
|
||||
export const IN_GUEST_AGENT_INSTALL_SUMMARY_LABEL = 'Install agent';
|
||||
export const IN_GUEST_AGENT_INSTALL_ACTION_LABEL = 'Add agent for AI actions';
|
||||
export const IN_GUEST_AGENT_INSTALL_TITLE =
|
||||
'Install Pulse Agent inside this guest to unlock deep telemetry and AI actions.';
|
||||
'Install Pulse Agent for this workload path to unlock deep telemetry and AI actions.';
|
||||
export const WORKLOAD_ACTION_AGENT_LABEL = 'Node agent connected';
|
||||
export const getWorkloadActionAgentTitle = (guest: WorkloadGuest): string => {
|
||||
const node = (guest.node || '').trim();
|
||||
return node
|
||||
? `Discovery and governed actions use the Pulse Agent connected to ${node}.`
|
||||
: 'Discovery and governed actions use the connected parent node Pulse Agent.';
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import { Dynamic } from 'solid-js/web';
|
||||
import AlertTriangleIcon from 'lucide-solid/icons/alert-triangle';
|
||||
import CheckCircle2Icon from 'lucide-solid/icons/check-circle-2';
|
||||
import CircleSlashIcon from 'lucide-solid/icons/circle-slash';
|
||||
import Clock3Icon from 'lucide-solid/icons/clock-3';
|
||||
import Loader2Icon from 'lucide-solid/icons/loader-2';
|
||||
import MinusCircleIcon from 'lucide-solid/icons/minus-circle';
|
||||
import type { DiscoveryReadinessPresentation } from '@/utils/resourceDiscoveryReadiness';
|
||||
|
||||
interface DiscoveryReadinessBadgeProps {
|
||||
presentation?: DiscoveryReadinessPresentation | null;
|
||||
compact?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const toneClasses: Record<DiscoveryReadinessPresentation['tone'], string> = {
|
||||
success:
|
||||
'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300',
|
||||
warning:
|
||||
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300',
|
||||
info: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300',
|
||||
danger:
|
||||
'border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300',
|
||||
muted:
|
||||
'border-border bg-surface-alt text-muted dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300',
|
||||
};
|
||||
|
||||
const iconForState = (state: DiscoveryReadinessPresentation['state']): Component<{ class?: string }> => {
|
||||
switch (state) {
|
||||
case 'fresh':
|
||||
return CheckCircle2Icon;
|
||||
case 'stale':
|
||||
return Clock3Icon;
|
||||
case 'running':
|
||||
return Loader2Icon;
|
||||
case 'failed':
|
||||
case 'unavailable':
|
||||
return AlertTriangleIcon;
|
||||
case 'unsupported':
|
||||
return CircleSlashIcon;
|
||||
case 'missing':
|
||||
case 'unknown':
|
||||
default:
|
||||
return MinusCircleIcon;
|
||||
}
|
||||
};
|
||||
|
||||
export const DiscoveryReadinessBadge: Component<DiscoveryReadinessBadgeProps> = (props) => {
|
||||
const presentation = () => props.presentation;
|
||||
const label = () => (props.compact ? presentation()?.shortLabel : presentation()?.label);
|
||||
const Icon = () => iconForState(presentation()?.state ?? 'unknown');
|
||||
|
||||
return (
|
||||
<span
|
||||
class={[
|
||||
'inline-flex h-6 max-w-full items-center gap-1 rounded border px-1.5 text-[10px] font-medium leading-none whitespace-nowrap',
|
||||
presentation() ? toneClasses[presentation()!.tone] : toneClasses.muted,
|
||||
props.class,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
title={presentation()?.title}
|
||||
aria-label={presentation()?.title ?? presentation()?.label}
|
||||
>
|
||||
<Dynamic
|
||||
component={Icon()}
|
||||
class={`h-3.5 w-3.5 shrink-0 ${presentation()?.state === 'running' ? 'animate-spin' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="truncate">{label()}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
|
@ -37,6 +37,7 @@ import searchInputEnhancementsModelSource from '@/components/shared/searchInputE
|
|||
import searchInputModelSource from '@/components/shared/searchInputModel.ts?raw';
|
||||
import statusBadgeSource from '@/components/shared/StatusBadge.tsx?raw';
|
||||
import statusBadgeModelSource from '@/components/shared/statusBadgeModel.ts?raw';
|
||||
import discoveryReadinessBadgeSource from '@/components/shared/DiscoveryReadinessBadge.tsx?raw';
|
||||
import subtabsSource from '@/components/shared/Subtabs.tsx?raw';
|
||||
import toggleSource from '@/components/shared/Toggle.tsx?raw';
|
||||
import toggleModelSource from '@/components/shared/toggleModel.ts?raw';
|
||||
|
|
@ -724,6 +725,18 @@ describe('shared primitive guardrails', () => {
|
|||
expect(statusBadgeModelSource).toContain("labelEnabled ?? 'Enabled'");
|
||||
});
|
||||
|
||||
it('keeps discovery readiness badges presentation-only and accessible', () => {
|
||||
expect(discoveryReadinessBadgeSource).toContain(
|
||||
"import type { DiscoveryReadinessPresentation } from '@/utils/resourceDiscoveryReadiness'",
|
||||
);
|
||||
expect(discoveryReadinessBadgeSource).toContain("aria-label={presentation()?.title");
|
||||
expect(discoveryReadinessBadgeSource).toContain('title={presentation()?.title}');
|
||||
expect(discoveryReadinessBadgeSource).toContain('aria-hidden="true"');
|
||||
expect(discoveryReadinessBadgeSource).not.toContain('fetch(');
|
||||
expect(discoveryReadinessBadgeSource).not.toContain('localStorage');
|
||||
expect(discoveryReadinessBadgeSource).not.toContain('innerHTML');
|
||||
});
|
||||
|
||||
it('routes settings info callouts through CalloutCard', () => {
|
||||
expect(calloutCardSource).toContain(
|
||||
"type CalloutTone = 'danger' | 'info' | 'success' | 'warning'",
|
||||
|
|
|
|||
|
|
@ -65,6 +65,75 @@ describe('useColumnVisibility', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('hides newly default-hidden columns once for existing user preferences', async () => {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(['ip']));
|
||||
|
||||
let disposeRoot: (() => void) | undefined;
|
||||
let visibility:
|
||||
| ReturnType<typeof useColumnVisibility>
|
||||
| undefined;
|
||||
|
||||
createRoot((dispose) => {
|
||||
disposeRoot = dispose;
|
||||
const columns: ColumnDef[] = [
|
||||
{ id: 'name', label: 'Name' },
|
||||
{ id: 'type', label: 'Type', toggleable: true, defaultHidden: true },
|
||||
{ id: 'ip', label: 'IP', toggleable: true },
|
||||
{ id: 'aiContext', label: 'AI Context', toggleable: true, defaultHidden: true },
|
||||
];
|
||||
|
||||
visibility = useColumnVisibility(
|
||||
storageKey,
|
||||
columns,
|
||||
['ip'],
|
||||
undefined,
|
||||
{},
|
||||
['aiContext'],
|
||||
);
|
||||
|
||||
expect(visibility.hiddenColumns()).toEqual(['ip', 'aiContext']);
|
||||
expect(visibility.visibleColumns().map((col) => col.id)).toEqual(['name', 'type']);
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(window.localStorage.getItem(storageKey)).toBe(JSON.stringify(['ip', 'aiContext']));
|
||||
expect(window.localStorage.getItem(`${storageKey}:default-hidden-applied`)).toBe(
|
||||
JSON.stringify(['aiContext']),
|
||||
);
|
||||
|
||||
visibility?.show('aiContext');
|
||||
await Promise.resolve();
|
||||
expect(window.localStorage.getItem(storageKey)).toBe(JSON.stringify(['ip']));
|
||||
disposeRoot?.();
|
||||
|
||||
createRoot((dispose) => {
|
||||
const columns: ColumnDef[] = [
|
||||
{ id: 'name', label: 'Name' },
|
||||
{ id: 'type', label: 'Type', toggleable: true, defaultHidden: true },
|
||||
{ id: 'ip', label: 'IP', toggleable: true },
|
||||
{ id: 'aiContext', label: 'AI Context', toggleable: true, defaultHidden: true },
|
||||
];
|
||||
|
||||
const remounted = useColumnVisibility(
|
||||
storageKey,
|
||||
columns,
|
||||
['ip'],
|
||||
undefined,
|
||||
{},
|
||||
['aiContext'],
|
||||
);
|
||||
|
||||
expect(remounted.hiddenColumns()).toEqual(['ip']);
|
||||
expect(remounted.visibleColumns().map((col) => col.id)).toEqual([
|
||||
'name',
|
||||
'type',
|
||||
'aiContext',
|
||||
]);
|
||||
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it('resets back to the canonical default-hidden set', () => {
|
||||
createRoot((dispose) => {
|
||||
const columns: ColumnDef[] = [
|
||||
|
|
|
|||
|
|
@ -55,6 +55,40 @@ const normalizePersistedColumnIds = (
|
|||
return { ids, migrated };
|
||||
};
|
||||
|
||||
const defaultHiddenMigrationStorageKey = (storageKey: string): string =>
|
||||
`${storageKey}:default-hidden-applied`;
|
||||
|
||||
const readAppliedDefaultHiddenMigrations = (
|
||||
storageKey: string,
|
||||
persistedIdAliases: Record<string, string>,
|
||||
): string[] => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(defaultHiddenMigrationStorageKey(storageKey));
|
||||
if (raw === null) return [];
|
||||
return normalizePersistedColumnIds(JSON.parse(raw), persistedIdAliases).ids;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const persistAppliedDefaultHiddenMigrations = (
|
||||
storageKey: string,
|
||||
ids: string[],
|
||||
): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
defaultHiddenMigrationStorageKey(storageKey),
|
||||
JSON.stringify(Array.from(new Set(ids))),
|
||||
);
|
||||
} catch {
|
||||
// localStorage may be full or unavailable; the hidden-column preference still works.
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for managing column visibility with persistence.
|
||||
*
|
||||
|
|
@ -67,6 +101,8 @@ const normalizePersistedColumnIds = (
|
|||
* @param defaultHidden - Optional array of column IDs to hide by default (only used if no user preference exists)
|
||||
* @param relevantColumns - Optional reactive accessor returning the set of column IDs relevant to the current view.
|
||||
* When non-null, columns not in the set are excluded from visibleColumns and availableToggles.
|
||||
* @param defaultHiddenMigrationIds - New default-hidden columns that should be hidden once for users with
|
||||
* existing preferences. After the user shows the column, the migration marker prevents re-hiding it.
|
||||
*/
|
||||
export function useColumnVisibility(
|
||||
storageKey: string,
|
||||
|
|
@ -74,6 +110,7 @@ export function useColumnVisibility(
|
|||
defaultHidden: string[] = [],
|
||||
relevantColumns?: Accessor<Set<string> | null>,
|
||||
persistedIdAliases: Record<string, string> = {},
|
||||
defaultHiddenMigrationIds: string[] = [],
|
||||
) {
|
||||
const defaultHiddenFromColumns = columns
|
||||
.filter((c) => c.defaultHidden)
|
||||
|
|
@ -87,6 +124,25 @@ export function useColumnVisibility(
|
|||
const hasUserPreference =
|
||||
typeof window !== 'undefined' && window.localStorage.getItem(storageKey) !== null;
|
||||
let persistedIdsMigrated = false;
|
||||
const appliedDefaultHiddenMigrations = hasUserPreference
|
||||
? readAppliedDefaultHiddenMigrations(storageKey, persistedIdAliases)
|
||||
: [];
|
||||
const pendingDefaultHiddenMigrations = hasUserPreference
|
||||
? Array.from(
|
||||
new Set(
|
||||
defaultHiddenMigrationIds
|
||||
.map((id) => id.trim())
|
||||
.filter(
|
||||
(id) =>
|
||||
id &&
|
||||
effectiveDefaultHidden.includes(id) &&
|
||||
toggleableIds.includes(id) &&
|
||||
!appliedDefaultHiddenMigrations.includes(id),
|
||||
),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
let defaultHiddenMigrationsPersisted = false;
|
||||
|
||||
// Persist hidden columns to localStorage
|
||||
// Use defaultHidden only if no user preference exists yet
|
||||
|
|
@ -100,6 +156,11 @@ export function useColumnVisibility(
|
|||
const parsed = JSON.parse(str);
|
||||
const normalized = normalizePersistedColumnIds(parsed, persistedIdAliases);
|
||||
persistedIdsMigrated = normalized.migrated;
|
||||
for (const id of pendingDefaultHiddenMigrations) {
|
||||
if (normalized.ids.includes(id)) continue;
|
||||
normalized.ids.push(id);
|
||||
persistedIdsMigrated = true;
|
||||
}
|
||||
return normalized.ids;
|
||||
} catch {
|
||||
return [];
|
||||
|
|
@ -109,8 +170,19 @@ export function useColumnVisibility(
|
|||
);
|
||||
|
||||
createEffect(() => {
|
||||
if (!hasUserPreference || !persistedIdsMigrated) return;
|
||||
const hasUnpersistedDefaultHiddenMigrations =
|
||||
pendingDefaultHiddenMigrations.length > 0 && !defaultHiddenMigrationsPersisted;
|
||||
if (!hasUserPreference || (!persistedIdsMigrated && !hasUnpersistedDefaultHiddenMigrations)) {
|
||||
return;
|
||||
}
|
||||
persistedIdsMigrated = false;
|
||||
if (hasUnpersistedDefaultHiddenMigrations) {
|
||||
defaultHiddenMigrationsPersisted = true;
|
||||
persistAppliedDefaultHiddenMigrations(storageKey, [
|
||||
...appliedDefaultHiddenMigrations,
|
||||
...pendingDefaultHiddenMigrations,
|
||||
]);
|
||||
}
|
||||
setHiddenColumns([...hiddenColumns()]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
ResourceCephMeta,
|
||||
ResourceChange,
|
||||
ResourceFacetCounts,
|
||||
ResourceDiscoveryReadiness,
|
||||
ResourceDiscoveryTarget,
|
||||
ResourceDockerMeta,
|
||||
ResourceMetricsTarget,
|
||||
|
|
@ -460,6 +461,7 @@ type APIResource = {
|
|||
resourceId?: string;
|
||||
hostname?: string;
|
||||
};
|
||||
discoveryReadiness?: ResourceDiscoveryReadiness;
|
||||
metricsTarget?: {
|
||||
resourceType?: string;
|
||||
resourceId?: string;
|
||||
|
|
@ -864,6 +866,7 @@ const toResource = (v2: APIResource): Resource => {
|
|||
ips: v2.identity?.ipAddresses,
|
||||
},
|
||||
discoveryTarget,
|
||||
discoveryReadiness: v2.discoveryReadiness,
|
||||
metricsTarget,
|
||||
canonicalIdentity: v2.canonicalIdentity,
|
||||
policy: v2.policy as Resource['policy'],
|
||||
|
|
@ -888,6 +891,7 @@ const toResource = (v2: APIResource): Resource => {
|
|||
ceph: v2.ceph,
|
||||
metrics: v2.metrics,
|
||||
discoveryTarget: v2.discoveryTarget,
|
||||
discoveryReadiness: v2.discoveryReadiness,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
getPreferredResourceKubernetesContext,
|
||||
} from '@/utils/resourceIdentity';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import type { ResourceDiscoveryTarget } from '@/types/resource';
|
||||
import type { ResourceDiscoveryReadiness, ResourceDiscoveryTarget } from '@/types/resource';
|
||||
|
||||
const WORKLOADS_URL = '/api/resources?type=vm,system-container,app-container,pod';
|
||||
const WORKLOADS_PAGE_LIMIT = 200;
|
||||
|
|
@ -147,6 +147,7 @@ type APIResource = {
|
|||
resourceId?: string;
|
||||
hostname?: string;
|
||||
};
|
||||
discoveryReadiness?: ResourceDiscoveryReadiness;
|
||||
};
|
||||
|
||||
type APIListResponse = {
|
||||
|
|
@ -540,6 +541,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
|
|||
platformType,
|
||||
platformScopes,
|
||||
discoveryTarget,
|
||||
discoveryReadiness: resource.discoveryReadiness,
|
||||
vmware:
|
||||
platformType === 'vmware-vsphere' && resource.vmware
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -161,6 +161,33 @@ export interface ResourceDiscoveryTarget {
|
|||
hostname?: string;
|
||||
}
|
||||
|
||||
export type ResourceDiscoveryReadinessState =
|
||||
| 'fresh'
|
||||
| 'stale'
|
||||
| 'missing'
|
||||
| 'running'
|
||||
| 'failed'
|
||||
| 'unavailable'
|
||||
| 'unsupported';
|
||||
|
||||
export interface ResourceDiscoveryReadiness {
|
||||
state: ResourceDiscoveryReadinessState;
|
||||
reason?: string;
|
||||
source?: string;
|
||||
resourceType?: string;
|
||||
targetId?: string;
|
||||
resourceId?: string;
|
||||
discoveryId?: string;
|
||||
observedAt?: string;
|
||||
generatedAt?: string;
|
||||
ageSeconds?: number;
|
||||
staleAfterSeconds?: number;
|
||||
factCount?: number;
|
||||
serviceName?: string;
|
||||
serviceCategory?: string;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
export interface ResourceCanonicalIdentity {
|
||||
displayName?: string;
|
||||
hostname?: string;
|
||||
|
|
@ -1336,6 +1363,7 @@ export interface Resource {
|
|||
|
||||
// Canonical discovery request coordinates from backend
|
||||
discoveryTarget?: ResourceDiscoveryTarget;
|
||||
discoveryReadiness?: ResourceDiscoveryReadiness;
|
||||
|
||||
// Metrics history query coordinates from backend
|
||||
metricsTarget?: ResourceMetricsTarget;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { VM, Container, DockerContainerUpdateStatus } from './api';
|
||||
import type { ResourceDiscoveryTarget } from './resource';
|
||||
import type { ResourceDiscoveryReadiness, ResourceDiscoveryTarget } from './resource';
|
||||
|
||||
export type WorkloadType = 'vm' | 'system-container' | 'app-container' | 'pod';
|
||||
export type WorkloadContainerViewMode = 'container' | 'system-container' | 'app-container';
|
||||
|
|
@ -32,6 +32,7 @@ export type WorkloadGuest = (VM | Container) & {
|
|||
// Canonical discovery ownership. API-backed platforms such as TrueNAS should
|
||||
// only expose Discovery when the unified resource contract supplies a target.
|
||||
discoveryTarget?: ResourceDiscoveryTarget;
|
||||
discoveryReadiness?: ResourceDiscoveryReadiness;
|
||||
// vSphere placement context surfaced in the workload drawer's vSphere
|
||||
// card. Only populated for resources with platformScopes containing
|
||||
// `vmware-vsphere`. Kept narrow on purpose — operational state like
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
formatDiscoveryReadinessBriefingLine,
|
||||
formatReadinessAge,
|
||||
getDiscoveryReadinessPresentation,
|
||||
} from '@/utils/resourceDiscoveryReadiness';
|
||||
|
||||
describe('resourceDiscoveryReadiness', () => {
|
||||
it('formats age labels without depending on wall-clock time', () => {
|
||||
expect(formatReadinessAge(30)).toBe('under a minute old');
|
||||
expect(formatReadinessAge(60)).toBe('1 minute old');
|
||||
expect(formatReadinessAge(3600)).toBe('1 hour old');
|
||||
expect(formatReadinessAge(86400)).toBe('1 day old');
|
||||
});
|
||||
|
||||
it('presents fresh discovery with service and fact detail', () => {
|
||||
const presentation = getDiscoveryReadinessPresentation({
|
||||
state: 'fresh',
|
||||
reason: 'Discovery data is within the configured freshness window.',
|
||||
serviceName: 'Home Assistant',
|
||||
factCount: 7,
|
||||
ageSeconds: 120,
|
||||
});
|
||||
|
||||
expect(presentation?.statusLabel).toBe('Discovery fresh');
|
||||
expect(presentation?.tone).toBe('success');
|
||||
expect(presentation?.detail).toContain('Home Assistant');
|
||||
expect(presentation?.detail).toContain('7 facts');
|
||||
expect(presentation?.detail).toContain('2 minutes old');
|
||||
});
|
||||
|
||||
it('presents missing discovery as actionable absence', () => {
|
||||
const presentation = getDiscoveryReadinessPresentation({
|
||||
state: 'missing',
|
||||
reason: 'Discovery has not run for this resource.',
|
||||
});
|
||||
|
||||
expect(presentation?.shortLabel).toBe('None');
|
||||
expect(presentation?.tone).toBe('muted');
|
||||
expect(presentation?.title).toContain('Discovery has not run');
|
||||
});
|
||||
|
||||
it('renders the Assistant briefing line from readiness metadata', () => {
|
||||
expect(
|
||||
formatDiscoveryReadinessBriefingLine({
|
||||
state: 'stale',
|
||||
serviceName: 'Home Assistant',
|
||||
factCount: 5,
|
||||
}),
|
||||
).toBe('Discovery data: Discovery stale, service Home Assistant, 5 facts');
|
||||
});
|
||||
});
|
||||
|
|
@ -828,4 +828,37 @@ describe('resourceStateAdapters nodeFromResource', () => {
|
|||
);
|
||||
expect(agent.agent?.unraid?.risk?.reasons?.[0]?.code).toBe('unraid_no_parity');
|
||||
});
|
||||
|
||||
it('preserves discovery readiness when merging realtime resource snapshots', () => {
|
||||
const [resource] = mergeCanonicalResourceSnapshot(
|
||||
[
|
||||
{
|
||||
id: 'system-container-homeassistant',
|
||||
type: 'system-container',
|
||||
name: 'homeassistant',
|
||||
displayName: 'homeassistant',
|
||||
platformId: '101',
|
||||
platformType: 'proxmox-pve',
|
||||
sourceType: 'api',
|
||||
status: 'online',
|
||||
lastSeen: Date.now(),
|
||||
discoveryReadiness: {
|
||||
state: 'missing',
|
||||
reason: 'Discovery has not run for this resource.',
|
||||
resourceType: 'system-container',
|
||||
targetId: 'agent-delly',
|
||||
resourceId: '101',
|
||||
generatedAt: '2026-06-04T15:00:00Z',
|
||||
},
|
||||
} as Resource,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(resource.discoveryReadiness).toMatchObject({
|
||||
state: 'missing',
|
||||
targetId: 'agent-delly',
|
||||
resourceId: '101',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -295,10 +295,27 @@ describe('getDiscoveryResourceTypeForWorkload', () => {
|
|||
type: 'vm',
|
||||
node: 'pve1',
|
||||
vmid: 101,
|
||||
discoveryTarget: {
|
||||
resourceType: 'vm' as const,
|
||||
agentId: 'agent-pve1',
|
||||
resourceId: '101',
|
||||
},
|
||||
};
|
||||
expect(getDiscoveryResourceTypeForWorkload(guest)).toBe('vm');
|
||||
});
|
||||
|
||||
it('does not infer Proxmox workload discovery ownership from the node name', () => {
|
||||
const guest = {
|
||||
id: 'vm:pve1:101',
|
||||
instance: 'cluster-a',
|
||||
workloadType: 'vm' as const,
|
||||
type: 'vm',
|
||||
node: 'pve1',
|
||||
vmid: 101,
|
||||
};
|
||||
expect(getDiscoveryResourceTypeForWorkload(guest)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the canonical discovery resource type for Docker-managed app-container workloads', () => {
|
||||
const guest = {
|
||||
instance: 'docker-host-1',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import type { AIChatContext } from '@/stores/aiChat';
|
||||
import type { Resource, ResourceDiscoveryTarget } from '@/types/resource';
|
||||
import type { Resource, ResourceDiscoveryReadiness, ResourceDiscoveryTarget } from '@/types/resource';
|
||||
import {
|
||||
formatDiscoveryReadinessBriefingLine,
|
||||
getDiscoveryReadinessPresentation,
|
||||
} from '@/utils/resourceDiscoveryReadiness';
|
||||
import {
|
||||
getPreferredResourceDisplayName,
|
||||
getPrimaryResourceIdentity,
|
||||
|
|
@ -18,6 +22,7 @@ export interface ResourceAssistantContextTarget {
|
|||
parentName?: string;
|
||||
primaryIdentity?: string;
|
||||
discoveryTarget?: ResourceDiscoveryTarget | null;
|
||||
discoveryReadiness?: ResourceDiscoveryReadiness | null;
|
||||
}
|
||||
|
||||
export const buildResourceAssistantContextForTarget = (
|
||||
|
|
@ -25,6 +30,11 @@ export const buildResourceAssistantContextForTarget = (
|
|||
): AIChatContext => {
|
||||
const displayName = target.name || target.id;
|
||||
const subjectParts = compact([target.type, target.status, target.technology]);
|
||||
const readinessPresentation = getDiscoveryReadinessPresentation(
|
||||
target.discoveryReadiness,
|
||||
Boolean(target.discoveryTarget),
|
||||
);
|
||||
const readinessLine = formatDiscoveryReadinessBriefingLine(target.discoveryReadiness);
|
||||
const detailLines = compact([
|
||||
`Resource ID: ${target.id}`,
|
||||
target.primaryIdentity && target.primaryIdentity !== target.id
|
||||
|
|
@ -34,6 +44,7 @@ export const buildResourceAssistantContextForTarget = (
|
|||
target.discoveryTarget
|
||||
? `Discovery: ${target.discoveryTarget.resourceType}:${target.discoveryTarget.resourceId}`
|
||||
: '',
|
||||
readinessLine,
|
||||
]);
|
||||
|
||||
return {
|
||||
|
|
@ -49,7 +60,9 @@ export const buildResourceAssistantContextForTarget = (
|
|||
sourceLabel: 'Pulse resource context',
|
||||
title: displayName,
|
||||
subject: subjectParts.join(' / '),
|
||||
statusLabel: 'Read-only context attached',
|
||||
statusLabel: readinessPresentation
|
||||
? `Read-only context attached · ${readinessPresentation.statusLabel}`
|
||||
: 'Read-only context attached',
|
||||
detailLines,
|
||||
safetyNote: 'Approval required before any action.',
|
||||
},
|
||||
|
|
@ -79,4 +92,5 @@ export const buildResourceAssistantContext = (resource: Resource): AIChatContext
|
|||
parentName: resource.parentName,
|
||||
primaryIdentity: getPrimaryResourceIdentity(resource),
|
||||
discoveryTarget: resource.discoveryTarget,
|
||||
discoveryReadiness: resource.discoveryReadiness,
|
||||
});
|
||||
|
|
|
|||
137
frontend-modern/src/utils/resourceDiscoveryReadiness.ts
Normal file
137
frontend-modern/src/utils/resourceDiscoveryReadiness.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { formatDiscoveryAge } from '@/api/discovery';
|
||||
import type {
|
||||
ResourceDiscoveryReadiness,
|
||||
ResourceDiscoveryReadinessState,
|
||||
} from '@/types/resource';
|
||||
|
||||
export type DiscoveryReadinessTone = 'success' | 'warning' | 'info' | 'danger' | 'muted';
|
||||
|
||||
export interface DiscoveryReadinessPresentation {
|
||||
state: ResourceDiscoveryReadinessState | 'unknown';
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
statusLabel: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
tone: DiscoveryReadinessTone;
|
||||
}
|
||||
|
||||
const STATE_COPY: Record<
|
||||
ResourceDiscoveryReadinessState,
|
||||
Pick<DiscoveryReadinessPresentation, 'label' | 'shortLabel' | 'statusLabel' | 'tone'>
|
||||
> = {
|
||||
fresh: {
|
||||
label: 'Discovery fresh',
|
||||
shortLabel: 'Fresh',
|
||||
statusLabel: 'Discovery fresh',
|
||||
tone: 'success',
|
||||
},
|
||||
stale: {
|
||||
label: 'Discovery stale',
|
||||
shortLabel: 'Stale',
|
||||
statusLabel: 'Discovery stale',
|
||||
tone: 'warning',
|
||||
},
|
||||
missing: {
|
||||
label: 'Not discovered',
|
||||
shortLabel: 'None',
|
||||
statusLabel: 'No discovery data',
|
||||
tone: 'muted',
|
||||
},
|
||||
running: {
|
||||
label: 'Discovery running',
|
||||
shortLabel: 'Running',
|
||||
statusLabel: 'Discovery running',
|
||||
tone: 'info',
|
||||
},
|
||||
failed: {
|
||||
label: 'Discovery failed',
|
||||
shortLabel: 'Failed',
|
||||
statusLabel: 'Discovery failed',
|
||||
tone: 'danger',
|
||||
},
|
||||
unavailable: {
|
||||
label: 'Discovery unavailable',
|
||||
shortLabel: 'Unavailable',
|
||||
statusLabel: 'Discovery unavailable',
|
||||
tone: 'warning',
|
||||
},
|
||||
unsupported: {
|
||||
label: 'Not supported',
|
||||
shortLabel: 'N/A',
|
||||
statusLabel: 'Discovery unsupported',
|
||||
tone: 'muted',
|
||||
},
|
||||
};
|
||||
|
||||
export const formatReadinessAge = (seconds?: number): string => {
|
||||
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return '';
|
||||
if (seconds < 60) return 'under a minute old';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} old`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} old`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} day${days === 1 ? '' : 's'} old`;
|
||||
};
|
||||
|
||||
const observedLabel = (readiness: ResourceDiscoveryReadiness): string => {
|
||||
if (readiness.observedAt) return formatDiscoveryAge(readiness.observedAt);
|
||||
return formatReadinessAge(readiness.ageSeconds);
|
||||
};
|
||||
|
||||
export const getDiscoveryReadinessPresentation = (
|
||||
readiness?: ResourceDiscoveryReadiness | null,
|
||||
hasDiscoverySupport = true,
|
||||
): DiscoveryReadinessPresentation | null => {
|
||||
if (!readiness) {
|
||||
if (!hasDiscoverySupport) return null;
|
||||
return {
|
||||
state: 'unknown',
|
||||
label: 'Discovery unknown',
|
||||
shortLabel: 'Unknown',
|
||||
statusLabel: 'Discovery unknown',
|
||||
title: 'Discovery status is not available for this resource yet.',
|
||||
detail: 'Discovery status unavailable',
|
||||
tone: 'muted',
|
||||
};
|
||||
}
|
||||
|
||||
const copy = STATE_COPY[readiness.state] ?? {
|
||||
label: 'Discovery unknown',
|
||||
shortLabel: 'Unknown',
|
||||
statusLabel: 'Discovery unknown',
|
||||
tone: 'muted' as const,
|
||||
};
|
||||
const details = [
|
||||
readiness.reason,
|
||||
readiness.serviceName ? `Service: ${readiness.serviceName}` : '',
|
||||
readiness.factCount && readiness.factCount > 0 ? `${readiness.factCount} facts` : '',
|
||||
observedLabel(readiness) ? `Observed ${observedLabel(readiness)}` : '',
|
||||
].filter((value): value is string => Boolean(value && value.trim()));
|
||||
const detail = details.join(' · ');
|
||||
|
||||
return {
|
||||
state: readiness.state,
|
||||
label: copy.label,
|
||||
shortLabel: copy.shortLabel,
|
||||
statusLabel: copy.statusLabel,
|
||||
title: detail ? `${copy.label}: ${detail}` : copy.label,
|
||||
detail,
|
||||
tone: copy.tone,
|
||||
};
|
||||
};
|
||||
|
||||
export const formatDiscoveryReadinessBriefingLine = (
|
||||
readiness?: ResourceDiscoveryReadiness | null,
|
||||
): string => {
|
||||
const presentation = getDiscoveryReadinessPresentation(readiness, Boolean(readiness));
|
||||
if (!presentation) return '';
|
||||
const details = [
|
||||
presentation.statusLabel,
|
||||
readiness?.serviceName ? `service ${readiness.serviceName}` : '',
|
||||
readiness?.observedAt ? `observed ${formatDiscoveryAge(readiness.observedAt)}` : '',
|
||||
readiness?.factCount && readiness.factCount > 0 ? `${readiness.factCount} facts` : '',
|
||||
].filter((value): value is string => Boolean(value && value.trim()));
|
||||
return details.length > 0 ? `Discovery data: ${details.join(', ')}` : '';
|
||||
};
|
||||
|
|
@ -152,7 +152,14 @@ const mergePlatformData = (
|
|||
}
|
||||
}
|
||||
|
||||
for (const key of ['storage', 'physicalDisk', 'ceph', 'metrics', 'discoveryTarget']) {
|
||||
for (const key of [
|
||||
'storage',
|
||||
'physicalDisk',
|
||||
'ceph',
|
||||
'metrics',
|
||||
'discoveryTarget',
|
||||
'discoveryReadiness',
|
||||
]) {
|
||||
const nested = mergeRecord(asRecord(incoming[key]), asRecord(existing[key]));
|
||||
if (nested) {
|
||||
merged[key] = nested;
|
||||
|
|
@ -679,6 +686,7 @@ export const mergeCanonicalResource = (incoming: Resource, existing?: Resource):
|
|||
incoming.platformType ?? existingCanonical.platformType,
|
||||
),
|
||||
discoveryTarget: incoming.discoveryTarget ?? existingCanonical.discoveryTarget,
|
||||
discoveryReadiness: incoming.discoveryReadiness ?? existingCanonical.discoveryReadiness,
|
||||
metricsTarget: incoming.metricsTarget ?? existingCanonical.metricsTarget,
|
||||
canonicalIdentity: mergeCanonicalIdentity(
|
||||
incoming.canonicalIdentity,
|
||||
|
|
|
|||
|
|
@ -235,15 +235,6 @@ export const resolveDiscoveryTargetForWorkload = (
|
|||
const resourceId = (match?.[1] || rawId).trim();
|
||||
return agentId && resourceId ? { resourceType: 'pod', agentId, resourceId } : null;
|
||||
}
|
||||
if (
|
||||
(type === 'vm' || type === 'system-container') &&
|
||||
Number.isFinite(guest.vmid) &&
|
||||
guest.vmid > 0
|
||||
) {
|
||||
const agentId = (guest.node || '').trim();
|
||||
const resourceId = String(guest.vmid);
|
||||
return agentId ? { resourceType: type, agentId, resourceId } : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -73,11 +73,12 @@ const (
|
|||
agentContextTrustDiscovered = "discovered"
|
||||
agentContextTrustUserTaught = "user-taught"
|
||||
|
||||
agentContextSourceUnifiedResource = "unified-resource"
|
||||
agentContextSourceResourceStore = "resource-store"
|
||||
agentContextSourceOperatorState = "operator-state"
|
||||
agentContextSourcePatrol = "patrol"
|
||||
agentContextSourceActionAudit = "action-audit"
|
||||
agentContextSourceUnifiedResource = "unified-resource"
|
||||
agentContextSourceResourceStore = "resource-store"
|
||||
agentContextSourceServiceDiscovery = "service-discovery"
|
||||
agentContextSourceOperatorState = "operator-state"
|
||||
agentContextSourcePatrol = "patrol"
|
||||
agentContextSourceActionAudit = "action-audit"
|
||||
)
|
||||
|
||||
// BuildResourceContextSections returns the canonical resource context
|
||||
|
|
@ -324,6 +325,7 @@ func buildAgentRuntimeContextSection(resource unified.Resource, now time.Time) S
|
|||
addAgentContextFact(§ion.Facts, "Discovery resource", unified.ResourcePolicyRedactedValue(resource.DiscoveryTarget.ResourceID, policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||
addAgentContextFact(§ion.Facts, "Discovery hostname", unified.ResourcePolicyRedactedValue(resource.DiscoveryTarget.Hostname, policy, unified.ResourceRedactionHostname), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||
}
|
||||
addDiscoveryReadinessFacts(§ion.Facts, resource, now)
|
||||
|
||||
addMetricsFacts(§ion.Facts, resource, observedAt)
|
||||
addAgentDataFacts(§ion.Facts, resource, observedAt)
|
||||
|
|
@ -428,6 +430,43 @@ func buildAgentRecentChangesContextSection(resource unified.Resource, store unif
|
|||
return section
|
||||
}
|
||||
|
||||
func addDiscoveryReadinessFacts(facts *[]Fact, resource unified.Resource, now time.Time) {
|
||||
if resource.DiscoveryReadiness == nil {
|
||||
return
|
||||
}
|
||||
readiness := *resource.DiscoveryReadiness
|
||||
observedAt := readiness.ObservedAt
|
||||
addAgentContextFact(facts, "Discovery readiness", formatDiscoveryReadinessFact(readiness), agentContextSourceServiceDiscovery, agentContextTrustPulseAuthored, observedAt)
|
||||
if readiness.ServiceName != "" {
|
||||
addAgentContextFact(facts, "Discovered service", unified.ResourcePolicyRedactedValue(readiness.ServiceName, resource.Policy, unified.ResourceRedactionHostname, unified.ResourceRedactionAlias), agentContextSourceServiceDiscovery, agentContextTrustDiscovered, observedAt)
|
||||
}
|
||||
addAgentContextFact(facts, "Discovery category", readiness.ServiceCategory, agentContextSourceServiceDiscovery, agentContextTrustDiscovered, observedAt)
|
||||
addAgentContextCountFact(facts, "Discovery facts", readiness.FactCount, agentContextSourceServiceDiscovery, agentContextTrustDiscovered, observedAt)
|
||||
if readiness.Confidence > 0 {
|
||||
addAgentContextFact(facts, "Discovery confidence", fmt.Sprintf("%.0f%%", readiness.Confidence*100), agentContextSourceServiceDiscovery, agentContextTrustDiscovered, observedAt)
|
||||
}
|
||||
if readiness.StaleAfterSeconds > 0 {
|
||||
addAgentContextFact(facts, "Discovery stale after", fmt.Sprintf("%ds", readiness.StaleAfterSeconds), agentContextSourceServiceDiscovery, agentContextTrustPulseAuthored, &now)
|
||||
}
|
||||
}
|
||||
|
||||
func formatDiscoveryReadinessFact(readiness unified.ResourceDiscoveryReadiness) string {
|
||||
parts := []string{string(readiness.State)}
|
||||
if readiness.Reason != "" {
|
||||
parts = append(parts, readiness.Reason)
|
||||
}
|
||||
if readiness.ObservedAt != nil && !readiness.ObservedAt.IsZero() {
|
||||
parts = append(parts, "observed="+readiness.ObservedAt.UTC().Format(time.RFC3339))
|
||||
}
|
||||
if readiness.AgeSeconds > 0 {
|
||||
parts = append(parts, fmt.Sprintf("age=%ds", readiness.AgeSeconds))
|
||||
}
|
||||
if readiness.StaleAfterSeconds > 0 {
|
||||
parts = append(parts, fmt.Sprintf("staleAfter=%ds", readiness.StaleAfterSeconds))
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func addMetricsFacts(facts *[]Fact, resource unified.Resource, observedAt *time.Time) {
|
||||
if resource.Metrics == nil {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -19,6 +19,17 @@ func TestBuildResourceContextSectionsAppliesRedactionTrustAndFreshness(t *testin
|
|||
resource := sensitiveTestResource(now, observedAt)
|
||||
resource.ParentID = &parentID
|
||||
resource.ParentName = "prod-node"
|
||||
resource.DiscoveryReadiness = &unified.ResourceDiscoveryReadiness{
|
||||
State: unified.ResourceDiscoveryReadinessFresh,
|
||||
Source: "service-discovery",
|
||||
ObservedAt: &observedAt,
|
||||
AgeSeconds: 60,
|
||||
StaleAfterSeconds: int64((30 * 24 * time.Hour).Seconds()),
|
||||
FactCount: 4,
|
||||
ServiceName: "finance-db.internal",
|
||||
ServiceCategory: "database",
|
||||
Confidence: 0.84,
|
||||
}
|
||||
resource.Proxmox = &unified.ProxmoxData{
|
||||
NodeName: "prod-node",
|
||||
ClusterName: "prod-west",
|
||||
|
|
@ -95,6 +106,19 @@ func TestBuildResourceContextSectionsAppliesRedactionTrustAndFreshness(t *testin
|
|||
t.Fatalf("%s fact = %#v, want redacted by policy", label, fact)
|
||||
}
|
||||
}
|
||||
readiness := requireFact(t, runtime, "Discovery readiness")
|
||||
if readiness.Source != agentContextSourceServiceDiscovery || readiness.TrustTier != agentContextTrustPulseAuthored {
|
||||
t.Fatalf("readiness provenance = source %q trust %q", readiness.Source, readiness.TrustTier)
|
||||
}
|
||||
if !strings.Contains(readiness.Value, "fresh") || !strings.Contains(readiness.Value, "staleAfter=") {
|
||||
t.Fatalf("readiness value = %q", readiness.Value)
|
||||
}
|
||||
if service := requireFact(t, runtime, "Discovered service"); service.Value != unified.ResourcePolicyRedactedLabel || !service.Redacted {
|
||||
t.Fatalf("discovered service fact = %#v, want redacted by policy", service)
|
||||
}
|
||||
if factCount := requireFact(t, runtime, "Discovery facts"); factCount.Value != "4" || factCount.TrustTier != agentContextTrustDiscovered {
|
||||
t.Fatalf("discovery facts = %#v, want discovered count", factCount)
|
||||
}
|
||||
if risk := requireFact(t, runtime, "Risk summary"); !strings.Contains(risk.Value, unified.ResourcePolicyRedactedLabel) {
|
||||
t.Fatalf("risk summary = %q, want redacted label", risk.Value)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -283,7 +283,7 @@ func TestResourceContextHandoffScenarioUsesConfiguredResource(t *testing.T) {
|
|||
|
||||
scenario := ResourceContextHandoffScenario()
|
||||
assert.Equal(t, "Resource Context Handoff", scenario.Name)
|
||||
if assert.Len(t, scenario.Steps, 4) {
|
||||
if assert.Len(t, scenario.Steps, 5) {
|
||||
first := scenario.Steps[0]
|
||||
if assert.Len(t, first.HandoffResources, 1) {
|
||||
assert.Equal(t, StepHandoffResource{
|
||||
|
|
@ -296,6 +296,7 @@ func TestResourceContextHandoffScenarioUsesConfiguredResource(t *testing.T) {
|
|||
assert.Equal(t, StepHandoffMetadata{Kind: "resource_context"}, first.HandoffMetadata)
|
||||
assert.NotNil(t, first.AutonomousMode)
|
||||
assert.False(t, *first.AutonomousMode)
|
||||
assert.Equal(t, "Report discovery readiness from context", scenario.Steps[1].Name)
|
||||
assert.Empty(t, scenario.Steps[1].HandoffResources, "later steps should exercise persisted session handoff")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -446,6 +446,19 @@ func ResourceContextHandoffScenario() Scenario {
|
|||
AssertContentOmitsAll(forbidden...),
|
||||
}, noUnsafeActions...),
|
||||
},
|
||||
{
|
||||
Name: "Report discovery readiness from context",
|
||||
Prompt: "Before using any tools, tell me whether Pulse has fresh, stale, missing, running, unavailable, or unsupported discovery data for this attached resource, and what that means for how much you can answer from context.",
|
||||
AutonomousMode: &interactive,
|
||||
Assertions: append([]Assertion{
|
||||
AssertNoError(),
|
||||
AssertHasContent(),
|
||||
AssertToolNotUsed("pulse_discovery"),
|
||||
AssertContentContainsAny("discovery", "fresh", "stale", "missing", "running", "unavailable", "unsupported"),
|
||||
AssertMaxToolCalls(0),
|
||||
AssertContentOmitsAll(forbidden...),
|
||||
}, noUnsafeActions...),
|
||||
},
|
||||
{
|
||||
Name: "Respect action boundary",
|
||||
Prompt: "Can you SSH into it and change the Home Assistant automations? If that is not safe from this context, explain the governed next step.",
|
||||
|
|
|
|||
|
|
@ -172,6 +172,10 @@ type AgentResourceContextRedaction = agentcontext.Redaction
|
|||
// older clients that parse the original top-level fields.
|
||||
type AgentResourceContextSection = agentcontext.Section
|
||||
|
||||
// AgentResourceDiscoveryReadiness is the agent-stable projection of
|
||||
// service-discovery freshness already carried on resource API payloads.
|
||||
type AgentResourceDiscoveryReadiness = unified.ResourceDiscoveryReadiness
|
||||
|
||||
// AgentResourceContext is the bundled, agent-consumable situated
|
||||
// picture of a resource. One read returns:
|
||||
// - core identity (canonical id, type, name)
|
||||
|
|
@ -188,16 +192,17 @@ type AgentResourceContextSection = agentcontext.Section
|
|||
// detail (full finding records, full audit history) remains available
|
||||
// via the existing per-finding / per-audit endpoints.
|
||||
type AgentResourceContext struct {
|
||||
CanonicalID string `json:"canonicalId"`
|
||||
ResourceType string `json:"resourceType"`
|
||||
ResourceName string `json:"resourceName"`
|
||||
Technology string `json:"technology,omitempty"`
|
||||
OperatorState *AgentResourceOperatorState `json:"operatorState,omitempty"`
|
||||
ActiveFindings []AgentResourceFindingSnapshot `json:"activeFindings"`
|
||||
PendingApprovals []AgentResourceApprovalSummary `json:"pendingApprovals"`
|
||||
RecentActions []AgentResourceActionSummary `json:"recentActions"`
|
||||
ContextSections []AgentResourceContextSection `json:"contextSections"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
CanonicalID string `json:"canonicalId"`
|
||||
ResourceType string `json:"resourceType"`
|
||||
ResourceName string `json:"resourceName"`
|
||||
Technology string `json:"technology,omitempty"`
|
||||
OperatorState *AgentResourceOperatorState `json:"operatorState,omitempty"`
|
||||
DiscoveryReadiness *AgentResourceDiscoveryReadiness `json:"discoveryReadiness,omitempty"`
|
||||
ActiveFindings []AgentResourceFindingSnapshot `json:"activeFindings"`
|
||||
PendingApprovals []AgentResourceApprovalSummary `json:"pendingApprovals"`
|
||||
RecentActions []AgentResourceActionSummary `json:"recentActions"`
|
||||
ContextSections []AgentResourceContextSection `json:"contextSections"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
}
|
||||
|
||||
// AgentFindingsProvider returns the active findings for a resource as
|
||||
|
|
@ -309,7 +314,7 @@ func (h *AgentContextHandler) HandleResourceContext(w http.ResponseWriter, r *ht
|
|||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
resource, resourceID, ok := registry.GetByReference(resourceID)
|
||||
resource, resourceID, ok := presentationResourceByReference(registry, resourceID)
|
||||
if !ok {
|
||||
writeJSONError(w, http.StatusNotFound, "resource_not_found",
|
||||
"No resource is registered with this canonical id.")
|
||||
|
|
@ -322,16 +327,22 @@ func (h *AgentContextHandler) HandleResourceContext(w http.ResponseWriter, r *ht
|
|||
return
|
||||
}
|
||||
|
||||
generatedAt := time.Now().UTC()
|
||||
resourceCopy := *resource
|
||||
attachDiscoveryTarget(&resourceCopy)
|
||||
h.resources.attachDiscoveryReadiness(&resourceCopy, generatedAt)
|
||||
|
||||
bundle := AgentResourceContext{
|
||||
CanonicalID: resourceID,
|
||||
ResourceType: string(resource.Type),
|
||||
ResourceName: resource.Name,
|
||||
Technology: resource.Technology,
|
||||
ActiveFindings: []AgentResourceFindingSnapshot{},
|
||||
PendingApprovals: []AgentResourceApprovalSummary{},
|
||||
RecentActions: []AgentResourceActionSummary{},
|
||||
ContextSections: []AgentResourceContextSection{},
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
CanonicalID: resourceID,
|
||||
ResourceType: string(resourceCopy.Type),
|
||||
ResourceName: resourceCopy.Name,
|
||||
Technology: resourceCopy.Technology,
|
||||
DiscoveryReadiness: resourceCopy.DiscoveryReadiness,
|
||||
ActiveFindings: []AgentResourceFindingSnapshot{},
|
||||
PendingApprovals: []AgentResourceApprovalSummary{},
|
||||
RecentActions: []AgentResourceActionSummary{},
|
||||
ContextSections: []AgentResourceContextSection{},
|
||||
GeneratedAt: generatedAt,
|
||||
}
|
||||
|
||||
// Operator-set state — single point lookup.
|
||||
|
|
@ -376,7 +387,7 @@ func (h *AgentContextHandler) HandleResourceContext(w http.ResponseWriter, r *ht
|
|||
}
|
||||
}
|
||||
|
||||
bundle.ContextSections = buildAgentResourceContextSections(*resource, store, bundle)
|
||||
bundle.ContextSections = buildAgentResourceContextSections(resourceCopy, store, bundle)
|
||||
|
||||
redactAgentResourceContextCommandsForRequest(&bundle, r)
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,39 @@ func (p *countingApprovalsProvider) PendingApprovalCountsByResource(orgID string
|
|||
return p.staticApprovalsProvider.PendingApprovalCountsByResource(orgID)
|
||||
}
|
||||
|
||||
type staticResourceDiscoveryReadinessProvider struct {
|
||||
byResource map[string]unified.ResourceDiscoveryReadiness
|
||||
}
|
||||
|
||||
func (p staticResourceDiscoveryReadinessProvider) DiscoveryReadinessForResource(resource unified.Resource, now time.Time) unified.ResourceDiscoveryReadiness {
|
||||
if readiness, ok := p.byResource[unified.CanonicalResourceID(resource.ID)]; ok {
|
||||
if readiness.GeneratedAt.IsZero() {
|
||||
readiness.GeneratedAt = now
|
||||
}
|
||||
if readiness.ResourceType == "" && resource.DiscoveryTarget != nil {
|
||||
readiness.ResourceType = resource.DiscoveryTarget.ResourceType
|
||||
}
|
||||
if readiness.TargetID == "" && resource.DiscoveryTarget != nil {
|
||||
readiness.TargetID = resource.DiscoveryTarget.AgentID
|
||||
}
|
||||
if readiness.ResourceID == "" && resource.DiscoveryTarget != nil {
|
||||
readiness.ResourceID = resource.DiscoveryTarget.ResourceID
|
||||
}
|
||||
return readiness
|
||||
}
|
||||
readiness := unified.ResourceDiscoveryReadiness{
|
||||
State: unified.ResourceDiscoveryReadinessMissing,
|
||||
Source: "service-discovery",
|
||||
GeneratedAt: now,
|
||||
}
|
||||
if resource.DiscoveryTarget != nil {
|
||||
readiness.ResourceType = resource.DiscoveryTarget.ResourceType
|
||||
readiness.TargetID = resource.DiscoveryTarget.AgentID
|
||||
readiness.ResourceID = resource.DiscoveryTarget.ResourceID
|
||||
}
|
||||
return readiness
|
||||
}
|
||||
|
||||
// agentContextFixtureHandlers wires a ResourceHandlers around a
|
||||
// pre-built unified-resource seed (using the same
|
||||
// `resourceUnifiedSeedProvider` pattern from resources_test.go) plus
|
||||
|
|
@ -231,6 +264,75 @@ func TestHandleAgentResourceContext_BundlesIdentityOperatorStateAndFindings(t *t
|
|||
}
|
||||
}
|
||||
|
||||
func TestHandleAgentResourceContext_IncludesDiscoveryReadiness(t *testing.T) {
|
||||
observedAt := time.Date(2026, 6, 4, 10, 30, 0, 0, time.UTC)
|
||||
resourceID := "vm:node-a:101"
|
||||
h, _ := agentContextFixtureHandlersForResource(t, unified.Resource{
|
||||
ID: resourceID,
|
||||
Type: unified.ResourceTypeVM,
|
||||
Name: "homeassistant",
|
||||
Status: unified.StatusOnline,
|
||||
LastSeen: observedAt,
|
||||
UpdatedAt: observedAt,
|
||||
Proxmox: &unified.ProxmoxData{
|
||||
NodeName: "node-a",
|
||||
VMID: 101,
|
||||
LinkedAgentID: "agent-a",
|
||||
},
|
||||
})
|
||||
h.resources.SetDiscoveryReadinessProvider(staticResourceDiscoveryReadinessProvider{
|
||||
byResource: map[string]unified.ResourceDiscoveryReadiness{
|
||||
resourceID: {
|
||||
State: unified.ResourceDiscoveryReadinessFresh,
|
||||
Source: "service-discovery",
|
||||
ObservedAt: &observedAt,
|
||||
AgeSeconds: 120,
|
||||
StaleAfterSeconds: int64((30 * 24 * time.Hour).Seconds()),
|
||||
FactCount: 7,
|
||||
ServiceName: "Home Assistant",
|
||||
ServiceCategory: "home_automation",
|
||||
Confidence: 0.91,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-context/"+resourceID, nil)
|
||||
h.HandleResourceContext(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200; got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var bundle AgentResourceContext
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &bundle); err != nil {
|
||||
t.Fatalf("unmarshal bundle: %v", err)
|
||||
}
|
||||
if bundle.DiscoveryReadiness == nil || bundle.DiscoveryReadiness.State != unified.ResourceDiscoveryReadinessFresh {
|
||||
t.Fatalf("discovery readiness = %+v, want fresh", bundle.DiscoveryReadiness)
|
||||
}
|
||||
|
||||
runtimeSection, ok := findAgentContextSection(bundle.ContextSections, "runtime")
|
||||
if !ok {
|
||||
t.Fatal("runtime section missing")
|
||||
}
|
||||
readinessFact, ok := findAgentContextFact(runtimeSection, "Discovery readiness")
|
||||
if !ok {
|
||||
t.Fatal("Discovery readiness fact missing")
|
||||
}
|
||||
if !strings.Contains(readinessFact.Value, "fresh") || !strings.Contains(readinessFact.Value, "staleAfter=") {
|
||||
t.Fatalf("readiness fact value = %q", readinessFact.Value)
|
||||
}
|
||||
if readinessFact.Source != "service-discovery" || readinessFact.TrustTier != "pulse-authored" {
|
||||
t.Fatalf("readiness fact provenance = source %q trust %q", readinessFact.Source, readinessFact.TrustTier)
|
||||
}
|
||||
serviceFact, ok := findAgentContextFact(runtimeSection, "Discovered service")
|
||||
if !ok || serviceFact.TrustTier != "discovered" {
|
||||
t.Fatalf("discovered service fact = %+v", serviceFact)
|
||||
}
|
||||
if serviceFact.Value != "Home Assistant" && serviceFact.Value != unified.ResourcePolicyRedactedLabel {
|
||||
t.Fatalf("discovered service value = %q, want service name or policy redaction", serviceFact.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgentResourceContext_ResolvesSourceIDReference(t *testing.T) {
|
||||
h, _ := agentContextFixtureHandlersForResource(t, unified.Resource{
|
||||
ID: "system-container-6adaf34f529d241a",
|
||||
|
|
@ -273,6 +375,81 @@ func TestHandleAgentResourceContext_ResolvesSourceIDReference(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestHandleAgentResourceContext_UsesLinkedNodeAgentForProxmoxGuestDiscovery(t *testing.T) {
|
||||
parentID := "agent-delly"
|
||||
resources := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
||||
resources.SetStateProvider(resourceUnifiedSeedProvider{
|
||||
snapshot: models.StateSnapshot{LastUpdate: time.Now()},
|
||||
resources: []unified.Resource{
|
||||
{
|
||||
ID: parentID,
|
||||
Type: unified.ResourceTypeAgent,
|
||||
Name: "delly",
|
||||
Status: unified.StatusOnline,
|
||||
Sources: []unified.DataSource{
|
||||
unified.SourceAgent,
|
||||
},
|
||||
Agent: &unified.AgentData{
|
||||
AgentID: "agent-delly",
|
||||
Hostname: "delly",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "system-container-homeassistant",
|
||||
Type: unified.ResourceTypeSystemContainer,
|
||||
Name: "homeassistant",
|
||||
Status: unified.StatusOnline,
|
||||
ParentID: &parentID,
|
||||
Sources: []unified.DataSource{
|
||||
unified.SourceProxmox,
|
||||
},
|
||||
Proxmox: &unified.ProxmoxData{
|
||||
SourceID: "delly:delly:101",
|
||||
NodeName: "delly",
|
||||
Instance: "delly",
|
||||
VMID: 101,
|
||||
ContainerType: "lxc",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
resources.SetDiscoveryReadinessProvider(staticResourceDiscoveryReadinessProvider{})
|
||||
h := NewAgentContextHandler(resources)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-context/delly:delly:101", nil)
|
||||
h.HandleResourceContext(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200; got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var bundle AgentResourceContext
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &bundle); err != nil {
|
||||
t.Fatalf("unmarshal bundle: %v", err)
|
||||
}
|
||||
if bundle.CanonicalID != "system-container-homeassistant" {
|
||||
t.Fatalf("canonical id = %q, want system-container-homeassistant", bundle.CanonicalID)
|
||||
}
|
||||
if bundle.DiscoveryReadiness == nil {
|
||||
t.Fatal("expected discovery readiness in context bundle")
|
||||
}
|
||||
if bundle.DiscoveryReadiness.State != unified.ResourceDiscoveryReadinessMissing {
|
||||
t.Fatalf("readiness state = %q, want missing", bundle.DiscoveryReadiness.State)
|
||||
}
|
||||
if bundle.DiscoveryReadiness.ResourceType != "system-container" ||
|
||||
bundle.DiscoveryReadiness.TargetID != "agent-delly" ||
|
||||
bundle.DiscoveryReadiness.ResourceID != "101" {
|
||||
t.Fatalf("readiness target mismatch: %+v", bundle.DiscoveryReadiness)
|
||||
}
|
||||
runtime, ok := findAgentContextSection(bundle.ContextSections, "runtime")
|
||||
if !ok {
|
||||
t.Fatalf("expected runtime section in context bundle: %+v", bundle.ContextSections)
|
||||
}
|
||||
if _, ok := findAgentContextFact(runtime, "Discovery readiness"); !ok {
|
||||
t.Fatalf("runtime section must include discovery readiness fact: %+v", runtime.Facts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgentResourceContext_ContextSectionsIncludeRuntimeTopologyAndRecentChanges(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
parentID := "agent:pve1"
|
||||
|
|
|
|||
|
|
@ -11354,6 +11354,67 @@ func TestContract_ResourceListPolicyMetadata(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestContract_ProxmoxWorkloadDiscoveryTargetUsesLinkedNodeAgent(t *testing.T) {
|
||||
now := time.Date(2026, 6, 4, 20, 0, 0, 0, time.UTC)
|
||||
h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
||||
h.SetStateProvider(resourceUnifiedSeedProvider{
|
||||
snapshot: models.StateSnapshot{LastUpdate: now},
|
||||
resources: []unifiedresources.Resource{
|
||||
{
|
||||
ID: "system-container:grafana",
|
||||
Type: unifiedresources.ResourceTypeSystemContainer,
|
||||
Name: "grafana",
|
||||
Status: unifiedresources.StatusOnline,
|
||||
LastSeen: now,
|
||||
Sources: []unifiedresources.DataSource{unifiedresources.SourceProxmox},
|
||||
Proxmox: &unifiedresources.ProxmoxData{
|
||||
NodeName: "delly",
|
||||
VMID: 124,
|
||||
LinkedAgentID: "agent-delly",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "system-container:unlinked",
|
||||
Type: unifiedresources.ResourceTypeSystemContainer,
|
||||
Name: "unlinked",
|
||||
Status: unifiedresources.StatusOnline,
|
||||
LastSeen: now,
|
||||
Sources: []unifiedresources.DataSource{unifiedresources.SourceProxmox},
|
||||
Proxmox: &unifiedresources.ProxmoxData{
|
||||
NodeName: "delly",
|
||||
VMID: 125,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=system-container", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleListResources(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp ResourcesResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
resourcesByID := make(map[string]unifiedresources.Resource, len(resp.Data))
|
||||
for _, resource := range resp.Data {
|
||||
resourcesByID[resource.ID] = resource
|
||||
}
|
||||
linkedTarget := resourcesByID["system-container:grafana"].DiscoveryTarget
|
||||
if linkedTarget == nil {
|
||||
t.Fatal("expected linked Proxmox workload discovery target")
|
||||
}
|
||||
if linkedTarget.AgentID != "agent-delly" || linkedTarget.ResourceID != "124" {
|
||||
t.Fatalf("linked discovery target = %+v, want agent-delly/124", linkedTarget)
|
||||
}
|
||||
if target := resourcesByID["system-container:unlinked"].DiscoveryTarget; target != nil {
|
||||
t.Fatalf("expected unlinked Proxmox workload to omit discovery target, got %+v", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContract_ResourceListUsesDeterministicNameTieBreakers(t *testing.T) {
|
||||
now := time.Date(2026, 4, 11, 0, 0, 0, 0, time.UTC)
|
||||
h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
||||
|
|
@ -14408,16 +14469,12 @@ func TestContract_AgentResourceContextEndpointSurfacesStableShape(t *testing.T)
|
|||
}
|
||||
src := string(source)
|
||||
// Always-array initialization for the iteration-safe contract.
|
||||
if !strings.Contains(src, "ActiveFindings: []AgentResourceFindingSnapshot{},") {
|
||||
if !regexp.MustCompile(`ActiveFindings:\s+\[\]AgentResourceFindingSnapshot\{\},`).MatchString(src) {
|
||||
t.Error("activeFindings must default to an empty slice, not nil — agents iterate without nil-checks")
|
||||
}
|
||||
if !strings.Contains(src, "RecentActions: []AgentResourceActionSummary{},") {
|
||||
if !regexp.MustCompile(`RecentActions:\s+\[\]AgentResourceActionSummary\{\},`).MatchString(src) {
|
||||
t.Error("recentActions must default to an empty slice, not nil")
|
||||
}
|
||||
// Field-presence branching contract for operatorState.
|
||||
if !strings.Contains(src, "OperatorState *AgentResourceOperatorState `json:\"operatorState,omitempty\"`") {
|
||||
t.Error("operatorState must be omitempty so absent entries surface as missing field")
|
||||
}
|
||||
// Server-computed maintenance-active flag — server pre-computes so
|
||||
// agents don't re-evaluate timestamps client-side.
|
||||
if !strings.Contains(src, "MaintenanceWindowActive: state.IsInMaintenanceAt(now)") {
|
||||
|
|
@ -14431,19 +14488,44 @@ func TestContract_AgentResourceContextEndpointSurfacesStableShape(t *testing.T)
|
|||
// pendingApprovals must initialize as an empty slice so agents
|
||||
// iterate without nil-checking — same iteration-safe contract
|
||||
// as activeFindings and recentActions.
|
||||
if !strings.Contains(src, "PendingApprovals: []AgentResourceApprovalSummary{}") {
|
||||
if !regexp.MustCompile(`PendingApprovals:\s+\[\]AgentResourceApprovalSummary\{\},`).MatchString(src) {
|
||||
t.Error("pendingApprovals must default to an empty slice, not nil — agents iterate without nil-checks")
|
||||
}
|
||||
if !strings.Contains(src, "ContextSections: []AgentResourceContextSection{}") {
|
||||
if !regexp.MustCompile(`ContextSections:\s+\[\]AgentResourceContextSection\{\},`).MatchString(src) {
|
||||
t.Error("contextSections must default to an empty slice, not nil — resource-aware agents iterate without nil-checks")
|
||||
}
|
||||
// Pending approvals must reuse the AgentResourceApprovalSummary
|
||||
// shape so the bundle and approval.pending SSE events stay in
|
||||
// the same vocabulary.
|
||||
if !strings.Contains(src, "PendingApprovals []AgentResourceApprovalSummary `json:\"pendingApprovals\"`") {
|
||||
|
||||
contextType := reflect.TypeOf(AgentResourceContext{})
|
||||
operatorStateField, ok := contextType.FieldByName("OperatorState")
|
||||
if !ok {
|
||||
t.Fatal("AgentResourceContext must carry OperatorState")
|
||||
}
|
||||
if operatorStateField.Type != reflect.TypeOf((*AgentResourceOperatorState)(nil)) ||
|
||||
operatorStateField.Tag.Get("json") != "operatorState,omitempty" {
|
||||
t.Error("operatorState must be an omitempty pointer so absent entries surface as a missing field")
|
||||
}
|
||||
discoveryReadinessField, ok := contextType.FieldByName("DiscoveryReadiness")
|
||||
if !ok {
|
||||
t.Fatal("AgentResourceContext must carry DiscoveryReadiness")
|
||||
}
|
||||
if discoveryReadinessField.Type != reflect.TypeOf((*AgentResourceDiscoveryReadiness)(nil)) ||
|
||||
discoveryReadinessField.Tag.Get("json") != "discoveryReadiness,omitempty" {
|
||||
t.Error("discoveryReadiness must be an omitempty pointer to the shared resource readiness projection")
|
||||
}
|
||||
pendingApprovalsField, ok := contextType.FieldByName("PendingApprovals")
|
||||
if !ok {
|
||||
t.Fatal("AgentResourceContext must carry PendingApprovals")
|
||||
}
|
||||
if pendingApprovalsField.Type != reflect.TypeOf([]AgentResourceApprovalSummary{}) ||
|
||||
pendingApprovalsField.Tag.Get("json") != "pendingApprovals" {
|
||||
t.Error("AgentResourceContext must carry PendingApprovals as a stable []AgentResourceApprovalSummary field")
|
||||
}
|
||||
if !strings.Contains(src, "ContextSections []AgentResourceContextSection `json:\"contextSections\"`") {
|
||||
contextSectionsField, ok := contextType.FieldByName("ContextSections")
|
||||
if !ok {
|
||||
t.Fatal("AgentResourceContext must carry ContextSections")
|
||||
}
|
||||
if contextSectionsField.Type != reflect.TypeOf([]AgentResourceContextSection{}) ||
|
||||
contextSectionsField.Tag.Get("json") != "contextSections" {
|
||||
t.Error("AgentResourceContext must carry ContextSections as a stable []AgentResourceContextSection field")
|
||||
}
|
||||
if !strings.Contains(src, "type AgentResourceContextRedaction = agentcontext.Redaction") ||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
mockfixtures "github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/servicediscovery"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
|
@ -42,6 +43,33 @@ func (h *DiscoveryHandlers) SetService(service *servicediscovery.Service) {
|
|||
h.service = service
|
||||
}
|
||||
|
||||
// DiscoveryReadinessForResource implements the unified resource readiness
|
||||
// provider used by resource and agent-context endpoints.
|
||||
func (h *DiscoveryHandlers) DiscoveryReadinessForResource(resource unified.Resource, now time.Time) unified.ResourceDiscoveryReadiness {
|
||||
target := resource.DiscoveryTarget
|
||||
if h == nil {
|
||||
return servicediscovery.DiscoveryReadinessUnavailableForTarget(target, now, "Discovery handlers are not configured.")
|
||||
}
|
||||
if h.service != nil {
|
||||
return h.service.DiscoveryReadinessForResource(resource, now)
|
||||
}
|
||||
if target == nil {
|
||||
return servicediscovery.DiscoveryReadinessForTarget(nil, nil, nil, servicediscovery.DefaultConfig().MaxDiscoveryAge, now)
|
||||
}
|
||||
if _, ok := servicediscovery.DiscoveryResourceTypeForTarget(target); !ok {
|
||||
return servicediscovery.DiscoveryReadinessForTarget(target, nil, nil, servicediscovery.DefaultConfig().MaxDiscoveryAge, now)
|
||||
}
|
||||
if mockfixtures.IsMockEnabled() {
|
||||
resourceType, ok := servicediscovery.DiscoveryResourceTypeForTarget(target)
|
||||
if !ok {
|
||||
return servicediscovery.DiscoveryReadinessForTarget(target, nil, nil, servicediscovery.DefaultConfig().MaxDiscoveryAge, now)
|
||||
}
|
||||
discovery := mockDiscoveryFixtureByResource(resourceType, target.AgentID, target.ResourceID)
|
||||
return servicediscovery.DiscoveryReadinessForTarget(target, discovery, nil, servicediscovery.DefaultConfig().MaxDiscoveryAge, now)
|
||||
}
|
||||
return servicediscovery.DiscoveryReadinessUnavailableForTarget(target, now, "Discovery service is not configured.")
|
||||
}
|
||||
|
||||
// SetAIConfigProvider sets the AI config provider for showing AI provider info.
|
||||
func (h *DiscoveryHandlers) SetAIConfigProvider(provider AIConfigProvider) {
|
||||
h.aiConfigProvider = provider
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@ type ResourceHandlers struct {
|
|||
tenantStateProvider TenantStateProvider
|
||||
actionExecutor ActionExecutor
|
||||
actionCompleted func(unified.ActionAuditRecord)
|
||||
discoveryReadiness ResourceDiscoveryReadinessProvider
|
||||
}
|
||||
|
||||
// ResourceDiscoveryReadinessProvider projects service-discovery state onto a
|
||||
// unified resource without coupling the resource API to discovery storage.
|
||||
type ResourceDiscoveryReadinessProvider interface {
|
||||
DiscoveryReadinessForResource(resource unified.Resource, now time.Time) unified.ResourceDiscoveryReadiness
|
||||
}
|
||||
|
||||
type registrySeed struct {
|
||||
|
|
@ -102,6 +109,12 @@ func (h *ResourceHandlers) SetActionCompletedPublisher(publisher func(unified.Ac
|
|||
h.actionCompleted = publisher
|
||||
}
|
||||
|
||||
// SetDiscoveryReadinessProvider wires the canonical discovery-readiness
|
||||
// projection onto resource responses and Assistant context packs.
|
||||
func (h *ResourceHandlers) SetDiscoveryReadinessProvider(provider ResourceDiscoveryReadinessProvider) {
|
||||
h.discoveryReadiness = provider
|
||||
}
|
||||
|
||||
// SetSupplementalRecordsProvider configures additional records for a source.
|
||||
func (h *ResourceHandlers) SetSupplementalRecordsProvider(source unified.DataSource, provider SupplementalRecordsProvider) {
|
||||
h.supplementalMu.Lock()
|
||||
|
|
@ -148,6 +161,7 @@ func (h *ResourceHandlers) HandleListResources(w http.ResponseWriter, r *http.Re
|
|||
|
||||
paged, meta := paginate(resources, filters.page, filters.limit)
|
||||
attachDiscoveryTargets(paged)
|
||||
h.attachDiscoveryReadinesses(paged, time.Now().UTC())
|
||||
attachMetricsTargets(paged, registry)
|
||||
paged = unified.RefreshCanonicalMetadataSlice(paged)
|
||||
pruneResourcesForListResponse(paged)
|
||||
|
|
@ -281,6 +295,7 @@ func (h *ResourceHandlers) HandleGetResource(w http.ResponseWriter, r *http.Requ
|
|||
|
||||
resourceCopy := *resource
|
||||
attachDiscoveryTarget(&resourceCopy)
|
||||
h.attachDiscoveryReadiness(&resourceCopy, time.Now().UTC())
|
||||
attachMetricsTarget(&resourceCopy, registry)
|
||||
unified.RefreshCanonicalMetadata(&resourceCopy)
|
||||
resourceCopy.Type = resourceContractType(resourceCopy)
|
||||
|
|
@ -297,19 +312,28 @@ func presentationResourcesFromRegistry(registry *unified.ResourceRegistry) []uni
|
|||
}
|
||||
|
||||
func presentationResourceByID(registry *unified.ResourceRegistry, resourceID string) (*unified.Resource, bool) {
|
||||
resourceID = unified.CanonicalResourceID(resourceID)
|
||||
if resourceID == "" || registry == nil {
|
||||
return nil, false
|
||||
resource, _, ok := presentationResourceByReference(registry, resourceID)
|
||||
return resource, ok
|
||||
}
|
||||
|
||||
func presentationResourceByReference(registry *unified.ResourceRegistry, ref string) (*unified.Resource, string, bool) {
|
||||
ref = unified.CanonicalResourceID(ref)
|
||||
if ref == "" || registry == nil {
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
rawResource, resolvedID, ok := registry.GetByReference(ref)
|
||||
if !ok {
|
||||
return nil, "", false
|
||||
}
|
||||
for _, resource := range presentationResourcesFromRegistry(registry) {
|
||||
if unified.CanonicalResourceID(resource.ID) == resourceID {
|
||||
if unified.CanonicalResourceID(resource.ID) == resolvedID {
|
||||
resourceCopy := resource
|
||||
return &resourceCopy, true
|
||||
return &resourceCopy, resolvedID, true
|
||||
}
|
||||
}
|
||||
|
||||
return registry.Get(resourceID)
|
||||
return rawResource, resolvedID, true
|
||||
}
|
||||
|
||||
type resourceFacetCountsResponse = unified.ResourceFacetCounts
|
||||
|
|
@ -504,6 +528,8 @@ func (h *ResourceHandlers) HandleGetChildren(w http.ResponseWriter, r *http.Requ
|
|||
}
|
||||
|
||||
children := registry.GetChildren(path)
|
||||
attachDiscoveryTargets(children)
|
||||
h.attachDiscoveryReadinesses(children, time.Now().UTC())
|
||||
children = unified.RefreshCanonicalMetadataSlice(children)
|
||||
applyResourceContractTypes(children)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
|
@ -2023,7 +2049,29 @@ func attachDiscoveryTarget(resource *unified.Resource) {
|
|||
if resource == nil {
|
||||
return
|
||||
}
|
||||
resource.DiscoveryTarget = buildDiscoveryTarget(*resource)
|
||||
if target := buildDiscoveryTarget(*resource); target != nil {
|
||||
resource.DiscoveryTarget = target
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) attachDiscoveryReadinesses(resources []unified.Resource, now time.Time) {
|
||||
if h == nil || h.discoveryReadiness == nil {
|
||||
return
|
||||
}
|
||||
for i := range resources {
|
||||
h.attachDiscoveryReadiness(&resources[i], now)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) attachDiscoveryReadiness(resource *unified.Resource, now time.Time) {
|
||||
if h == nil || h.discoveryReadiness == nil || resource == nil {
|
||||
return
|
||||
}
|
||||
readiness := h.discoveryReadiness.DiscoveryReadinessForResource(*resource, now)
|
||||
if readiness.State == "" {
|
||||
return
|
||||
}
|
||||
resource.DiscoveryReadiness = &readiness
|
||||
}
|
||||
|
||||
func attachMetricsTargets(resources []unified.Resource, registry *unified.ResourceRegistry) {
|
||||
|
|
@ -2211,7 +2259,7 @@ func proxmoxGuestDiscoveryTarget(resource unified.Resource, resourceType string)
|
|||
return nil
|
||||
}
|
||||
resourceID := strconv.Itoa(resource.Proxmox.VMID)
|
||||
hostID := strings.TrimSpace(resource.Proxmox.NodeName)
|
||||
hostID := proxmoxLinkedAgentID(resource.Proxmox)
|
||||
if hostID == "" || resourceID == "" {
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -258,6 +258,67 @@ func TestResourceListUsesUnifiedSeedProvider(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResourceListAttachesDiscoveryReadiness(t *testing.T) {
|
||||
now := time.Date(2026, 6, 4, 11, 0, 0, 0, time.UTC)
|
||||
observedAt := now.Add(-5 * time.Minute)
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h.SetStateProvider(resourceUnifiedSeedProvider{
|
||||
snapshot: models.StateSnapshot{LastUpdate: now},
|
||||
resources: []unified.Resource{
|
||||
{
|
||||
ID: "agent:node-a",
|
||||
Type: unified.ResourceTypeAgent,
|
||||
Name: "node-a",
|
||||
Status: unified.StatusOnline,
|
||||
LastSeen: now,
|
||||
UpdatedAt: now,
|
||||
Sources: []unified.DataSource{unified.SourceAgent},
|
||||
Agent: &unified.AgentData{
|
||||
AgentID: "node-a",
|
||||
Hostname: "node-a",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
h.SetDiscoveryReadinessProvider(staticResourceDiscoveryReadinessProvider{
|
||||
byResource: map[string]unified.ResourceDiscoveryReadiness{
|
||||
"agent:node-a": {
|
||||
State: unified.ResourceDiscoveryReadinessStale,
|
||||
Source: "service-discovery",
|
||||
ObservedAt: &observedAt,
|
||||
AgeSeconds: int64((31 * 24 * time.Hour).Seconds()),
|
||||
ServiceName: "Node services",
|
||||
FactCount: 3,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent", nil)
|
||||
h.HandleListResources(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp ResourcesResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(resp.Data) != 1 {
|
||||
t.Fatalf("expected 1 resource, got %d", len(resp.Data))
|
||||
}
|
||||
readiness := resp.Data[0].DiscoveryReadiness
|
||||
if readiness == nil {
|
||||
t.Fatal("expected discoveryReadiness in resource payload")
|
||||
}
|
||||
if readiness.State != unified.ResourceDiscoveryReadinessStale {
|
||||
t.Fatalf("readiness state = %q, want stale", readiness.State)
|
||||
}
|
||||
if readiness.ResourceType != "agent" || readiness.TargetID != "node-a" || readiness.ResourceID != "node-a" {
|
||||
t.Fatalf("readiness target = %+v, want agent/node-a/node-a", readiness)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceListUsesDeterministicNameTieBreakers(t *testing.T) {
|
||||
now := time.Date(2026, 4, 11, 0, 0, 0, 0, time.UTC)
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
|
|
@ -1951,6 +2012,51 @@ func TestBuildDiscoveryTargetKubernetesPrefersAgentID(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBuildDiscoveryTargetProxmoxGuestUsesLinkedNodeAgentID(t *testing.T) {
|
||||
target := buildDiscoveryTarget(unified.Resource{
|
||||
ID: "system-container:source:grafana",
|
||||
Type: unified.ResourceTypeSystemContainer,
|
||||
Name: "grafana",
|
||||
Proxmox: &unified.ProxmoxData{
|
||||
NodeName: "delly",
|
||||
VMID: 124,
|
||||
LinkedAgentID: "agent-delly",
|
||||
},
|
||||
})
|
||||
|
||||
if target == nil {
|
||||
t.Fatal("expected discovery target")
|
||||
}
|
||||
if target.ResourceType != "system-container" {
|
||||
t.Fatalf("resource type = %q, want system-container", target.ResourceType)
|
||||
}
|
||||
if target.AgentID != "agent-delly" {
|
||||
t.Fatalf("agentID = %q, want agent-delly", target.AgentID)
|
||||
}
|
||||
if target.ResourceID != "124" {
|
||||
t.Fatalf("resourceID = %q, want 124", target.ResourceID)
|
||||
}
|
||||
if target.Hostname != "grafana" {
|
||||
t.Fatalf("hostname = %q, want grafana", target.Hostname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDiscoveryTargetProxmoxGuestRequiresLinkedNodeAgentID(t *testing.T) {
|
||||
target := buildDiscoveryTarget(unified.Resource{
|
||||
ID: "vm:source:db",
|
||||
Type: unified.ResourceTypeVM,
|
||||
Name: "db",
|
||||
Proxmox: &unified.ProxmoxData{
|
||||
NodeName: "delly",
|
||||
VMID: 101,
|
||||
},
|
||||
})
|
||||
|
||||
if target != nil {
|
||||
t.Fatalf("expected no discovery target without linked agent, got %+v", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDiscoveryTargetDockerAppContainerUsesAgentAndContainerName(t *testing.T) {
|
||||
target := buildDiscoveryTarget(unified.Resource{
|
||||
ID: "app-container:source:abc123",
|
||||
|
|
|
|||
|
|
@ -708,6 +708,9 @@ func (r *Router) setupRoutes() {
|
|||
// AI-powered infrastructure discovery handlers
|
||||
// Note: The actual service is wired up later via SetDiscoveryService
|
||||
r.discoveryHandlers = NewDiscoveryHandlers(nil, r.config)
|
||||
if r.resourceHandlers != nil {
|
||||
r.resourceHandlers.SetDiscoveryReadinessProvider(r.discoveryHandlers)
|
||||
}
|
||||
|
||||
// Wire license checker for Pro feature gating (AI Patrol, Alert Analysis, Auto-Fix)
|
||||
r.aiSettingsHandler.SetLicenseHandlers(r.licenseHandlers)
|
||||
|
|
@ -1577,6 +1580,9 @@ func (r *Router) SetDiscoveryService(svc *servicediscovery.Service) {
|
|||
if r.discoveryHandlers != nil {
|
||||
r.discoveryHandlers.SetService(svc)
|
||||
}
|
||||
if r.resourceHandlers != nil && r.discoveryHandlers != nil {
|
||||
r.resourceHandlers.SetDiscoveryReadinessProvider(r.discoveryHandlers)
|
||||
}
|
||||
|
||||
// Wire up WebSocket hub for progress broadcasting
|
||||
if svc != nil && r.wsHub != nil {
|
||||
|
|
|
|||
220
internal/servicediscovery/readiness.go
Normal file
220
internal/servicediscovery/readiness.go
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
package servicediscovery
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
const discoveryReadinessSource = "service-discovery"
|
||||
|
||||
// DiscoveryResourceTypeForTarget maps unified resource discovery targets onto
|
||||
// the persisted service-discovery resource vocabulary.
|
||||
func DiscoveryResourceTypeForTarget(target *unified.DiscoveryTarget) (ResourceType, bool) {
|
||||
if target == nil {
|
||||
return "", false
|
||||
}
|
||||
switch normalized := strings.ToLower(strings.TrimSpace(target.ResourceType)); normalized {
|
||||
case string(ResourceTypeAgent):
|
||||
return ResourceTypeAgent, true
|
||||
case string(ResourceTypeVM):
|
||||
return ResourceTypeVM, true
|
||||
case string(ResourceTypeSystemContainer):
|
||||
return ResourceTypeSystemContainer, true
|
||||
case "app-container", string(ResourceTypeDocker):
|
||||
return ResourceTypeDocker, true
|
||||
case "pod", string(ResourceTypeK8s):
|
||||
return ResourceTypeK8s, true
|
||||
default:
|
||||
if strings.HasPrefix(normalized, "k8s-") {
|
||||
return ResourceTypeK8s, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// DiscoveryIDForTarget returns the persisted discovery id that corresponds to
|
||||
// a unified discovery target when that target can be represented by service
|
||||
// discovery.
|
||||
func DiscoveryIDForTarget(target *unified.DiscoveryTarget) (string, bool) {
|
||||
resourceType, ok := DiscoveryResourceTypeForTarget(target)
|
||||
if !ok || target == nil {
|
||||
return "", false
|
||||
}
|
||||
targetID := strings.TrimSpace(target.AgentID)
|
||||
resourceID := strings.TrimSpace(target.ResourceID)
|
||||
if targetID == "" || resourceID == "" {
|
||||
return "", false
|
||||
}
|
||||
return MakeResourceID(resourceType, targetID, resourceID), true
|
||||
}
|
||||
|
||||
// DiscoveryReadinessForResource projects the discovery store into the stable
|
||||
// unified resource readiness contract.
|
||||
func (s *Service) DiscoveryReadinessForResource(resource unified.Resource, now time.Time) unified.ResourceDiscoveryReadiness {
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
target := resource.DiscoveryTarget
|
||||
if target == nil {
|
||||
return DiscoveryReadinessForTarget(nil, nil, nil, defaultDiscoveryMaxAge, now)
|
||||
}
|
||||
if s == nil || s.store == nil {
|
||||
return DiscoveryReadinessUnavailableForTarget(target, now, "Discovery service is not configured.")
|
||||
}
|
||||
|
||||
resourceType, ok := DiscoveryResourceTypeForTarget(target)
|
||||
if !ok {
|
||||
return DiscoveryReadinessForTarget(target, nil, nil, s.GetMaxDiscoveryAge(), now)
|
||||
}
|
||||
|
||||
discoveryID, _ := DiscoveryIDForTarget(target)
|
||||
progress := s.GetProgress(discoveryID)
|
||||
discovery, err := s.GetDiscoveryByResource(resourceType, target.AgentID, target.ResourceID)
|
||||
if err != nil {
|
||||
return DiscoveryReadinessReadFailureForTarget(target, now)
|
||||
}
|
||||
return DiscoveryReadinessForTarget(target, discovery, progress, s.GetMaxDiscoveryAge(), now)
|
||||
}
|
||||
|
||||
// DiscoveryReadinessUnavailableForTarget creates a readiness projection for a
|
||||
// supported target when Pulse cannot currently read service-discovery state.
|
||||
func DiscoveryReadinessUnavailableForTarget(target *unified.DiscoveryTarget, now time.Time, reason string) unified.ResourceDiscoveryReadiness {
|
||||
readiness := discoveryReadinessBase(target, now)
|
||||
readiness.State = unified.ResourceDiscoveryReadinessUnavailable
|
||||
readiness.Reason = strings.TrimSpace(reason)
|
||||
if readiness.Reason == "" {
|
||||
readiness.Reason = "Discovery status is not available."
|
||||
}
|
||||
return readiness
|
||||
}
|
||||
|
||||
// DiscoveryReadinessReadFailureForTarget creates a readiness projection for a
|
||||
// supported target when the discovery store read failed.
|
||||
func DiscoveryReadinessReadFailureForTarget(target *unified.DiscoveryTarget, now time.Time) unified.ResourceDiscoveryReadiness {
|
||||
readiness := discoveryReadinessBase(target, now)
|
||||
readiness.State = unified.ResourceDiscoveryReadinessFailed
|
||||
readiness.Reason = "Discovery status could not be read."
|
||||
return readiness
|
||||
}
|
||||
|
||||
// DiscoveryReadinessForTarget summarizes discovery, scan progress, and the max
|
||||
// age policy without exposing raw discovery output.
|
||||
func DiscoveryReadinessForTarget(target *unified.DiscoveryTarget, discovery *ResourceDiscovery, progress *DiscoveryProgress, maxAge time.Duration, now time.Time) unified.ResourceDiscoveryReadiness {
|
||||
readiness := discoveryReadinessBase(target, now)
|
||||
if target == nil {
|
||||
readiness.State = unified.ResourceDiscoveryReadinessUnsupported
|
||||
readiness.Reason = "No discovery target is available for this resource."
|
||||
return readiness
|
||||
}
|
||||
if _, ok := DiscoveryResourceTypeForTarget(target); !ok {
|
||||
readiness.State = unified.ResourceDiscoveryReadinessUnsupported
|
||||
readiness.Reason = "Service discovery does not support this resource type."
|
||||
return readiness
|
||||
}
|
||||
|
||||
if progress != nil {
|
||||
observedAt := timePtrIfSet(progress.StartedAt)
|
||||
readiness.ObservedAt = observedAt
|
||||
if strings.TrimSpace(progress.Error) != "" {
|
||||
readiness.State = unified.ResourceDiscoveryReadinessFailed
|
||||
readiness.Reason = "Last discovery run failed."
|
||||
return readiness
|
||||
}
|
||||
if progress.Status == DiscoveryStatusRunning {
|
||||
readiness.State = unified.ResourceDiscoveryReadinessRunning
|
||||
readiness.Reason = "Discovery is currently running."
|
||||
return readiness
|
||||
}
|
||||
}
|
||||
|
||||
if discovery == nil {
|
||||
readiness.State = unified.ResourceDiscoveryReadinessMissing
|
||||
readiness.Reason = "Discovery has not run for this resource."
|
||||
return readiness
|
||||
}
|
||||
|
||||
readiness.DiscoveryID = strings.TrimSpace(discovery.ID)
|
||||
if readiness.DiscoveryID == "" {
|
||||
if discoveryID, ok := DiscoveryIDForTarget(target); ok {
|
||||
readiness.DiscoveryID = discoveryID
|
||||
}
|
||||
}
|
||||
readiness.ServiceName = strings.TrimSpace(discovery.ServiceName)
|
||||
readiness.ServiceCategory = strings.TrimSpace(string(discovery.Category))
|
||||
readiness.Confidence = discovery.Confidence
|
||||
readiness.FactCount = discoveryContextFactCount(discovery)
|
||||
|
||||
observed := discovery.UpdatedAt
|
||||
if observed.IsZero() {
|
||||
observed = discovery.DiscoveredAt
|
||||
}
|
||||
if !observed.IsZero() {
|
||||
observed = observed.UTC()
|
||||
readiness.ObservedAt = &observed
|
||||
age := now.Sub(observed)
|
||||
if age < 0 {
|
||||
age = 0
|
||||
}
|
||||
readiness.AgeSeconds = int64(age.Seconds())
|
||||
}
|
||||
|
||||
if maxAge <= 0 {
|
||||
maxAge = defaultDiscoveryMaxAge
|
||||
}
|
||||
readiness.StaleAfterSeconds = int64(maxAge.Seconds())
|
||||
if readiness.ObservedAt == nil {
|
||||
readiness.State = unified.ResourceDiscoveryReadinessStale
|
||||
readiness.Reason = "Discovery exists but has no observation timestamp."
|
||||
return readiness
|
||||
}
|
||||
if time.Duration(readiness.AgeSeconds)*time.Second > maxAge {
|
||||
readiness.State = unified.ResourceDiscoveryReadinessStale
|
||||
readiness.Reason = "Discovery data is older than the configured freshness window."
|
||||
return readiness
|
||||
}
|
||||
readiness.State = unified.ResourceDiscoveryReadinessFresh
|
||||
readiness.Reason = "Discovery data is within the configured freshness window."
|
||||
return readiness
|
||||
}
|
||||
|
||||
func discoveryReadinessBase(target *unified.DiscoveryTarget, now time.Time) unified.ResourceDiscoveryReadiness {
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
readiness := unified.ResourceDiscoveryReadiness{
|
||||
Source: discoveryReadinessSource,
|
||||
GeneratedAt: now.UTC(),
|
||||
}
|
||||
if target == nil {
|
||||
return readiness
|
||||
}
|
||||
readiness.ResourceType = strings.TrimSpace(target.ResourceType)
|
||||
readiness.TargetID = strings.TrimSpace(target.AgentID)
|
||||
readiness.ResourceID = strings.TrimSpace(target.ResourceID)
|
||||
if discoveryID, ok := DiscoveryIDForTarget(target); ok {
|
||||
readiness.DiscoveryID = discoveryID
|
||||
}
|
||||
return readiness
|
||||
}
|
||||
|
||||
func discoveryContextFactCount(discovery *ResourceDiscovery) int {
|
||||
if discovery == nil {
|
||||
return 0
|
||||
}
|
||||
return len(discovery.Facts) +
|
||||
len(discovery.ConfigPaths) +
|
||||
len(discovery.DataPaths) +
|
||||
len(discovery.LogPaths) +
|
||||
len(discovery.Ports) +
|
||||
len(discovery.DockerMounts)
|
||||
}
|
||||
|
||||
func timePtrIfSet(value time.Time) *time.Time {
|
||||
if value.IsZero() {
|
||||
return nil
|
||||
}
|
||||
value = value.UTC()
|
||||
return &value
|
||||
}
|
||||
140
internal/servicediscovery/readiness_test.go
Normal file
140
internal/servicediscovery/readiness_test.go
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
package servicediscovery
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
func TestDiscoveryReadinessForTargetStates(t *testing.T) {
|
||||
now := time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC)
|
||||
target := &unified.DiscoveryTarget{
|
||||
ResourceType: "system-container",
|
||||
AgentID: "node-a",
|
||||
ResourceID: "101",
|
||||
Hostname: "homeassistant",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
discovery *ResourceDiscovery
|
||||
maxAge time.Duration
|
||||
want unified.ResourceDiscoveryReadinessState
|
||||
}{
|
||||
{
|
||||
name: "fresh",
|
||||
discovery: &ResourceDiscovery{
|
||||
ID: MakeResourceID(ResourceTypeSystemContainer, "node-a", "101"),
|
||||
ServiceName: "Home Assistant",
|
||||
Category: CategoryHomeAuto,
|
||||
Confidence: 0.92,
|
||||
UpdatedAt: now.Add(-2 * time.Hour),
|
||||
Facts: []DiscoveryFact{{Key: "service", Value: "homeassistant"}},
|
||||
Ports: []PortInfo{{Port: 8123, Protocol: "tcp"}},
|
||||
},
|
||||
maxAge: 30 * 24 * time.Hour,
|
||||
want: unified.ResourceDiscoveryReadinessFresh,
|
||||
},
|
||||
{
|
||||
name: "stale",
|
||||
discovery: &ResourceDiscovery{
|
||||
ID: MakeResourceID(ResourceTypeSystemContainer, "node-a", "101"),
|
||||
UpdatedAt: now.Add(-31 * 24 * time.Hour),
|
||||
},
|
||||
maxAge: 30 * 24 * time.Hour,
|
||||
want: unified.ResourceDiscoveryReadinessStale,
|
||||
},
|
||||
{
|
||||
name: "missing",
|
||||
discovery: nil,
|
||||
maxAge: 30 * 24 * time.Hour,
|
||||
want: unified.ResourceDiscoveryReadinessMissing,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := DiscoveryReadinessForTarget(target, tt.discovery, nil, tt.maxAge, now)
|
||||
if got.State != tt.want {
|
||||
t.Fatalf("state = %q, want %q", got.State, tt.want)
|
||||
}
|
||||
if got.GeneratedAt.IsZero() {
|
||||
t.Fatal("GeneratedAt must be populated")
|
||||
}
|
||||
if got.ResourceType != "system-container" || got.TargetID != "node-a" || got.ResourceID != "101" {
|
||||
t.Fatalf("target fields = %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryReadinessForTargetCountsSafeDiscoveryMetadata(t *testing.T) {
|
||||
now := time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC)
|
||||
target := &unified.DiscoveryTarget{
|
||||
ResourceType: "app-container",
|
||||
AgentID: "docker-host",
|
||||
ResourceID: "homeassistant",
|
||||
}
|
||||
|
||||
discovery := &ResourceDiscovery{
|
||||
ID: MakeResourceID(ResourceTypeDocker, "docker-host", "homeassistant"),
|
||||
ResourceType: ResourceTypeDocker,
|
||||
ResourceID: "homeassistant",
|
||||
TargetID: "docker-host",
|
||||
ServiceName: "Home Assistant",
|
||||
Category: CategoryHomeAuto,
|
||||
Confidence: 0.88,
|
||||
UpdatedAt: now.Add(-time.Hour),
|
||||
Facts: []DiscoveryFact{{Key: "service", Value: "homeassistant"}},
|
||||
ConfigPaths: []string{"/config/configuration.yaml"},
|
||||
LogPaths: []string{"/config/home-assistant.log"},
|
||||
Ports: []PortInfo{{Port: 8123, Protocol: "tcp"}},
|
||||
DockerMounts: []DockerBindMount{{ContainerName: "homeassistant"}},
|
||||
}
|
||||
|
||||
got := DiscoveryReadinessForTarget(target, discovery, nil, 30*24*time.Hour, now)
|
||||
if got.State != unified.ResourceDiscoveryReadinessFresh {
|
||||
t.Fatalf("state = %q, want fresh", got.State)
|
||||
}
|
||||
if got.DiscoveryID != MakeResourceID(ResourceTypeDocker, "docker-host", "homeassistant") {
|
||||
t.Fatalf("discovery id = %q", got.DiscoveryID)
|
||||
}
|
||||
if got.FactCount != 5 {
|
||||
t.Fatalf("fact count = %d, want 5", got.FactCount)
|
||||
}
|
||||
if got.ServiceName != "Home Assistant" || got.ServiceCategory != string(CategoryHomeAuto) {
|
||||
t.Fatalf("service fields = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryReadinessForTargetRunningAndUnsupported(t *testing.T) {
|
||||
now := time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC)
|
||||
startedAt := now.Add(-30 * time.Second)
|
||||
target := &unified.DiscoveryTarget{
|
||||
ResourceType: "pod",
|
||||
AgentID: "cluster-a",
|
||||
ResourceID: "default/homeassistant",
|
||||
}
|
||||
|
||||
running := DiscoveryReadinessForTarget(target, nil, &DiscoveryProgress{
|
||||
ResourceID: MakeResourceID(ResourceTypeK8s, "cluster-a", "default/homeassistant"),
|
||||
Status: DiscoveryStatusRunning,
|
||||
StartedAt: startedAt,
|
||||
}, 30*24*time.Hour, now)
|
||||
if running.State != unified.ResourceDiscoveryReadinessRunning {
|
||||
t.Fatalf("running state = %q", running.State)
|
||||
}
|
||||
if running.ObservedAt == nil || !running.ObservedAt.Equal(startedAt) {
|
||||
t.Fatalf("running observedAt = %v, want %v", running.ObservedAt, startedAt)
|
||||
}
|
||||
|
||||
unsupported := DiscoveryReadinessForTarget(&unified.DiscoveryTarget{
|
||||
ResourceType: "ceph",
|
||||
AgentID: "cluster",
|
||||
ResourceID: "fsid",
|
||||
}, nil, nil, 30*24*time.Hour, now)
|
||||
if unsupported.State != unified.ResourceDiscoveryReadinessUnsupported {
|
||||
t.Fatalf("unsupported state = %q", unsupported.State)
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ func cloneResource(in *Resource) Resource {
|
|||
|
||||
out := *in
|
||||
out.DiscoveryTarget = cloneDiscoveryTarget(in.DiscoveryTarget)
|
||||
out.DiscoveryReadiness = cloneResourceDiscoveryReadiness(in.DiscoveryReadiness)
|
||||
out.MetricsTarget = cloneMetricsTarget(in.MetricsTarget)
|
||||
out.PlatformScopes = cloneStringSlice(in.PlatformScopes)
|
||||
out.Sources = cloneDataSourceSlice(in.Sources)
|
||||
|
|
@ -76,6 +77,15 @@ func cloneDiscoveryTarget(in *DiscoveryTarget) *DiscoveryTarget {
|
|||
return &out
|
||||
}
|
||||
|
||||
func cloneResourceDiscoveryReadiness(in *ResourceDiscoveryReadiness) *ResourceDiscoveryReadiness {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
out.ObservedAt = cloneTimePtr(in.ObservedAt)
|
||||
return &out
|
||||
}
|
||||
|
||||
func cloneMetricsTarget(in *MetricsTarget) *MetricsTarget {
|
||||
if in == nil {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -218,6 +218,30 @@ func TestNoDirectStateAccessForMigratedResources(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestProxmoxWorkloadActionTargetsStayBackendAuthored(t *testing.T) {
|
||||
apiSource, err := os.ReadFile(filepath.Join("..", "api", "resources.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("read api resources source: %v", err)
|
||||
}
|
||||
api := string(apiSource)
|
||||
if !strings.Contains(api, "hostID := proxmoxLinkedAgentID(resource.Proxmox)") {
|
||||
t.Fatal("Proxmox workload discovery targets must use the linked node agent ID")
|
||||
}
|
||||
if strings.Contains(api, "hostID := strings.TrimSpace(resource.Proxmox.NodeName)") {
|
||||
t.Fatal("Proxmox workload discovery targets must not use node display names as agent IDs")
|
||||
}
|
||||
|
||||
frontendSource, err := os.ReadFile(filepath.Join("..", "..", "frontend-modern", "src", "utils", "workloads.ts"))
|
||||
if err != nil {
|
||||
t.Fatalf("read frontend workload source: %v", err)
|
||||
}
|
||||
frontend := string(frontendSource)
|
||||
if strings.Contains(frontend, "const agentId = (guest.node || '').trim();") &&
|
||||
strings.Contains(frontend, "const resourceId = String(guest.vmid);") {
|
||||
t.Fatal("frontend workload mapping must not infer Proxmox action targets from node plus VMID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerSwarmEvidenceGuardStaysInAdapter(t *testing.T) {
|
||||
data, err := os.ReadFile("adapters.go")
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -24,12 +24,12 @@ func CoalescePresentationHostResourcesWithExclusions(
|
|||
for len(coalesced) < len(resources) {
|
||||
next := coalescePresentationHostResourcesOnce(coalesced, excluded)
|
||||
if len(next) == len(coalesced) {
|
||||
return next
|
||||
return refreshPresentationProxmoxChildActionAgents(next)
|
||||
}
|
||||
resources = coalesced
|
||||
coalesced = next
|
||||
}
|
||||
return coalesced
|
||||
return refreshPresentationProxmoxChildActionAgents(coalesced)
|
||||
}
|
||||
|
||||
func coalescePresentationHostResourcesOnce(
|
||||
|
|
@ -42,6 +42,7 @@ func coalescePresentationHostResourcesOnce(
|
|||
|
||||
coalesced := make([]Resource, 0, len(resources))
|
||||
indexByHostKey := make(map[string]int, len(resources))
|
||||
parentRedirects := make(map[string]string)
|
||||
for _, resource := range resources {
|
||||
resource.Type = CanonicalResourceType(resource.Type)
|
||||
hostKey := presentationHostMergeKey(resource)
|
||||
|
|
@ -66,12 +67,68 @@ func coalescePresentationHostResourcesOnce(
|
|||
coalesced = append(coalesced, resource)
|
||||
continue
|
||||
}
|
||||
coalesced[existingIndex] = mergePresentationHostResources(existing, resource)
|
||||
merged := mergePresentationHostResources(existing, resource)
|
||||
coalesced[existingIndex] = merged
|
||||
addPresentationParentRedirect(parentRedirects, existing.ID, merged.ID)
|
||||
addPresentationParentRedirect(parentRedirects, resource.ID, merged.ID)
|
||||
}
|
||||
|
||||
applyPresentationParentRedirects(coalesced, parentRedirects)
|
||||
return coalesced
|
||||
}
|
||||
|
||||
func addPresentationParentRedirect(redirects map[string]string, fromID, toID string) {
|
||||
fromID = CanonicalResourceID(strings.TrimSpace(fromID))
|
||||
toID = CanonicalResourceID(strings.TrimSpace(toID))
|
||||
if fromID == "" || toID == "" || fromID == toID {
|
||||
return
|
||||
}
|
||||
redirects[fromID] = toID
|
||||
}
|
||||
|
||||
func applyPresentationParentRedirects(resources []Resource, redirects map[string]string) {
|
||||
if len(redirects) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range resources {
|
||||
if resources[i].ParentID == nil {
|
||||
continue
|
||||
}
|
||||
parentID := CanonicalResourceID(strings.TrimSpace(*resources[i].ParentID))
|
||||
if redirectedID := redirects[parentID]; redirectedID != "" {
|
||||
resources[i].ParentID = &redirectedID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refreshPresentationProxmoxChildActionAgents(resources []Resource) []Resource {
|
||||
parentByID := make(map[string]int, len(resources))
|
||||
for i := range resources {
|
||||
resourceID := CanonicalResourceID(strings.TrimSpace(resources[i].ID))
|
||||
if resourceID != "" {
|
||||
parentByID[resourceID] = i
|
||||
}
|
||||
}
|
||||
|
||||
for i := range resources {
|
||||
switch CanonicalResourceType(resources[i].Type) {
|
||||
case ResourceTypeVM, ResourceTypeSystemContainer:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if resources[i].Proxmox == nil || resources[i].ParentID == nil {
|
||||
continue
|
||||
}
|
||||
parentID := CanonicalResourceID(strings.TrimSpace(*resources[i].ParentID))
|
||||
parentIndex, ok := parentByID[parentID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
attachLinkedAgentID(resources[i].Proxmox, linkedAgentIDFromResource(&resources[parentIndex]))
|
||||
}
|
||||
return resources
|
||||
}
|
||||
|
||||
func presentationHostMergeKey(resource Resource) string {
|
||||
if CanonicalResourceType(resource.Type) != ResourceTypeAgent {
|
||||
return ""
|
||||
|
|
|
|||
|
|
@ -58,6 +58,80 @@ func TestCoalescePresentationHostResourcesMergesSplitRuntimeAndPlatformHost(t *t
|
|||
}
|
||||
}
|
||||
|
||||
func TestCoalescePresentationHostResourcesRedirectsProxmoxChildrenToAgentBackedParent(t *testing.T) {
|
||||
now := time.Date(2026, 5, 22, 10, 30, 0, 0, time.UTC)
|
||||
proxmoxParentID := "agent-proxmox-delly"
|
||||
resources := []Resource{
|
||||
{
|
||||
ID: proxmoxParentID,
|
||||
Type: ResourceTypeAgent,
|
||||
Name: "delly",
|
||||
Status: StatusOnline,
|
||||
LastSeen: now,
|
||||
Sources: []DataSource{SourceProxmox},
|
||||
Identity: ResourceIdentity{Hostnames: []string{"delly"}},
|
||||
Proxmox: &ProxmoxData{
|
||||
SourceID: "homelab-delly",
|
||||
NodeName: "delly",
|
||||
ClusterName: "homelab",
|
||||
Instance: "delly",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "system-container-grafana",
|
||||
Type: ResourceTypeSystemContainer,
|
||||
Name: "grafana",
|
||||
Status: StatusOnline,
|
||||
LastSeen: now,
|
||||
ParentID: &proxmoxParentID,
|
||||
Sources: []DataSource{SourceProxmox},
|
||||
Identity: ResourceIdentity{Hostnames: []string{"grafana"}},
|
||||
Proxmox: &ProxmoxData{
|
||||
SourceID: "delly:delly:124",
|
||||
NodeName: "delly",
|
||||
ClusterName: "homelab",
|
||||
Instance: "delly",
|
||||
VMID: 124,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "agent-runtime-delly",
|
||||
Type: ResourceTypeAgent,
|
||||
Name: "delly",
|
||||
Status: StatusOnline,
|
||||
LastSeen: now.Add(time.Second),
|
||||
Sources: []DataSource{SourceAgent},
|
||||
Identity: ResourceIdentity{Hostnames: []string{"delly"}},
|
||||
Agent: &AgentData{
|
||||
AgentID: "agent-machine-delly",
|
||||
Hostname: "delly",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
coalesced := CoalescePresentationHostResources(resources)
|
||||
if len(coalesced) != 2 {
|
||||
t.Fatalf("expected merged parent plus child, got %d: %#v", len(coalesced), coalesced)
|
||||
}
|
||||
|
||||
var child *Resource
|
||||
for i := range coalesced {
|
||||
if coalesced[i].ID == "system-container-grafana" {
|
||||
child = &coalesced[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if child == nil {
|
||||
t.Fatal("expected grafana child in coalesced resources")
|
||||
}
|
||||
if child.ParentID == nil || *child.ParentID != "agent-runtime-delly" {
|
||||
t.Fatalf("expected child parent agent-runtime-delly, got %+v", child.ParentID)
|
||||
}
|
||||
if child.Proxmox == nil || child.Proxmox.LinkedAgentID != "agent-machine-delly" {
|
||||
t.Fatalf("expected child linked agent agent-machine-delly, got %+v", child.Proxmox)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoalescePresentationHostResourcesDoesNotMergeRuntimeOnlyNameCollision(t *testing.T) {
|
||||
now := time.Date(2026, 5, 22, 10, 30, 0, 0, time.UTC)
|
||||
resources := []Resource{
|
||||
|
|
|
|||
|
|
@ -1453,6 +1453,7 @@ func (rr *ResourceRegistry) ingestVM(vm models.VM, clusterByInstance map[string]
|
|||
clusterName := clusterByInstance[vm.Instance]
|
||||
if parentID := rr.proxmoxNodeParentID(vm.Instance, clusterName, vm.Node, ""); parentID != "" {
|
||||
resource.ParentID = &parentID
|
||||
attachLinkedAgentID(resource.Proxmox, rr.linkedAgentIDForResource(parentID))
|
||||
}
|
||||
if clusterName != "" && resource.Proxmox != nil {
|
||||
resource.Proxmox.ClusterName = clusterName
|
||||
|
|
@ -1466,6 +1467,7 @@ func (rr *ResourceRegistry) ingestContainer(ct models.Container, clusterByInstan
|
|||
clusterName := clusterByInstance[ct.Instance]
|
||||
if parentID := rr.proxmoxNodeParentID(ct.Instance, clusterName, ct.Node, ""); parentID != "" {
|
||||
resource.ParentID = &parentID
|
||||
attachLinkedAgentID(resource.Proxmox, rr.linkedAgentIDForResource(parentID))
|
||||
}
|
||||
if clusterName != "" && resource.Proxmox != nil {
|
||||
resource.Proxmox.ClusterName = clusterName
|
||||
|
|
@ -1473,6 +1475,27 @@ func (rr *ResourceRegistry) ingestContainer(ct models.Container, clusterByInstan
|
|||
rr.ingest(SourceProxmox, sourceID, resource, identity)
|
||||
}
|
||||
|
||||
func (rr *ResourceRegistry) linkedAgentIDForResource(resourceID string) string {
|
||||
if rr == nil {
|
||||
return ""
|
||||
}
|
||||
rr.mu.RLock()
|
||||
defer rr.mu.RUnlock()
|
||||
resourceID = CanonicalResourceID(resourceID)
|
||||
return linkedAgentIDFromResource(rr.resources[resourceID])
|
||||
}
|
||||
|
||||
func attachLinkedAgentID(proxmox *ProxmoxData, linkedAgentID string) {
|
||||
if proxmox == nil {
|
||||
return
|
||||
}
|
||||
linkedAgentID = strings.TrimSpace(linkedAgentID)
|
||||
if linkedAgentID == "" {
|
||||
return
|
||||
}
|
||||
proxmox.LinkedAgentID = linkedAgentID
|
||||
}
|
||||
|
||||
func (rr *ResourceRegistry) ingestStorage(storage models.Storage) {
|
||||
resource, identity := resourceFromStorage(storage)
|
||||
parentSourceID := proxmoxNodeSourceID(storage.Instance, storage.Node)
|
||||
|
|
@ -3047,6 +3070,11 @@ func (rr *ResourceRegistry) mergeResourceData(primary *Resource, other *Resource
|
|||
}
|
||||
|
||||
func (rr *ResourceRegistry) updateSourceMappings(fromID, toID string) {
|
||||
fromID = CanonicalResourceID(strings.TrimSpace(fromID))
|
||||
toID = CanonicalResourceID(strings.TrimSpace(toID))
|
||||
if fromID == "" || toID == "" || fromID == toID {
|
||||
return
|
||||
}
|
||||
for source, mapping := range rr.bySource {
|
||||
for key, value := range mapping {
|
||||
if value == fromID {
|
||||
|
|
@ -3055,6 +3083,23 @@ func (rr *ResourceRegistry) updateSourceMappings(fromID, toID string) {
|
|||
}
|
||||
rr.bySource[source] = mapping
|
||||
}
|
||||
rr.updateParentReferencesLocked(fromID, toID)
|
||||
}
|
||||
|
||||
func (rr *ResourceRegistry) updateParentReferencesLocked(fromID, toID string) {
|
||||
for _, resource := range rr.resources {
|
||||
if resource == nil {
|
||||
continue
|
||||
}
|
||||
if resource.ParentID != nil && CanonicalResourceID(strings.TrimSpace(*resource.ParentID)) == fromID {
|
||||
resource.ParentID = &toID
|
||||
}
|
||||
for source, parentID := range resource.parentBySource {
|
||||
if CanonicalResourceID(strings.TrimSpace(parentID)) == fromID {
|
||||
resource.parentBySource[source] = toID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (rr *ResourceRegistry) setSourceParent(resource *Resource, source DataSource, parentID *string) {
|
||||
|
|
@ -3112,9 +3157,42 @@ func (rr *ResourceRegistry) resolveCanonicalParentID(resource *Resource) *string
|
|||
if bestParentID == "" {
|
||||
return rr.resolveDerivedParentIDLocked(resource)
|
||||
}
|
||||
if derivedParentID := rr.resolveDerivedParentIDLocked(resource); derivedParentID != nil {
|
||||
derivedID := CanonicalResourceID(strings.TrimSpace(*derivedParentID))
|
||||
if rr.shouldPreferDerivedProxmoxParentLocked(resource, bestParentID, derivedID) {
|
||||
return &derivedID
|
||||
}
|
||||
}
|
||||
return &bestParentID
|
||||
}
|
||||
|
||||
func (rr *ResourceRegistry) shouldPreferDerivedProxmoxParentLocked(resource *Resource, currentParentID, derivedParentID string) bool {
|
||||
if resource == nil || resource.Proxmox == nil {
|
||||
return false
|
||||
}
|
||||
switch CanonicalResourceType(resource.Type) {
|
||||
case ResourceTypeVM, ResourceTypeSystemContainer, ResourceTypeStorage, ResourceTypePhysicalDisk:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
currentParentID = CanonicalResourceID(strings.TrimSpace(currentParentID))
|
||||
derivedParentID = CanonicalResourceID(strings.TrimSpace(derivedParentID))
|
||||
if currentParentID == "" || derivedParentID == "" || currentParentID == derivedParentID {
|
||||
return false
|
||||
}
|
||||
|
||||
derivedParent := rr.resources[derivedParentID]
|
||||
if derivedParent == nil {
|
||||
return false
|
||||
}
|
||||
currentParent := rr.resources[currentParentID]
|
||||
if currentParent == nil {
|
||||
return true
|
||||
}
|
||||
return proxmoxNodeParentAgentScore(derivedParent) > proxmoxNodeParentAgentScore(currentParent)
|
||||
}
|
||||
|
||||
func (rr *ResourceRegistry) resolveDerivedParentIDLocked(resource *Resource) *string {
|
||||
if resource == nil || resource.Proxmox == nil {
|
||||
return nil
|
||||
|
|
@ -3155,6 +3233,7 @@ func (rr *ResourceRegistry) proxmoxNodeParentIDLocked(instance, clusterName, nod
|
|||
}
|
||||
|
||||
excludeID = CanonicalResourceID(strings.TrimSpace(excludeID))
|
||||
fallbackParentID := ""
|
||||
for _, sourceID := range proxmoxNodeParentSourceIDCandidates(instance, clusterName, nodeName) {
|
||||
parentID := CanonicalResourceID(mapping[normalizeSourceID(sourceID)])
|
||||
if parentID == "" || parentID == excludeID {
|
||||
|
|
@ -3164,9 +3243,17 @@ func (rr *ResourceRegistry) proxmoxNodeParentIDLocked(instance, clusterName, nod
|
|||
if parent == nil || CanonicalResourceType(parent.Type) != ResourceTypeAgent {
|
||||
continue
|
||||
}
|
||||
if proxmoxNodeParentAgentScore(parent) > 0 {
|
||||
return parentID
|
||||
}
|
||||
if fallbackParentID == "" {
|
||||
fallbackParentID = parentID
|
||||
}
|
||||
}
|
||||
if parentID := rr.proxmoxNodeParentIDFromResourcesLocked(instance, clusterName, nodeName, excludeID); parentID != "" {
|
||||
return parentID
|
||||
}
|
||||
return rr.proxmoxNodeParentIDFromResourcesLocked(instance, clusterName, nodeName, excludeID)
|
||||
return fallbackParentID
|
||||
}
|
||||
|
||||
func proxmoxNodeParentSourceIDCandidates(instance, clusterName, nodeName string) []string {
|
||||
|
|
@ -3223,6 +3310,7 @@ func (rr *ResourceRegistry) proxmoxNodeParentIDFromResourcesLocked(instance, clu
|
|||
if score < 0 {
|
||||
continue
|
||||
}
|
||||
score += proxmoxNodeParentAgentScore(resource)
|
||||
if score > bestScore || (score == bestScore && (bestID == "" || candidateID < bestID)) {
|
||||
bestID = candidateID
|
||||
bestScore = score
|
||||
|
|
@ -3231,6 +3319,20 @@ func (rr *ResourceRegistry) proxmoxNodeParentIDFromResourcesLocked(instance, clu
|
|||
return bestID
|
||||
}
|
||||
|
||||
func proxmoxNodeParentAgentScore(resource *Resource) int {
|
||||
if resource == nil {
|
||||
return 0
|
||||
}
|
||||
score := 0
|
||||
if resource.Agent != nil || hasDataSource(resource.Sources, SourceAgent) {
|
||||
score += 1000
|
||||
}
|
||||
if resource.Proxmox != nil && strings.TrimSpace(resource.Proxmox.LinkedAgentID) != "" {
|
||||
score += 500
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func proxmoxNodeParentScopeScore(instance, clusterName string, parent *ProxmoxData) int {
|
||||
if parent == nil {
|
||||
return -1
|
||||
|
|
@ -3264,6 +3366,7 @@ func (rr *ResourceRegistry) buildChildCounts() {
|
|||
// recomputing to prevent stale state after re-parenting or parent removal.
|
||||
for _, r := range rr.resources {
|
||||
r.ParentID = rr.resolveCanonicalParentID(r)
|
||||
rr.refreshLinkedAgentIDFromParentLocked(r)
|
||||
r.ChildCount = 0
|
||||
r.ParentName = ""
|
||||
}
|
||||
|
|
@ -3289,6 +3392,37 @@ func (rr *ResourceRegistry) buildChildCounts() {
|
|||
}
|
||||
}
|
||||
|
||||
func (rr *ResourceRegistry) refreshLinkedAgentIDFromParentLocked(resource *Resource) {
|
||||
if resource == nil || resource.Proxmox == nil || resource.ParentID == nil {
|
||||
return
|
||||
}
|
||||
switch CanonicalResourceType(resource.Type) {
|
||||
case ResourceTypeVM, ResourceTypeSystemContainer:
|
||||
default:
|
||||
return
|
||||
}
|
||||
parentID := CanonicalResourceID(strings.TrimSpace(*resource.ParentID))
|
||||
if parentID == "" {
|
||||
return
|
||||
}
|
||||
attachLinkedAgentID(resource.Proxmox, linkedAgentIDFromResource(rr.resources[parentID]))
|
||||
}
|
||||
|
||||
func linkedAgentIDFromResource(resource *Resource) string {
|
||||
if resource == nil {
|
||||
return ""
|
||||
}
|
||||
if resource.Proxmox != nil {
|
||||
if linkedAgentID := strings.TrimSpace(resource.Proxmox.LinkedAgentID); linkedAgentID != "" {
|
||||
return linkedAgentID
|
||||
}
|
||||
}
|
||||
if resource.Agent != nil {
|
||||
return strings.TrimSpace(resource.Agent.AgentID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (rr *ResourceRegistry) chooseNewID(resourceType ResourceType, identity ResourceIdentity, source DataSource, sourceID string) string {
|
||||
switch resourceType {
|
||||
case ResourceTypeAgent:
|
||||
|
|
|
|||
|
|
@ -1635,6 +1635,9 @@ func TestResourceRegistry_IngestSnapshotParentsClusterNamedProxmoxGuestsToMerged
|
|||
if vms[0].ParentName == "" {
|
||||
t.Fatal("expected vm parent name to be derived")
|
||||
}
|
||||
if vms[0].Proxmox == nil || vms[0].Proxmox.LinkedAgentID != "host-1" {
|
||||
t.Fatalf("expected vm linked agent host-1, got %+v", vms[0].Proxmox)
|
||||
}
|
||||
|
||||
containers := rr.ListByType(ResourceTypeSystemContainer)
|
||||
if len(containers) != 1 {
|
||||
|
|
@ -1643,6 +1646,9 @@ func TestResourceRegistry_IngestSnapshotParentsClusterNamedProxmoxGuestsToMerged
|
|||
if containers[0].ParentID == nil || *containers[0].ParentID != parentID {
|
||||
t.Fatalf("expected container parent %q, got %+v", parentID, containers[0].ParentID)
|
||||
}
|
||||
if containers[0].Proxmox == nil || containers[0].Proxmox.LinkedAgentID != "host-1" {
|
||||
t.Fatalf("expected container linked agent host-1, got %+v", containers[0].Proxmox)
|
||||
}
|
||||
|
||||
parent, ok := rr.Get(parentID)
|
||||
if !ok {
|
||||
|
|
@ -1653,6 +1659,168 @@ func TestResourceRegistry_IngestSnapshotParentsClusterNamedProxmoxGuestsToMerged
|
|||
}
|
||||
}
|
||||
|
||||
func TestResourceRegistry_ManualNodeMergeRewritesProxmoxGuestParentAndActionAgent(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
if err := store.AddLink(ResourceLink{
|
||||
ResourceA: "agent-old-proxmox-node",
|
||||
ResourceB: "agent-current",
|
||||
PrimaryID: "agent-current",
|
||||
}); err != nil {
|
||||
t.Fatalf("add link: %v", err)
|
||||
}
|
||||
rr := NewRegistry(store)
|
||||
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
|
||||
staleParentID := "agent-old-proxmox-node"
|
||||
|
||||
rr.IngestResources([]Resource{
|
||||
{
|
||||
ID: staleParentID,
|
||||
Type: ResourceTypeAgent,
|
||||
Name: "delly",
|
||||
Status: "online",
|
||||
LastSeen: now,
|
||||
Sources: []DataSource{SourceProxmox},
|
||||
SourceStatus: map[DataSource]SourceStatus{
|
||||
SourceProxmox: {Status: "online", LastSeen: now},
|
||||
},
|
||||
Proxmox: &ProxmoxData{
|
||||
SourceID: "homelab-delly",
|
||||
NodeName: "delly",
|
||||
ClusterName: "homelab",
|
||||
Instance: "delly",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "agent-current",
|
||||
Type: ResourceTypeAgent,
|
||||
Name: "delly",
|
||||
Status: "online",
|
||||
LastSeen: now,
|
||||
Sources: []DataSource{SourceAgent},
|
||||
SourceStatus: map[DataSource]SourceStatus{
|
||||
SourceAgent: {Status: "online", LastSeen: now},
|
||||
},
|
||||
Agent: &AgentData{
|
||||
AgentID: "agent-delly",
|
||||
Hostname: "delly",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "system-container-grafana",
|
||||
Type: ResourceTypeSystemContainer,
|
||||
Name: "grafana",
|
||||
Status: "online",
|
||||
LastSeen: now,
|
||||
ParentID: &staleParentID,
|
||||
Sources: []DataSource{SourceProxmox},
|
||||
SourceStatus: map[DataSource]SourceStatus{
|
||||
SourceProxmox: {Status: "online", LastSeen: now},
|
||||
},
|
||||
Proxmox: &ProxmoxData{
|
||||
SourceID: "delly:delly:124",
|
||||
NodeName: "delly",
|
||||
ClusterName: "homelab",
|
||||
Instance: "delly",
|
||||
VMID: 124,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
containers := rr.ListByType(ResourceTypeSystemContainer)
|
||||
if len(containers) != 1 {
|
||||
t.Fatalf("expected 1 container, got %d", len(containers))
|
||||
}
|
||||
if containers[0].ParentID == nil || *containers[0].ParentID != "agent-current" {
|
||||
t.Fatalf("expected container parent agent-current, got %+v", containers[0].ParentID)
|
||||
}
|
||||
if containers[0].ParentName != "delly" {
|
||||
t.Fatalf("expected container parent name delly, got %q", containers[0].ParentName)
|
||||
}
|
||||
if containers[0].Proxmox == nil || containers[0].Proxmox.LinkedAgentID != "agent-delly" {
|
||||
t.Fatalf("expected container linked agent agent-delly, got %+v", containers[0].Proxmox)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceRegistry_ProxmoxGuestParentPrefersAgentBackedNodeDuplicate(t *testing.T) {
|
||||
rr := NewRegistry(nil)
|
||||
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
rr.IngestResources([]Resource{
|
||||
{
|
||||
ID: "agent-current",
|
||||
Type: ResourceTypeAgent,
|
||||
Name: "delly",
|
||||
Status: "online",
|
||||
LastSeen: now,
|
||||
Sources: []DataSource{SourceAgent, SourceProxmox},
|
||||
SourceStatus: map[DataSource]SourceStatus{
|
||||
SourceAgent: {Status: "online", LastSeen: now},
|
||||
SourceProxmox: {Status: "online", LastSeen: now},
|
||||
},
|
||||
Agent: &AgentData{
|
||||
AgentID: "agent-delly",
|
||||
Hostname: "delly",
|
||||
},
|
||||
Proxmox: &ProxmoxData{
|
||||
SourceID: "homelab-delly",
|
||||
NodeName: "delly",
|
||||
ClusterName: "homelab",
|
||||
Instance: "delly",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "agent-old-proxmox-node",
|
||||
Type: ResourceTypeAgent,
|
||||
Name: "delly",
|
||||
Status: "online",
|
||||
LastSeen: now,
|
||||
Sources: []DataSource{SourceProxmox},
|
||||
SourceStatus: map[DataSource]SourceStatus{
|
||||
SourceProxmox: {Status: "online", LastSeen: now},
|
||||
},
|
||||
Proxmox: &ProxmoxData{
|
||||
SourceID: "homelab-delly",
|
||||
NodeName: "delly",
|
||||
ClusterName: "homelab",
|
||||
Instance: "delly",
|
||||
},
|
||||
},
|
||||
})
|
||||
rr.IngestRecords(SourceProxmox, []IngestRecord{
|
||||
{
|
||||
SourceID: "delly:delly:124",
|
||||
ParentSourceID: "homelab-delly",
|
||||
Resource: Resource{
|
||||
Type: ResourceTypeSystemContainer,
|
||||
Name: "grafana",
|
||||
Status: "online",
|
||||
LastSeen: now,
|
||||
Proxmox: &ProxmoxData{
|
||||
SourceID: "delly:delly:124",
|
||||
NodeName: "delly",
|
||||
ClusterName: "homelab",
|
||||
Instance: "delly",
|
||||
VMID: 124,
|
||||
},
|
||||
},
|
||||
Identity: ResourceIdentity{
|
||||
Hostnames: []string{"grafana"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
containers := rr.ListByType(ResourceTypeSystemContainer)
|
||||
if len(containers) != 1 {
|
||||
t.Fatalf("expected 1 container, got %d", len(containers))
|
||||
}
|
||||
if containers[0].ParentID == nil || *containers[0].ParentID != "agent-current" {
|
||||
t.Fatalf("expected container parent agent-current, got %+v", containers[0].ParentID)
|
||||
}
|
||||
if containers[0].Proxmox == nil || containers[0].Proxmox.LinkedAgentID != "agent-delly" {
|
||||
t.Fatalf("expected container linked agent agent-delly, got %+v", containers[0].Proxmox)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceRegistry_IngestSnapshotDerivesProxmoxWorkloadParentWhenNodeSourceIDUsesPreviousClusterAlias(t *testing.T) {
|
||||
rr := NewRegistry(nil)
|
||||
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
|
||||
|
|
|
|||
|
|
@ -18,12 +18,13 @@ type Resource struct {
|
|||
LastSeen time.Time `json:"lastSeen"`
|
||||
UpdatedAt time.Time `json:"updatedAt,omitempty"`
|
||||
|
||||
DiscoveryTarget *DiscoveryTarget `json:"discoveryTarget,omitempty"`
|
||||
MetricsTarget *MetricsTarget `json:"metricsTarget,omitempty"`
|
||||
Canonical *CanonicalIdentity `json:"canonicalIdentity,omitempty"`
|
||||
Policy *ResourcePolicy `json:"policy,omitempty"`
|
||||
AISafeSummary string `json:"aiSafeSummary,omitempty"`
|
||||
PlatformScopes []string `json:"platformScopes,omitempty"`
|
||||
DiscoveryTarget *DiscoveryTarget `json:"discoveryTarget,omitempty"`
|
||||
DiscoveryReadiness *ResourceDiscoveryReadiness `json:"discoveryReadiness,omitempty"`
|
||||
MetricsTarget *MetricsTarget `json:"metricsTarget,omitempty"`
|
||||
Canonical *CanonicalIdentity `json:"canonicalIdentity,omitempty"`
|
||||
Policy *ResourcePolicy `json:"policy,omitempty"`
|
||||
AISafeSummary string `json:"aiSafeSummary,omitempty"`
|
||||
PlatformScopes []string `json:"platformScopes,omitempty"`
|
||||
|
||||
Sources []DataSource `json:"sources"`
|
||||
SourceStatus map[DataSource]SourceStatus `json:"sourceStatus,omitempty"`
|
||||
|
|
@ -97,6 +98,42 @@ type DiscoveryTarget struct {
|
|||
Hostname string `json:"hostname,omitempty"`
|
||||
}
|
||||
|
||||
// ResourceDiscoveryReadiness summarizes whether service discovery has useful,
|
||||
// recent grounding for a resource. It is intentionally metadata-only: raw
|
||||
// command output, environment variables, config files, and secrets do not
|
||||
// belong in this projection.
|
||||
type ResourceDiscoveryReadiness struct {
|
||||
State ResourceDiscoveryReadinessState `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
ResourceType string `json:"resourceType,omitempty"`
|
||||
TargetID string `json:"targetId,omitempty"`
|
||||
ResourceID string `json:"resourceId,omitempty"`
|
||||
DiscoveryID string `json:"discoveryId,omitempty"`
|
||||
ObservedAt *time.Time `json:"observedAt,omitempty"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
AgeSeconds int64 `json:"ageSeconds,omitempty"`
|
||||
StaleAfterSeconds int64 `json:"staleAfterSeconds,omitempty"`
|
||||
FactCount int `json:"factCount,omitempty"`
|
||||
ServiceName string `json:"serviceName,omitempty"`
|
||||
ServiceCategory string `json:"serviceCategory,omitempty"`
|
||||
Confidence float64 `json:"confidence,omitempty"`
|
||||
}
|
||||
|
||||
// ResourceDiscoveryReadinessState is the canonical state vocabulary shared by
|
||||
// API payloads, Assistant handoffs, and frontend badges.
|
||||
type ResourceDiscoveryReadinessState string
|
||||
|
||||
const (
|
||||
ResourceDiscoveryReadinessFresh ResourceDiscoveryReadinessState = "fresh"
|
||||
ResourceDiscoveryReadinessStale ResourceDiscoveryReadinessState = "stale"
|
||||
ResourceDiscoveryReadinessMissing ResourceDiscoveryReadinessState = "missing"
|
||||
ResourceDiscoveryReadinessRunning ResourceDiscoveryReadinessState = "running"
|
||||
ResourceDiscoveryReadinessFailed ResourceDiscoveryReadinessState = "failed"
|
||||
ResourceDiscoveryReadinessUnavailable ResourceDiscoveryReadinessState = "unavailable"
|
||||
ResourceDiscoveryReadinessUnsupported ResourceDiscoveryReadinessState = "unsupported"
|
||||
)
|
||||
|
||||
// MetricsTarget describes the resource type and ID to use when querying
|
||||
// the metrics history endpoint for this unified resource.
|
||||
type MetricsTarget struct {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue