mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Surface blocked workload inventory sources
Show workload-capable source failures on Workloads and keep matching Proxmox host agents attached to their API source when inventory collection is blocked.
This commit is contained in:
parent
649a601dca
commit
321f563a52
17 changed files with 715 additions and 12 deletions
|
|
@ -2959,6 +2959,26 @@
|
|||
"path": "frontend-modern/src/utils/alertOverviewPresentation.ts",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/utils/connectionErrorPresentation.ts",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/connections_grouping.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/connections_grouping_test.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/contract_test.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/resources.go",
|
||||
|
|
@ -3159,16 +3179,36 @@
|
|||
"path": "frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/components/Workloads/__tests__/workloadInventorySourceIssues.test.ts",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/components/Workloads/useWorkloadsState.ts",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/components/Workloads/workloadSelectors.ts",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/components/Workloads/WorkloadsStateCards.tsx",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/components/Workloads/WorkloadsSurface.tsx",
|
||||
|
|
|
|||
|
|
@ -556,6 +556,11 @@ profile and assignment columns, but embedded table framing must route through
|
|||
columns: the landing table reuses the `System` and `Endpoint` cells for a
|
||||
short host fact such as OS/platform plus a reported endpoint, while the
|
||||
richer agent identity facts stay in the edit/detail surface.
|
||||
Exact Proxmox host-agent attachments remain backend-authored through
|
||||
`/api/connections` even when Proxmox workload inventory is blocked by
|
||||
credentials or reachability; lifecycle surfaces must render that attached
|
||||
agent as part of the API source row and must not re-offer host install or
|
||||
standalone lifecycle actions for the same physical host.
|
||||
Public demo and other read-only settings posture must stay reporting-first
|
||||
on that same lifecycle-owned workspace boundary: infrastructure workspace
|
||||
routes may retain install and platform setup surfaces for manageable
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ product API routes free of maintainer commercial analytics.
|
|||
59. `internal/api/connections_handlers.go`
|
||||
60. `internal/api/connections_probe.go`
|
||||
61. `frontend-modern/src/api/connections.ts`
|
||||
62. `frontend-modern/src/utils/connectionErrorPresentation.ts`
|
||||
62. `frontend-modern/src/api/hostedSignup.ts`
|
||||
63. `internal/api/availability_handlers.go`
|
||||
64. `frontend-modern/src/api/availabilityTargets.ts`
|
||||
|
|
@ -856,7 +857,12 @@ the canonical monitored-system blocked payload.
|
|||
discovery and settings surfaces can reconcile hostname-only and IP-only
|
||||
views of the same enrolled machine instead of showing a second
|
||||
"discovered" candidate row for an already represented source member or
|
||||
API-plus-agent source row. Agent-backed
|
||||
API-plus-agent source row. Exact host-agent matches for a configured
|
||||
Proxmox primary source must also attach through the backend systems
|
||||
payload when workload node inventory is absent because credentials or
|
||||
reachability are blocked, so the infrastructure ledger does not split the
|
||||
same physical host into an API row plus a standalone host-agent row.
|
||||
Agent-backed
|
||||
connections also own canonical version/update facts on that same payload:
|
||||
when a source or attachment is backed by Pulse Agent, `/api/connections`
|
||||
carries the
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@ regression protection.
|
|||
5. `internal/api/slo_bench_test.go`
|
||||
6. `frontend-modern/src/components/Workloads/WorkloadsSurface.tsx`
|
||||
7. `frontend-modern/src/components/Workloads/WorkloadsStateCards.tsx`
|
||||
8. `frontend-modern/src/components/Workloads/WorkloadsStatsStrip.tsx`
|
||||
8. `frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts`
|
||||
9. `frontend-modern/src/components/Workloads/__tests__/workloadInventorySourceIssues.test.ts`
|
||||
10. `frontend-modern/src/components/Workloads/WorkloadsStatsStrip.tsx`
|
||||
9. `frontend-modern/src/components/Workloads/WorkloadsTable.tsx`
|
||||
10. `frontend-modern/src/components/Workloads/WorkloadPanel.tsx`
|
||||
11. `frontend-modern/src/components/Workloads/WorkloadTableHeader.tsx`
|
||||
|
|
@ -209,6 +211,14 @@ regression protection.
|
|||
`platformData.sources` hints, and must not reconstruct platform identity by
|
||||
scanning sibling resources or making row-time API calls.
|
||||
5. Extend workload hot-path filter, sort, grouping, and stats math through `frontend-modern/src/components/Workloads/workloadSelectors.ts`, and extend workload identity, discovery routing, and node-topology helpers through `frontend-modern/src/components/Workloads/workloadTopology.ts`, rather than duplicating selector or topology logic in `frontend-modern/src/components/Workloads/WorkloadsSurface.tsx`
|
||||
Workloads source-health messaging must derive from the canonical
|
||||
`/api/connections` ledger through
|
||||
`frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts`.
|
||||
The Workloads page may show a bounded partial-inventory banner when a
|
||||
configured workload-capable source is unauthorized, unreachable, stale,
|
||||
pending, or paused, but it must not fabricate VM/container rows from host
|
||||
telemetry or hide source blockers just because another source still has
|
||||
workload rows.
|
||||
The retired dashboard overview route must not return as a hot-path
|
||||
orientation shortcut. First-viewport system count, health, source coverage,
|
||||
and freshness now belong to Infrastructure and Add infrastructure, derived
|
||||
|
|
|
|||
|
|
@ -590,6 +590,10 @@ bypass the API fail-closed execution gate.
|
|||
unknown because no trustworthy applied fingerprint exists, storage and
|
||||
recovery must preserve that uncertainty instead of translating it into a
|
||||
storage-local current/drifted verdict.
|
||||
When `/api/connections` attaches an exact-match host agent to a blocked
|
||||
Proxmox API source without fresh node inventory, storage and recovery must
|
||||
treat that as one represented source with host telemetry, not as a second
|
||||
protected system or a storage-local duplicate host.
|
||||
22. Keep backend-native platform actions on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app control for Assistant, storage and recovery may consume the refreshed recovery points afterward, but they must not grow a parallel recovery-local action transport or action-specific payload shape.
|
||||
23. Keep backend-native platform diagnostics on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app log reads for Assistant, storage and recovery may use those diagnostics during investigation, but they must not grow a parallel recovery-local log transport or diagnostic payload shape.
|
||||
24. Keep backend-native platform configuration reads on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app config for Assistant, storage and recovery may use that runtime shape during investigation, but they must not grow a parallel recovery-local config transport or provider-shaped configuration payload.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Show } from 'solid-js';
|
||||
import { For, Show } from 'solid-js';
|
||||
|
||||
import {
|
||||
buildInfrastructureOnboardingPath,
|
||||
|
|
@ -25,6 +25,7 @@ type WorkloadsStateCardsProps = Pick<
|
|||
| 'kioskMode'
|
||||
| 'navigate'
|
||||
| 'reconnect'
|
||||
| 'workloadInventoryIssues'
|
||||
| 'workloads'
|
||||
>;
|
||||
|
||||
|
|
@ -61,6 +62,66 @@ export function WorkloadsStateCards(props: WorkloadsStateCardsProps) {
|
|||
</Card>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={
|
||||
props.connected() &&
|
||||
props.initialDataReceived() &&
|
||||
!props.workloads.loading() &&
|
||||
props.workloadInventoryIssues().length > 0
|
||||
}
|
||||
>
|
||||
<div
|
||||
role="alert"
|
||||
class="mb-3 rounded-md border border-amber-400/40 bg-amber-50 px-4 py-3 text-sm text-amber-950 shadow-sm dark:border-amber-500/30 dark:bg-amber-950/30 dark:text-amber-100"
|
||||
>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div>
|
||||
<p class="text-sm font-semibold">Workload inventory is incomplete</p>
|
||||
<p class="mt-1 text-xs text-amber-900/80 dark:text-amber-100/75">
|
||||
One or more configured workload sources cannot currently report inventory.
|
||||
</p>
|
||||
</div>
|
||||
<ul class="space-y-1.5">
|
||||
<For each={props.workloadInventoryIssues().slice(0, 3)}>
|
||||
{(issue) => (
|
||||
<li class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-amber-950 dark:text-amber-50">
|
||||
{issue.name}
|
||||
</span>
|
||||
<span class="rounded border border-amber-400/40 px-1.5 py-0.5 text-[11px] font-medium uppercase text-amber-900 dark:border-amber-400/30 dark:text-amber-100">
|
||||
{issue.stateLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-0.5 text-xs text-amber-900/85 dark:text-amber-100/80">
|
||||
{issue.description}
|
||||
</p>
|
||||
<Show when={issue.detail}>
|
||||
{(detail) => (
|
||||
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/65">
|
||||
{detail()}
|
||||
</p>
|
||||
)}
|
||||
</Show>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
<Show when={!props.kioskMode()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.navigate(buildInfrastructureWorkspacePath())}
|
||||
class="inline-flex shrink-0 items-center justify-center rounded-md border border-amber-500/40 bg-amber-100 px-3 py-1.5 text-xs font-medium text-amber-950 transition-colors hover:bg-amber-200 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 dark:border-amber-400/30 dark:bg-amber-900/50 dark:text-amber-50 dark:hover:bg-amber-900"
|
||||
>
|
||||
Review sources
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={
|
||||
props.connected() &&
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ export function WorkloadsSurface(props: WorkloadsSurfaceProps) {
|
|||
kioskMode={state.kioskMode}
|
||||
navigate={state.navigate}
|
||||
reconnect={state.reconnectSurface}
|
||||
workloadInventoryIssues={state.workloadInventoryIssues}
|
||||
workloads={state.workloads}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ describe('WorkloadsStateCards', () => {
|
|||
kioskMode={() => false}
|
||||
navigate={navigate}
|
||||
reconnect={() => undefined}
|
||||
workloadInventoryIssues={() => []}
|
||||
workloads={{
|
||||
workloads: [] as any,
|
||||
loading: () => false,
|
||||
|
|
@ -96,6 +97,7 @@ describe('WorkloadsStateCards', () => {
|
|||
kioskMode={() => false}
|
||||
navigate={navigate}
|
||||
reconnect={() => undefined}
|
||||
workloadInventoryIssues={() => []}
|
||||
workloads={{
|
||||
workloads: [] as any,
|
||||
loading: () => false,
|
||||
|
|
@ -112,4 +114,76 @@ describe('WorkloadsStateCards', () => {
|
|||
|
||||
expect(navigate).toHaveBeenCalledWith('/settings/infrastructure');
|
||||
});
|
||||
|
||||
it('surfaces partial workload inventory blockers even when other guests exist', () => {
|
||||
const navigate = vi.fn();
|
||||
|
||||
render(() => (
|
||||
<WorkloadsStateCards
|
||||
allGuests={() => [{ id: 'guest-1' } as any]}
|
||||
connected={() => true}
|
||||
workloadsDisconnectedState={() => ({
|
||||
title: 'Connection lost',
|
||||
description: 'Unable to connect to the backend server',
|
||||
actionLabel: 'Reconnect now',
|
||||
})}
|
||||
workloadsGuestsEmptyState={() => ({
|
||||
title: 'No guests found',
|
||||
description: 'No guests match your current filters',
|
||||
})}
|
||||
workloadsInfrastructureEmptyState={() => ({
|
||||
title: 'No infrastructure sources connected',
|
||||
description:
|
||||
'Start in Settings → Infrastructure by choosing a source strategy. Connect a platform API for inventory and health, install Pulse Agent for host telemetry, or use both when you want full coverage.',
|
||||
actionLabel: 'Add infrastructure source',
|
||||
})}
|
||||
workloadsLoadingState={() => ({
|
||||
title: 'Loading workloads...',
|
||||
description: 'Connecting to monitoring service',
|
||||
})}
|
||||
workloadsNoInventoryState={() => ({
|
||||
title: 'No workload inventory available',
|
||||
description:
|
||||
'Pulse has infrastructure sources, but no VM, container, or pod inventory is available right now. Review source credentials, permissions, and collection status in Settings → Infrastructure.',
|
||||
actionLabel: 'Review infrastructure sources',
|
||||
})}
|
||||
filteredGuests={() => [{ id: 'guest-1' } as any]}
|
||||
hasInfrastructureSources={() => true}
|
||||
infrastructureSourceStateReady={() => true}
|
||||
initialDataReceived={() => true}
|
||||
kioskMode={() => false}
|
||||
navigate={navigate}
|
||||
reconnect={() => undefined}
|
||||
workloadInventoryIssues={() => [
|
||||
{
|
||||
id: 'pve:delly',
|
||||
name: 'delly',
|
||||
typeLabel: 'Proxmox VE',
|
||||
state: 'unauthorized',
|
||||
stateLabel: 'Credentials invalid',
|
||||
coverageLabel: 'VMs and containers',
|
||||
description:
|
||||
'Pulse has VMs and containers enabled for delly, but its Proxmox VE API credentials are invalid.',
|
||||
detail: 'Authentication failed. Re-check the API token or username/password.',
|
||||
},
|
||||
]}
|
||||
workloads={{
|
||||
workloads: [{ id: 'guest-1' }] as any,
|
||||
loading: () => false,
|
||||
refetch: async () => [],
|
||||
mutate: (value) => (Array.isArray(value) ? value : value([])),
|
||||
error: () => null,
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('Workload inventory is incomplete')).toBeInTheDocument();
|
||||
expect(screen.getByText('delly')).toBeInTheDocument();
|
||||
expect(screen.getByText('Credentials invalid')).toBeInTheDocument();
|
||||
expect(screen.queryByText('No workload inventory available')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Review sources' }));
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith('/settings/infrastructure');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import workloadsWorkloadViewportSyncSource from '../useWorkloadViewportSync.ts?r
|
|||
import workloadsWorkloadRouteStateSource from '../useWorkloadRouteState.ts?raw';
|
||||
import workloadsWorkloadUrlSyncSource from '../useWorkloadUrlSync.ts?raw';
|
||||
import workloadsStateSource from '../useWorkloadsState.ts?raw';
|
||||
import workloadInventorySourceIssuesSource from '../workloadInventorySourceIssues.ts?raw';
|
||||
import groupedTableWindowingSource from '../useGroupedTableWindowing.ts?raw';
|
||||
import thresholdSliderSource from '../ThresholdSlider.tsx?raw';
|
||||
import thresholdSliderModelSource from '../thresholdSliderModel.ts?raw';
|
||||
|
|
@ -173,6 +174,12 @@ vi.mock('@/hooks/useUnifiedResources', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/api/connections', () => ({
|
||||
ConnectionsAPI: {
|
||||
list: vi.fn().mockResolvedValue({ connections: [], systems: [] }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/api/guestMetadata', () => ({
|
||||
GuestMetadataAPI: { getAllMetadata: vi.fn().mockResolvedValue({}) },
|
||||
}));
|
||||
|
|
@ -656,6 +663,10 @@ describe('Workloads performance contract', () => {
|
|||
expect(workloadsStateSource).toContain('useWorkloadSelectionState');
|
||||
expect(workloadsStateSource).toContain('useWorkloadsDerivedState');
|
||||
expect(workloadsStateSource).toContain('useWorkloadRouteState');
|
||||
expect(workloadsStateSource).toContain('buildWorkloadInventorySourceIssues');
|
||||
expect(workloadsStateCardsSource).toContain('workloadInventoryIssues');
|
||||
expect(workloadInventorySourceIssuesSource).toContain('WORKLOAD_CAPABLE_TYPES');
|
||||
expect(workloadInventorySourceIssuesSource).toContain('formatConnectionErrorMessage');
|
||||
expect(workloadsStateSource).toContain('createWorkloadSortComparator');
|
||||
expect(workloadsStateSource).toContain('filterWorkloads(params)');
|
||||
expect(workloadsStateSource).not.toContain('useBreakpoint');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { Connection } from '@/api/connections';
|
||||
import { buildWorkloadInventorySourceIssues } from '../workloadInventorySourceIssues';
|
||||
|
||||
const connection = (overrides: Partial<Connection>): Connection =>
|
||||
({
|
||||
id: 'pve:delly',
|
||||
type: 'pve',
|
||||
name: 'delly',
|
||||
address: 'https://delly:8006',
|
||||
state: 'active',
|
||||
enabled: true,
|
||||
surfaces: ['vms', 'containers', 'storage', 'backups'],
|
||||
scope: { vms: true, containers: true, storage: true, backups: true },
|
||||
lastSeen: null,
|
||||
lastError: null,
|
||||
source: 'agent',
|
||||
fleet: {
|
||||
enrollmentState: 'configured',
|
||||
livenessState: 'active',
|
||||
versionDrift: 'not-applicable',
|
||||
adapterHealth: 'healthy',
|
||||
configRollout: 'configured',
|
||||
credentialStatus: 'verified',
|
||||
updateStatus: 'not-applicable',
|
||||
remoteControl: 'not-applicable',
|
||||
},
|
||||
capabilities: {
|
||||
supportsPause: true,
|
||||
supportsScope: true,
|
||||
supportsTest: true,
|
||||
},
|
||||
...overrides,
|
||||
}) as Connection;
|
||||
|
||||
describe('buildWorkloadInventorySourceIssues', () => {
|
||||
it('reports enabled workload-capable sources with invalid credentials', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({
|
||||
state: 'unauthorized',
|
||||
fleet: {
|
||||
enrollmentState: 'configured',
|
||||
livenessState: 'unauthorized',
|
||||
versionDrift: 'not-applicable',
|
||||
adapterHealth: 'blocked',
|
||||
configRollout: 'configured',
|
||||
credentialStatus: 'invalid',
|
||||
updateStatus: 'not-applicable',
|
||||
remoteControl: 'not-applicable',
|
||||
},
|
||||
lastError: {
|
||||
at: '2026-05-13T23:58:54Z',
|
||||
message: 'Authentication failed - check API token or credentials',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(issues).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'pve:delly',
|
||||
name: 'delly',
|
||||
stateLabel: 'Credentials invalid',
|
||||
coverageLabel: 'VMs and containers',
|
||||
description:
|
||||
'Pulse has VMs and containers enabled for delly, but its Proxmox VE API credentials are invalid.',
|
||||
detail: 'Authentication failed. Re-check the API token or username/password.',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores active, disabled, and non-workload sources', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({ id: 'pve:pi', name: 'pi', state: 'active' }),
|
||||
connection({ id: 'pve:paused', enabled: false, state: 'unauthorized' }),
|
||||
connection({
|
||||
id: 'pbs:tower',
|
||||
type: 'pbs',
|
||||
name: 'pbs-docker',
|
||||
state: 'unreachable',
|
||||
surfaces: ['backups'],
|
||||
scope: { backups: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { createEffect, createMemo } from 'solid-js';
|
||||
import { createEffect, createMemo, createResource, onCleanup } from 'solid-js';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import { ConnectionsAPI, type ConnectionsListResponse } from '@/api/connections';
|
||||
import type { VM, Container, Node } from '@/types/api';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import { useWebSocket } from '@/contexts/appRuntime';
|
||||
|
|
@ -28,9 +29,15 @@ import { useWorkloadGuestMetadataState } from './useWorkloadGuestMetadataState';
|
|||
import { useWorkloadSelectionState } from './useWorkloadSelectionState';
|
||||
import { useWorkloadsDerivedState } from './useWorkloadsDerivedState';
|
||||
import { useWorkloadRouteState } from './useWorkloadRouteState';
|
||||
import { buildWorkloadInventorySourceIssues } from './workloadInventorySourceIssues';
|
||||
|
||||
const WORKLOADS_INFRASTRUCTURE_SOURCES_QUERY =
|
||||
'type=agent,docker-host,k8s-cluster,k8s-node,pbs,pmg,storage,physical_disk,ceph';
|
||||
const WORKLOADS_CONNECTIONS_POLL_INTERVAL_MS = 15000;
|
||||
const EMPTY_CONNECTIONS_RESPONSE: ConnectionsListResponse = {
|
||||
connections: [],
|
||||
systems: [],
|
||||
};
|
||||
|
||||
export interface WorkloadsSurfaceProps {
|
||||
vms: VM[];
|
||||
|
|
@ -59,6 +66,15 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
|||
cacheKey: 'workloads-infrastructure-sources',
|
||||
enabled: workloadsEnabled,
|
||||
});
|
||||
const connectionsResourceKey = createMemo(() => (workloadsEnabled() ? 'enabled' : null));
|
||||
const [connectionsSnapshot, { refetch: refetchConnectionsSnapshot }] =
|
||||
createResource<ConnectionsListResponse, string | null>(
|
||||
connectionsResourceKey,
|
||||
async () => ConnectionsAPI.list(),
|
||||
{
|
||||
initialValue: EMPTY_CONNECTIONS_RESPONSE,
|
||||
},
|
||||
);
|
||||
|
||||
const dedupeGuests = (guests: WorkloadGuest[]): WorkloadGuest[] => {
|
||||
const seen = new Set<string>();
|
||||
|
|
@ -159,6 +175,9 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
|||
const workloadsDisconnectedState = createMemo(() =>
|
||||
getWorkloadsDisconnectedState(reconnecting()),
|
||||
);
|
||||
const workloadInventoryIssues = createMemo(() =>
|
||||
buildWorkloadInventorySourceIssues(connectionsSnapshot()?.connections ?? []),
|
||||
);
|
||||
const hasWorkloadsData = createMemo(() => allGuests().length > 0);
|
||||
const hasInfrastructureSources = createMemo(() =>
|
||||
workloadsEnabled()
|
||||
|
|
@ -183,10 +202,19 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
|||
const reconnectSurface = () => {
|
||||
if (workloadsEnabled()) {
|
||||
void workloads.refetch();
|
||||
void refetchConnectionsSnapshot();
|
||||
}
|
||||
reconnect();
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (!workloadsEnabled()) return;
|
||||
const handle = window.setInterval(() => {
|
||||
void refetchConnectionsSnapshot();
|
||||
}, WORKLOADS_CONNECTIONS_POLL_INTERVAL_MS);
|
||||
onCleanup(() => window.clearInterval(handle));
|
||||
});
|
||||
|
||||
let lastConnected = connected();
|
||||
let hasSeenConnectedState = connected();
|
||||
createEffect(() => {
|
||||
|
|
@ -392,6 +420,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
|||
workloadsSummaryFallbackSnapshots,
|
||||
workloadsSummaryRange,
|
||||
workloadsSummaryVisibleIds,
|
||||
workloadInventoryIssues,
|
||||
workloadsNoInventoryState,
|
||||
ws,
|
||||
groupingMode,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,165 @@
|
|||
import type { Connection, ConnectionState, ConnectionType } from '@/api/connections';
|
||||
import { formatConnectionErrorMessage } from '@/utils/connectionErrorPresentation';
|
||||
|
||||
export interface WorkloadInventorySourceIssue {
|
||||
id: string;
|
||||
name: string;
|
||||
typeLabel: string;
|
||||
state: ConnectionState;
|
||||
stateLabel: string;
|
||||
coverageLabel: string;
|
||||
description: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
const WORKLOAD_CAPABLE_TYPES: ReadonlySet<ConnectionType> = new Set([
|
||||
'pve',
|
||||
'vmware',
|
||||
'docker',
|
||||
'kubernetes',
|
||||
]);
|
||||
|
||||
const WORKLOAD_SURFACE_LABELS: Record<string, string> = {
|
||||
containers: 'containers',
|
||||
docker: 'containers',
|
||||
kubernetes: 'Kubernetes workloads',
|
||||
pods: 'pods',
|
||||
vms: 'VMs',
|
||||
};
|
||||
const WORKLOAD_SURFACE_ORDER = ['vms', 'containers', 'docker', 'pods', 'kubernetes'];
|
||||
|
||||
const CONNECTION_TYPE_LABELS: Partial<Record<ConnectionType, string>> = {
|
||||
docker: 'Docker',
|
||||
kubernetes: 'Kubernetes',
|
||||
pve: 'Proxmox VE',
|
||||
vmware: 'VMware vCenter',
|
||||
};
|
||||
|
||||
const STATE_RANK: Record<ConnectionState, number> = {
|
||||
active: 0,
|
||||
paused: 1,
|
||||
pending: 2,
|
||||
stale: 3,
|
||||
unauthorized: 4,
|
||||
unreachable: 5,
|
||||
};
|
||||
|
||||
const BLOCKING_STATES: ReadonlySet<ConnectionState> = new Set([
|
||||
'paused',
|
||||
'pending',
|
||||
'stale',
|
||||
'unauthorized',
|
||||
'unreachable',
|
||||
]);
|
||||
|
||||
const credentialInvalid = (connection: Connection): boolean =>
|
||||
connection.state === 'unauthorized' ||
|
||||
connection.fleet?.credentialStatus === 'invalid' ||
|
||||
connection.fleet?.credentialHealth?.status === 'invalid' ||
|
||||
connection.fleet?.credentialHealth?.status === 'expired';
|
||||
|
||||
const activeWorkloadSurfaces = (connection: Connection): string[] => {
|
||||
const scope = connection.scope ?? {};
|
||||
const scoped = Object.keys(scope).filter((surface) => scope[surface]);
|
||||
const surfaces = scoped.length > 0 ? scoped : connection.surfaces ?? [];
|
||||
const seen = new Set<string>();
|
||||
const labels: string[] = [];
|
||||
const orderedSurfaces = [...surfaces].sort((left, right) => {
|
||||
const leftRank = WORKLOAD_SURFACE_ORDER.indexOf(left);
|
||||
const rightRank = WORKLOAD_SURFACE_ORDER.indexOf(right);
|
||||
const normalizedLeftRank = leftRank === -1 ? WORKLOAD_SURFACE_ORDER.length : leftRank;
|
||||
const normalizedRightRank = rightRank === -1 ? WORKLOAD_SURFACE_ORDER.length : rightRank;
|
||||
if (normalizedLeftRank !== normalizedRightRank) return normalizedLeftRank - normalizedRightRank;
|
||||
return left.localeCompare(right);
|
||||
});
|
||||
for (const surface of orderedSurfaces) {
|
||||
const label = WORKLOAD_SURFACE_LABELS[surface];
|
||||
if (!label || seen.has(label)) continue;
|
||||
seen.add(label);
|
||||
labels.push(label);
|
||||
}
|
||||
return labels;
|
||||
};
|
||||
|
||||
const formatCoverage = (labels: readonly string[]): string => {
|
||||
if (labels.length === 0) return 'workload inventory';
|
||||
if (labels.length === 1) return labels[0] ?? 'workload inventory';
|
||||
if (labels.length === 2) return `${labels[0]} and ${labels[1]}`;
|
||||
return `${labels.slice(0, -1).join(', ')}, and ${labels[labels.length - 1]}`;
|
||||
};
|
||||
|
||||
const stateLabelFor = (connection: Connection): string => {
|
||||
if (credentialInvalid(connection)) return 'Credentials invalid';
|
||||
switch (connection.state) {
|
||||
case 'paused':
|
||||
return 'Collection paused';
|
||||
case 'pending':
|
||||
return 'Collection pending';
|
||||
case 'stale':
|
||||
return 'Collection stale';
|
||||
case 'unreachable':
|
||||
return 'Source unreachable';
|
||||
case 'unauthorized':
|
||||
return 'Credentials invalid';
|
||||
default:
|
||||
return 'Collection blocked';
|
||||
}
|
||||
};
|
||||
|
||||
const descriptionFor = (
|
||||
connection: Connection,
|
||||
typeLabel: string,
|
||||
coverageLabel: string,
|
||||
): string => {
|
||||
if (credentialInvalid(connection)) {
|
||||
return `Pulse has ${coverageLabel} enabled for ${connection.name}, but its ${typeLabel} API credentials are invalid.`;
|
||||
}
|
||||
switch (connection.state) {
|
||||
case 'paused':
|
||||
return `Pulse has ${coverageLabel} enabled for ${connection.name}, but collection is paused.`;
|
||||
case 'pending':
|
||||
return `Pulse has ${coverageLabel} enabled for ${connection.name}, but collection has not completed yet.`;
|
||||
case 'stale':
|
||||
return `Pulse has ${coverageLabel} enabled for ${connection.name}, but the last inventory data is stale.`;
|
||||
case 'unreachable':
|
||||
return `Pulse has ${coverageLabel} enabled for ${connection.name}, but the ${typeLabel} API is unreachable.`;
|
||||
default:
|
||||
return `Pulse has ${coverageLabel} enabled for ${connection.name}, but collection is blocked.`;
|
||||
}
|
||||
};
|
||||
|
||||
const compactDetail = (raw?: string | null): string | undefined => {
|
||||
const formatted = formatConnectionErrorMessage(raw);
|
||||
if (!formatted) return undefined;
|
||||
return formatted.length > 220 ? `${formatted.slice(0, 217)}...` : formatted;
|
||||
};
|
||||
|
||||
const connectionHasWorkloadCoverage = (connection: Connection): boolean =>
|
||||
WORKLOAD_CAPABLE_TYPES.has(connection.type) && activeWorkloadSurfaces(connection).length > 0;
|
||||
|
||||
export const buildWorkloadInventorySourceIssues = (
|
||||
connections: readonly Connection[],
|
||||
): WorkloadInventorySourceIssue[] =>
|
||||
connections
|
||||
.filter((connection) => connection.enabled)
|
||||
.filter(connectionHasWorkloadCoverage)
|
||||
.filter((connection) => BLOCKING_STATES.has(connection.state) || credentialInvalid(connection))
|
||||
.map((connection) => {
|
||||
const coverageLabel = formatCoverage(activeWorkloadSurfaces(connection));
|
||||
const typeLabel = CONNECTION_TYPE_LABELS[connection.type] ?? connection.type;
|
||||
return {
|
||||
id: connection.id,
|
||||
name: connection.name,
|
||||
typeLabel,
|
||||
state: connection.state,
|
||||
stateLabel: stateLabelFor(connection),
|
||||
coverageLabel,
|
||||
description: descriptionFor(connection, typeLabel, coverageLabel),
|
||||
detail: compactDetail(connection.lastError?.message ?? connection.stateReason),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const stateDelta = STATE_RANK[right.state] - STATE_RANK[left.state];
|
||||
if (stateDelta !== 0) return stateDelta;
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
|
|
@ -68,7 +68,7 @@ const HUMANIZED_PATTERNS: { match: RegExp; headline: string; hint: string | null
|
|||
hint: TLS_HINT,
|
||||
},
|
||||
{
|
||||
match: /\b401\b|unauthorized|invalid (?:credentials|api token)/i,
|
||||
match: /\b401\b|unauthorized|authentication failed|invalid (?:credentials|api token)/i,
|
||||
headline: 'Authentication failed',
|
||||
hint: AUTH_HINT,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -538,6 +538,18 @@ func resolveAgentAttachments(
|
|||
}
|
||||
}
|
||||
|
||||
for _, agent := range sortedConnectionsByID(connectionByID) {
|
||||
if agent.Type != ConnectionTypeAgent {
|
||||
continue
|
||||
}
|
||||
if _, exists := attachments[agent.ID]; exists {
|
||||
continue
|
||||
}
|
||||
if primaryID := directProxmoxHostAttachment(agent, connectionByID); primaryID != "" {
|
||||
register(agent.ID, primaryID, false, false)
|
||||
}
|
||||
}
|
||||
|
||||
for agentID, primaryID := range attachments {
|
||||
if primaryID == "" {
|
||||
delete(attachments, agentID)
|
||||
|
|
@ -547,6 +559,41 @@ func resolveAgentAttachments(
|
|||
return attachments
|
||||
}
|
||||
|
||||
func sortedConnectionsByID(connectionByID map[string]Connection) []Connection {
|
||||
connections := make([]Connection, 0, len(connectionByID))
|
||||
for _, connection := range connectionByID {
|
||||
connections = append(connections, connection)
|
||||
}
|
||||
sort.Slice(connections, func(i, j int) bool {
|
||||
return connections[i].ID < connections[j].ID
|
||||
})
|
||||
return connections
|
||||
}
|
||||
|
||||
func directProxmoxHostAttachment(
|
||||
agent Connection,
|
||||
connectionByID map[string]Connection,
|
||||
) string {
|
||||
if agent.Type != ConnectionTypeAgent {
|
||||
return ""
|
||||
}
|
||||
|
||||
var matchedID string
|
||||
for _, primary := range sortedConnectionsByID(connectionByID) {
|
||||
if primary.Type != ConnectionTypePVE || !primary.Enabled {
|
||||
continue
|
||||
}
|
||||
if !connectionsShareHost(agent, primary) {
|
||||
continue
|
||||
}
|
||||
if matchedID != "" {
|
||||
return ""
|
||||
}
|
||||
matchedID = primary.ID
|
||||
}
|
||||
return matchedID
|
||||
}
|
||||
|
||||
func buildProxmoxClusterNames(
|
||||
connectionByID map[string]Connection,
|
||||
monitor *monitoring.Monitor,
|
||||
|
|
@ -679,7 +726,9 @@ func normalizedConnectionHost(connection Connection) string {
|
|||
func connectionHostCandidates(connection Connection) []string {
|
||||
seen := make(map[string]struct{}, 2)
|
||||
out := make([]string, 0, 2)
|
||||
for _, candidate := range []string{connection.Address, connection.Name} {
|
||||
candidates := []string{connection.Address, connection.Name}
|
||||
candidates = append(candidates, connection.HostAliases...)
|
||||
for _, candidate := range candidates {
|
||||
normalized := normalizeHost(candidate)
|
||||
if normalized == "" {
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -426,3 +426,84 @@ func TestBuildConnectionSystems_GuestAgentStaysStandaloneWhenOnlyClusterInstance
|
|||
t.Fatalf("guest agent should remain standalone, got %+v", guestSystem.Components)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConnectionSystems_AttachesHostAgentToMatchingProxmoxSourceWithoutNodeInventory(t *testing.T) {
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
monitor, err := monitoring.New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("monitoring.New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { monitor.Stop() })
|
||||
|
||||
now := time.Date(2026, 5, 13, 23, 45, 0, 0, time.UTC)
|
||||
connections := []Connection{
|
||||
{
|
||||
ID: "pve:delly",
|
||||
Type: ConnectionTypePVE,
|
||||
Name: "delly",
|
||||
Address: "https://delly:8006",
|
||||
HostAliases: []string{"delly"},
|
||||
State: ConnectionStateUnauthorized,
|
||||
Enabled: true,
|
||||
Surfaces: []string{"vms", "containers", "storage", "backups"},
|
||||
Scope: map[string]bool{"vms": true, "containers": true, "storage": true, "backups": true},
|
||||
Source: ConnectionSourceAgent,
|
||||
Capabilities: ConnectionCapabilities{SupportsPause: true, SupportsScope: true, SupportsTest: true},
|
||||
},
|
||||
{
|
||||
ID: "agent:agent-delly",
|
||||
Type: ConnectionTypeAgent,
|
||||
Name: "delly",
|
||||
Address: "delly",
|
||||
HostAliases: []string{"delly", "192.168.0.5"},
|
||||
State: ConnectionStateActive,
|
||||
Enabled: true,
|
||||
Surfaces: []string{"host"},
|
||||
Scope: map[string]bool{"host": true},
|
||||
LastSeen: &now,
|
||||
Source: ConnectionSourceAgent,
|
||||
Capabilities: ConnectionCapabilities{SupportsPause: false, SupportsScope: false, SupportsTest: false},
|
||||
},
|
||||
{
|
||||
ID: "agent:agent-minipc",
|
||||
Type: ConnectionTypeAgent,
|
||||
Name: "minipc",
|
||||
Address: "minipc",
|
||||
HostAliases: []string{"minipc", "192.168.0.134"},
|
||||
State: ConnectionStateActive,
|
||||
Enabled: true,
|
||||
Surfaces: []string{"host"},
|
||||
Scope: map[string]bool{"host": true},
|
||||
LastSeen: &now,
|
||||
Source: ConnectionSourceAgent,
|
||||
Capabilities: ConnectionCapabilities{SupportsPause: false, SupportsScope: false, SupportsTest: false},
|
||||
},
|
||||
}
|
||||
|
||||
systems := buildConnectionSystems(connections, monitor)
|
||||
systemsByID := make(map[string]ConnectionSystem, len(systems))
|
||||
for _, system := range systems {
|
||||
systemsByID[system.ID] = system
|
||||
}
|
||||
|
||||
dellySystem := systemsByID["pve:delly"]
|
||||
if len(dellySystem.Components) != 2 {
|
||||
t.Fatalf("expected delly API and agent components, got %+v", dellySystem.Components)
|
||||
}
|
||||
|
||||
componentRoles := make(map[string]ConnectionSystemComponentRole, len(dellySystem.Components))
|
||||
for _, component := range dellySystem.Components {
|
||||
componentRoles[component.ConnectionID] = component.Role
|
||||
}
|
||||
if componentRoles["pve:delly"] != ConnectionSystemComponentRolePrimary {
|
||||
t.Fatalf("pve:delly role = %q, want %q", componentRoles["pve:delly"], ConnectionSystemComponentRolePrimary)
|
||||
}
|
||||
if componentRoles["agent:agent-delly"] != ConnectionSystemComponentRoleAttachment {
|
||||
t.Fatalf("agent:agent-delly role = %q, want %q", componentRoles["agent:agent-delly"], ConnectionSystemComponentRoleAttachment)
|
||||
}
|
||||
|
||||
minipcSystem := systemsByID["agent:agent-minipc"]
|
||||
if len(minipcSystem.Components) != 1 || minipcSystem.Components[0].Role != ConnectionSystemComponentRolePrimary {
|
||||
t.Fatalf("minipc should remain standalone without a direct source host match, got %+v", minipcSystem.Components)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13045,6 +13045,86 @@ func TestContract_ConnectionSystemMembersPayloadShapeStaysCanonical(t *testing.T
|
|||
assertJSONSnapshot(t, body, want)
|
||||
}
|
||||
|
||||
func TestContract_ConnectionSystemHostAgentAttachmentPayloadShapeStaysCanonical(t *testing.T) {
|
||||
response := ConnectionsListResponse{
|
||||
Connections: []Connection{
|
||||
{
|
||||
ID: "pve:delly",
|
||||
Type: ConnectionTypePVE,
|
||||
Name: "delly",
|
||||
Address: "https://delly:8006",
|
||||
HostAliases: []string{"delly"},
|
||||
State: ConnectionStateUnauthorized,
|
||||
Enabled: true,
|
||||
Surfaces: []string{"vms", "containers", "storage", "backups"},
|
||||
Scope: map[string]bool{"vms": true, "containers": true, "storage": true, "backups": true},
|
||||
Source: ConnectionSourceAgent,
|
||||
Fleet: ConnectionFleetGovernance{
|
||||
EnrollmentState: fleetStateConfigured,
|
||||
LivenessState: string(ConnectionStateUnauthorized),
|
||||
VersionDrift: fleetStateNotApplicable,
|
||||
AdapterHealth: fleetStateBlocked,
|
||||
ConfigRollout: fleetStateConfigured,
|
||||
CredentialStatus: fleetStateInvalid,
|
||||
UpdateStatus: fleetStateNotApplicable,
|
||||
RemoteControl: fleetStateNotApplicable,
|
||||
},
|
||||
Capabilities: ConnectionCapabilities{SupportsPause: true, SupportsScope: true, SupportsTest: true},
|
||||
},
|
||||
{
|
||||
ID: "agent:agent-delly",
|
||||
Type: ConnectionTypeAgent,
|
||||
Name: "delly",
|
||||
Address: "delly",
|
||||
HostAliases: []string{"delly", "192.168.0.5"},
|
||||
State: ConnectionStateActive,
|
||||
Enabled: true,
|
||||
Surfaces: []string{"host"},
|
||||
Scope: map[string]bool{"host": true},
|
||||
LastSeen: timePtr(time.Date(2026, 5, 13, 23, 45, 0, 0, time.UTC)),
|
||||
Source: ConnectionSourceAgent,
|
||||
Fleet: ConnectionFleetGovernance{
|
||||
EnrollmentState: fleetStateEnrolled,
|
||||
LivenessState: fleetStateActive,
|
||||
VersionDrift: fleetStateUnknown,
|
||||
AdapterHealth: fleetStateHealthy,
|
||||
ConfigRollout: fleetStateReported,
|
||||
CredentialStatus: fleetStateVerified,
|
||||
UpdateStatus: fleetStateUnknown,
|
||||
RemoteControl: fleetStateDisabled,
|
||||
},
|
||||
Capabilities: ConnectionCapabilities{SupportsPause: false, SupportsScope: false, SupportsTest: false},
|
||||
},
|
||||
},
|
||||
Systems: []ConnectionSystem{
|
||||
{
|
||||
ID: "pve:delly",
|
||||
Type: ConnectionTypePVE,
|
||||
Components: []ConnectionSystemComponent{
|
||||
{
|
||||
ConnectionID: "pve:delly",
|
||||
Type: ConnectionTypePVE,
|
||||
Role: ConnectionSystemComponentRolePrimary,
|
||||
},
|
||||
{
|
||||
ConnectionID: "agent:agent-delly",
|
||||
Type: ConnectionTypeAgent,
|
||||
Role: ConnectionSystemComponentRoleAttachment,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal ConnectionsListResponse with direct host agent attachment: %v", err)
|
||||
}
|
||||
|
||||
want := `{"connections":[{"id":"pve:delly","type":"pve","name":"delly","address":"https://delly:8006","hostAliases":["delly"],"state":"unauthorized","enabled":true,"surfaces":["vms","containers","storage","backups"],"scope":{"backups":true,"containers":true,"storage":true,"vms":true},"source":"agent","fleet":{"enrollmentState":"configured","livenessState":"unauthorized","versionDrift":"not-applicable","adapterHealth":"blocked","configRollout":"configured","credentialStatus":"invalid","updateStatus":"not-applicable","remoteControl":"not-applicable"},"capabilities":{"supportsPause":true,"supportsScope":true,"supportsTest":true}},{"id":"agent:agent-delly","type":"agent","name":"delly","address":"delly","hostAliases":["delly","192.168.0.5"],"state":"active","enabled":true,"surfaces":["host"],"scope":{"host":true},"lastSeen":"2026-05-13T23:45:00Z","source":"agent","fleet":{"enrollmentState":"enrolled","livenessState":"active","versionDrift":"unknown","adapterHealth":"healthy","configRollout":"reported","credentialStatus":"verified","updateStatus":"unknown","remoteControl":"disabled"},"capabilities":{"supportsPause":false,"supportsScope":false,"supportsTest":false}}],"systems":[{"id":"pve:delly","type":"pve","components":[{"connectionId":"pve:delly","type":"pve","role":"primary"},{"connectionId":"agent:agent-delly","type":"agent","role":"attachment"}]}]}`
|
||||
assertJSONSnapshot(t, body, want)
|
||||
}
|
||||
|
||||
func TestContract_AgentConnectionPayloadIncludesVersionFields(t *testing.T) {
|
||||
conn := Connection{
|
||||
ID: "agent:host-1",
|
||||
|
|
|
|||
|
|
@ -3625,12 +3625,12 @@ class SubsystemLookupTest(unittest.TestCase):
|
|||
self.assertEqual(
|
||||
api_match["matched_contract_references"],
|
||||
[
|
||||
{
|
||||
"heading": "## Shared Boundaries",
|
||||
"path": "internal/api/access_control_handlers.go",
|
||||
"line": 203,
|
||||
"heading_line": 106,
|
||||
}
|
||||
{
|
||||
"heading": "## Shared Boundaries",
|
||||
"path": "internal/api/access_control_handlers.go",
|
||||
"line": 204,
|
||||
"heading_line": 107,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue