Split direct Proxmox settings surface owners

This commit is contained in:
rcourtman 2026-03-20 23:40:02 +00:00
parent dcd88e2f64
commit 274f109dff
11 changed files with 584 additions and 338 deletions

View file

@ -34,16 +34,26 @@ management, and fleet control surfaces.
10. `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`
11. `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx`
12. `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`
13. `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`
14. `frontend-modern/src/components/Settings/UnifiedAgents.tsx`
15. `frontend-modern/src/components/Settings/NodeModal.tsx`
16. `frontend-modern/src/components/Settings/nodeModalModel.ts`
17. `frontend-modern/src/components/Settings/useNodeModalState.ts`
18. `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`
19. `frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx`
20. `frontend-modern/src/utils/agentProfilesPresentation.ts`
21. `frontend-modern/src/utils/agentInstallCommand.ts`
22. `frontend-modern/src/api/nodes.ts`
13. `frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx`
14. `frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx`
15. `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`
16. `frontend-modern/src/components/Settings/ProxmoxSettingsPanel.tsx`
17. `frontend-modern/src/components/Settings/ProxmoxDirectConnectionsCard.tsx`
18. `frontend-modern/src/components/Settings/ProxmoxDiscoveryResultsCard.tsx`
19. `frontend-modern/src/components/Settings/ProxmoxDeleteNodeDialog.tsx`
20. `frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx`
21. `frontend-modern/src/components/Settings/SettingsSectionNav.tsx`
22. `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`
23. `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`
24. `frontend-modern/src/components/Settings/UnifiedAgents.tsx`
25. `frontend-modern/src/components/Settings/NodeModal.tsx`
26. `frontend-modern/src/components/Settings/nodeModalModel.ts`
27. `frontend-modern/src/components/Settings/useNodeModalState.ts`
28. `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`
29. `frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx`
30. `frontend-modern/src/utils/agentProfilesPresentation.ts`
31. `frontend-modern/src/utils/agentInstallCommand.ts`
32. `frontend-modern/src/api/nodes.ts`
## Shared Boundaries
@ -70,7 +80,7 @@ management, and fleet control surfaces.
3. Add or change runtime-side Unified Agent startup, first-report assembly, and enroll/runtime continuity through `internal/hostagent/`.
4. Keep legacy Unified Agent compatibility names explicitly secondary when touching shared `internal/api/` runtime helpers: the legacy host-route family and `host-agent:*` scope names may remain as ingress or migration aliases, but they must not retake primary ownership in router state, live runtime scope checks, handler commentary, or operator-facing guidance.
5. Add or change installer flags, persisted service arguments, or upgrade-safe re-entry behavior through `scripts/install.sh` and `scripts/install.ps1`.
6. Add or change profile management, the extracted agent profiles runtime owner, the pure unified-agent inventory/install model, shared frontend install-command assembly, Proxmox setup/install API transport, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`, `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`, `frontend-modern/src/components/Settings/NodeModal.tsx`, `frontend-modern/src/components/Settings/nodeModalModel.ts`, `frontend-modern/src/components/Settings/useNodeModalState.ts`, `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`, `frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx`, and `frontend-modern/src/utils/agentInstallCommand.ts`.
6. Add or change profile management, the extracted agent profiles runtime owner, the pure unified-agent inventory/install model, the direct Proxmox workspace shell and section owners, shared frontend install-command assembly, Proxmox setup/install API transport, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`, `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx`, `frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/ProxmoxSettingsPanel.tsx`, `frontend-modern/src/components/Settings/ProxmoxDirectConnectionsCard.tsx`, `frontend-modern/src/components/Settings/ProxmoxDiscoveryResultsCard.tsx`, `frontend-modern/src/components/Settings/ProxmoxDeleteNodeDialog.tsx`, `frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx`, `frontend-modern/src/components/Settings/SettingsSectionNav.tsx`, `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`, `frontend-modern/src/components/Settings/NodeModal.tsx`, `frontend-modern/src/components/Settings/nodeModalModel.ts`, `frontend-modern/src/components/Settings/useNodeModalState.ts`, `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`, `frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx`, and `frontend-modern/src/utils/agentInstallCommand.ts`.
## Forbidden Paths
@ -137,6 +147,15 @@ reintroducing local array or JSON parsing rules.
The owned backend API surfaces must preserve the exact-release installer
fallback, canonical /api/auto-register behavior, and hosted org install-command
contracts instead of leaving those guarantees implied by generic API ownership.
The direct Proxmox fallback workspace now also lives explicitly inside this
lifecycle boundary: `InfrastructureWorkspace.tsx`,
`InfrastructureInstallPanel.tsx`, `InfrastructureReportingPanel.tsx`,
`ProxmoxSettingsPanel.tsx`, `ProxmoxDirectConnectionsCard.tsx`,
`ProxmoxDiscoveryResultsCard.tsx`, `ProxmoxDeleteNodeDialog.tsx`,
`ConfiguredNodeTables.tsx`, `SettingsSectionNav.tsx`, and
`useInfrastructureSettingsState.ts` own the fallback install/direct/reporting
operator flow instead of leaving those panels ungoverned beside the canonical
unified-agent install path.
That canonical /api/auto-register behavior now also includes hostname/IP continuity:
reruns that arrive through a different canonical host form must reuse the same
Pulse-managed node record and token instead of forking duplicate fleet entries.

View file

@ -368,13 +368,23 @@
"frontend-modern/src/api/nodes.ts",
"frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx",
"frontend-modern/src/components/Settings/AgentProfilesPanel.tsx",
"frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx",
"frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx",
"frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx",
"frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx",
"frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx",
"frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx",
"frontend-modern/src/components/Settings/NodeModal.tsx",
"frontend-modern/src/components/Settings/nodeModalModel.ts",
"frontend-modern/src/components/Settings/ProxmoxDeleteNodeDialog.tsx",
"frontend-modern/src/components/Settings/ProxmoxDirectConnectionsCard.tsx",
"frontend-modern/src/components/Settings/ProxmoxDiscoveryResultsCard.tsx",
"frontend-modern/src/components/Settings/ProxmoxSettingsPanel.tsx",
"frontend-modern/src/components/Settings/SettingsSectionNav.tsx",
"frontend-modern/src/components/Settings/UnifiedAgents.tsx",
"frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts",
"frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx",
"frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts",
"frontend-modern/src/components/Settings/useNodeModalState.ts",
"frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx",
"frontend-modern/src/utils/agentInstallCommand.ts",
@ -587,6 +597,32 @@
"frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx",
"frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx"
]
},
{
"id": "direct-proxmox-workspace-surface",
"label": "direct Proxmox workspace lifecycle proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx",
"frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx",
"frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx",
"frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx",
"frontend-modern/src/components/Settings/ProxmoxDeleteNodeDialog.tsx",
"frontend-modern/src/components/Settings/ProxmoxDirectConnectionsCard.tsx",
"frontend-modern/src/components/Settings/ProxmoxDiscoveryResultsCard.tsx",
"frontend-modern/src/components/Settings/ProxmoxSettingsPanel.tsx",
"frontend-modern/src/components/Settings/SettingsSectionNav.tsx",
"frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx",
"frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts",
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts",
"frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx",
"frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts"
]
}
]
}

View file

@ -0,0 +1,89 @@
import type { Component } from 'solid-js';
import { Show } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { Dialog } from '@/components/shared/Dialog';
import { SectionHeader } from '@/components/shared/SectionHeader';
interface ProxmoxDeleteNodeDialogProps {
deleteNodeLoading: boolean;
nodePendingDeleteHost: string;
nodePendingDeleteLabel: string;
nodePendingDeleteType: string;
nodePendingDeleteTypeLabel: string;
onCancel: () => void;
onDelete: () => Promise<void>;
}
export const ProxmoxDeleteNodeDialog: Component<ProxmoxDeleteNodeDialogProps> = (props) => {
return (
<Dialog
isOpen={true}
onClose={props.onCancel}
panelClass="max-w-lg"
closeOnBackdrop={false}
ariaLabel={`Remove ${props.nodePendingDeleteLabel}`}
>
<Card padding="lg" class="w-full max-w-lg space-y-5">
<SectionHeader title={`Remove ${props.nodePendingDeleteLabel}`} size="md" class="mb-1" />
<div class="space-y-3 text-sm text-gray-600">
<p>
Removing this {props.nodePendingDeleteTypeLabel.toLowerCase()} also scrubs the Pulse
footprint on the host - the proxy service, SSH key, API token, and bind mount are all
cleaned up automatically.
</p>
<div class="rounded-md border border-blue-200 bg-blue-50 p-3 text-sm leading-relaxed dark:border-blue-800 dark:bg-blue-900 dark:text-blue-100">
<p class="font-medium text-blue-900 dark:text-blue-100">What happens next</p>
<ul class="mt-2 list-disc space-y-1 pl-4 text-blue-800 dark:text-blue-200 text-sm">
<li>Pulse removes the node entry and clears related alerts.</li>
<li>
{props.nodePendingDeleteHost ? (
<>
The host <span class="font-semibold">{props.nodePendingDeleteHost}</span>{' '}
loses the proxy service, SSH key, and API token.
</>
) : (
'The host loses the proxy service, SSH key, and API token.'
)}
</li>
<li>
If the host comes back later, rerunning the setup script reinstalls everything with
a fresh key.
</li>
<Show when={props.nodePendingDeleteType === 'pbs'}>
<li>
Backup user tokens on the PBS are removed, so jobs referencing them will no
longer authenticate until the node is re-added.
</li>
</Show>
<Show when={props.nodePendingDeleteType === 'pmg'}>
<li>
Mail gateway tokens are removed as part of the cleanup; re-enroll to restore
outbound telemetry.
</li>
</Show>
</ul>
</div>
</div>
<div class="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={props.onCancel}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover"
disabled={props.deleteNodeLoading}
>
Keep node
</button>
<button
type="button"
onClick={props.onDelete}
disabled={props.deleteNodeLoading}
class="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-red-500 dark:hover:bg-red-400"
>
{props.deleteNodeLoading ? 'Removing…' : 'Remove node'}
</button>
</div>
</Card>
</Dialog>
);
};

View file

@ -0,0 +1,144 @@
import type { Component, JSX } from 'solid-js';
import { Dynamic } from 'solid-js/web';
import { Show } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { Toggle } from '@/components/shared/Toggle';
import type { ToggleChangeEvent } from '@/components/shared/Toggle';
import { getSettingsConfigurationLoadingState } from '@/utils/settingsShellPresentation';
import type { ProxmoxVariantPresentation } from '@/utils/proxmoxSettingsPresentation';
import type { NodeConfigWithStatus } from '@/types/nodes';
import type { DiscoveredServer, NodeType } from './useInfrastructureSettingsState';
interface ProxmoxDirectConnectionsCardProps {
activeAgent: NodeType;
activeConfig: ProxmoxVariantPresentation;
activeConfiguredNodes: NodeConfigWithStatus[];
activeDiscoveredNodes: DiscoveredServer[];
configuredTable: JSX.Element;
discoveryEnabled: boolean;
envOverrides: Record<string, boolean>;
initialLoadComplete: boolean;
onDiscoveryToggle: (event: ToggleChangeEvent) => Promise<void>;
onOpenCreateNode: (type: NodeType) => void;
onRefreshDiscovery: () => Promise<void>;
savingDiscoverySettings: boolean;
}
export const ProxmoxDirectConnectionsCard: Component<ProxmoxDirectConnectionsCardProps> = (
props,
) => {
return (
<>
<Show when={!props.initialLoadComplete}>
<div class="flex items-center justify-center rounded-md border border-dashed border-border bg-surface-alt py-12 text-sm text-muted">
{getSettingsConfigurationLoadingState().text}
</div>
</Show>
<Show when={props.initialLoadComplete}>
<Card padding="none" class="rounded-xl border border-border shadow-sm">
<div class="px-3 py-4 sm:px-6 sm:py-6 space-y-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="space-y-2">
<h4 class="text-base font-semibold text-base-content">{props.activeConfig.title}</h4>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="inline-flex items-center rounded-full bg-surface-alt px-2.5 py-1 font-medium text-base-content">
{props.activeConfiguredNodes.length} configured
</span>
<Show when={props.discoveryEnabled}>
<span class="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-1 font-medium text-blue-700 dark:bg-blue-950/40 dark:text-blue-300">
{props.activeDiscoveredNodes.length} discovered
</span>
</Show>
</div>
</div>
<div class="flex flex-wrap items-center justify-start gap-2 sm:justify-end">
<div
class="flex items-center gap-2 sm:gap-3"
title="Enable automatic discovery of Proxmox servers on your network"
>
<span class="text-xs sm:text-sm text-muted">Discovery</span>
<Toggle
checked={props.discoveryEnabled}
onChange={props.onDiscoveryToggle}
disabled={
props.envOverrides.discoveryEnabled || props.savingDiscoverySettings
}
containerClass="gap-2"
label={
<span class="text-xs font-medium text-muted">
{props.discoveryEnabled ? 'On' : 'Off'}
</span>
}
/>
</div>
<Show when={props.discoveryEnabled}>
<button
type="button"
onClick={props.onRefreshDiscovery}
class="px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm bg-gray-600 text-white rounded-md hover:bg-gray-700 transition-colors flex items-center gap-1"
title="Refresh discovered servers"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
<span class="hidden sm:inline">Refresh</span>
</button>
</Show>
<button
type="button"
onClick={() => props.onOpenCreateNode(props.activeAgent)}
class="px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors flex items-center gap-1"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span class="sm:hidden">Add</span>
<span class="hidden sm:inline">{props.activeConfig.addLabel}</span>
</button>
</div>
</div>
<Show when={props.activeConfiguredNodes.length > 0}>{props.configuredTable}</Show>
<Show
when={
props.activeConfiguredNodes.length === 0 &&
props.activeDiscoveredNodes.length === 0
}
>
<div class="flex flex-col items-center justify-center py-12 px-4 text-center">
<div class="rounded-full bg-surface-alt p-4 mb-4">
<Dynamic component={props.activeConfig.emptyIcon} class="h-8 w-8 text-muted" />
</div>
<p class="text-base font-medium text-base-content mb-1">
{props.activeConfig.emptyTitle}
</p>
<p class="text-sm text-muted">{props.activeConfig.emptyDescription}</p>
</div>
</Show>
</div>
</Card>
</Show>
</>
);
};

View file

@ -0,0 +1,140 @@
import type { Component } from 'solid-js';
import { For, Show } from 'solid-js';
import Loader from 'lucide-solid/icons/loader';
import { Card } from '@/components/shared/Card';
import { formatRelativeTime } from '@/utils/format';
import type { ProxmoxVariantPresentation } from '@/utils/proxmoxSettingsPresentation';
import type { DiscoveryScanStatus, DiscoveredServer } from './useInfrastructureSettingsState';
interface ProxmoxDiscoveryResultsCardProps {
activeConfig: ProxmoxVariantPresentation;
activeDiscoveredNodes: DiscoveredServer[];
discoveryScanStatus: DiscoveryScanStatus;
hasDiscoveryTimeouts: boolean;
onOpenDiscoveredNode: (server: DiscoveredServer) => void;
}
export const ProxmoxDiscoveryResultsCard: Component<ProxmoxDiscoveryResultsCardProps> = (
props,
) => {
return (
<Card padding="lg" class="rounded-xl border border-border shadow-sm">
<div class="space-y-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h4 class="text-base font-semibold text-base-content">Discovery results</h4>
<p class="text-sm text-muted">
Found servers that can be turned into direct connections for this Proxmox type.
</p>
</div>
<div class="text-xs text-muted">
<Show when={props.discoveryScanStatus.scanning}>
<span class="flex items-center gap-2">
<Loader class="h-4 w-4 animate-spin" />
<span>{props.activeConfig.scanningLabel}</span>
</span>
</Show>
<Show
when={
!props.discoveryScanStatus.scanning &&
(props.discoveryScanStatus.lastResultAt ||
props.discoveryScanStatus.lastScanStartedAt)
}
>
<span>
Last scan{' '}
{formatRelativeTime(
props.discoveryScanStatus.lastResultAt ??
props.discoveryScanStatus.lastScanStartedAt,
{ compact: true },
)}
</span>
</Show>
</div>
</div>
<Show
when={
props.discoveryScanStatus.errors && props.discoveryScanStatus.errors.length > 0
}
>
<div class="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900 border border-amber-200 dark:border-amber-800 rounded-md p-2">
<span class="font-medium">Discovery issues:</span>
<ul class="list-disc ml-4 mt-1 space-y-0.5">
<For each={props.discoveryScanStatus.errors ?? []}>{(error) => <li>{error}</li>}</For>
</ul>
<Show when={props.hasDiscoveryTimeouts}>
<p class="mt-2 text-[0.7rem] font-medium text-amber-700 dark:text-amber-300">
Large networks can time out in auto mode. Switch to a custom subnet for faster,
targeted scans.
</p>
</Show>
</div>
</Show>
<Show when={props.discoveryScanStatus.scanning && props.activeDiscoveredNodes.length === 0}>
<div class="flex items-center gap-2 text-xs text-muted">
<Loader class="h-4 w-4 animate-spin" />
<span>
Waiting for responses… this can take up to a minute depending on your network size.
</span>
</div>
</Show>
<Show
when={props.activeDiscoveredNodes.length > 0}
fallback={
<div class="rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted">
No discovery matches for this Proxmox type yet. You can still add a direct
connection manually.
</div>
}
>
<div class="space-y-3">
<For each={props.activeDiscoveredNodes}>
{(server) => (
<button
type="button"
class="w-full bg-surface-hover rounded-md p-4 border border-border text-left opacity-75 hover:opacity-100 hover:border-blue-300 dark:hover:border-blue-700 transition-all"
onClick={() => props.onOpenDiscoveredNode(server)}
>
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-3 h-3 mt-1.5 rounded-full bg-gray-400 animate-pulse"></div>
<div class="flex-1 min-w-0">
<h4 class="font-medium text-base-content">
{props.activeConfig.titleFromServer(server)}
</h4>
<p class="text-sm mt-1">
{server.ip}:{server.port}
</p>
<div class="flex items-center gap-2 mt-2">
<span class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-400 rounded">
Discovered
</span>
<span class="text-xs text-muted">
Click to configure this connection
</span>
</div>
</div>
</div>
</div>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" class="mt-1">
<path
d="M12 5v14m-7-7h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</div>
</button>
)}
</For>
</div>
</Show>
</div>
</Card>
);
};

View file

@ -1,27 +1,22 @@
import { Component, Accessor, Setter, Show, For, createMemo, createSignal } from 'solid-js';
import { Component, Accessor, Setter, Show, createMemo, createSignal } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { Dynamic } from 'solid-js/web';
import Loader from 'lucide-solid/icons/loader';
import type { Resource } from '@/types/resource';
import type { PBSInstance, PMGInstance } from '@/types/api';
import type { NodeConfig, NodeConfigWithStatus } from '@/types/nodes';
import type { SecurityStatus as SecurityStatusInfo } from '@/types/config';
import { notificationStore } from '@/stores/notifications';
import { formatRelativeTime } from '@/utils/format';
import { Card } from '@/components/shared/Card';
import { CalloutCard } from '@/components/shared/CalloutCard';
import { Dialog } from '@/components/shared/Dialog';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { Toggle } from '@/components/shared/Toggle';
import type { ToggleChangeEvent } from '@/components/shared/Toggle';
import { NodeModal } from './NodeModal';
import { PveNodesTable, PbsNodesTable, PmgNodesTable } from './ConfiguredNodeTables';
import { ProxmoxDeleteNodeDialog } from './ProxmoxDeleteNodeDialog';
import { ProxmoxDirectConnectionsCard } from './ProxmoxDirectConnectionsCard';
import { ProxmoxDiscoveryResultsCard } from './ProxmoxDiscoveryResultsCard';
import { SettingsSectionNav } from './SettingsSectionNav';
import {
buildProxmoxDiscoveryPrefillNode,
getProxmoxVariantPresentation,
} from '@/utils/proxmoxSettingsPresentation';
import { getSettingsConfigurationLoadingState } from '@/utils/settingsShellPresentation';
import type {
DiscoveredServer,
DiscoveryScanStatus,
@ -265,329 +260,43 @@ export const ProxmoxSettingsPanel: Component<ProxmoxSettingsPanelProps> = (props
<div class="space-y-6 mt-6">
<div class="space-y-4">
<Show when={!props.initialLoadComplete()}>
<div class="flex items-center justify-center rounded-md border border-dashed border-border bg-surface-alt py-12 text-sm text-muted">
{getSettingsConfigurationLoadingState().text}
</div>
</Show>
<Show when={props.initialLoadComplete()}>
<Card padding="none" class="rounded-xl border border-border shadow-sm">
<div class="px-3 py-4 sm:px-6 sm:py-6 space-y-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="space-y-2">
<h4 class="text-base font-semibold text-base-content">
{activeConfig().title}
</h4>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="inline-flex items-center rounded-full bg-surface-alt px-2.5 py-1 font-medium text-base-content">
{activeConfiguredNodes().length} configured
</span>
<Show when={props.discoveryEnabled()}>
<span class="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-1 font-medium text-blue-700 dark:bg-blue-950/40 dark:text-blue-300">
{activeDiscoveredNodes().length} discovered
</span>
</Show>
</div>
</div>
<div class="flex flex-wrap items-center justify-start gap-2 sm:justify-end">
<div
class="flex items-center gap-2 sm:gap-3"
title="Enable automatic discovery of Proxmox servers on your network"
>
<span class="text-xs sm:text-sm text-muted">Discovery</span>
<Toggle
checked={props.discoveryEnabled()}
onChange={handleDiscoveryToggle}
disabled={
props.envOverrides().discoveryEnabled || props.savingDiscoverySettings()
}
containerClass="gap-2"
label={
<span class="text-xs font-medium text-muted">
{props.discoveryEnabled() ? 'On' : 'Off'}
</span>
}
/>
</div>
<Show when={props.discoveryEnabled()}>
<button
type="button"
onClick={handleRefreshDiscovery}
class="px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm bg-gray-600 text-white rounded-md hover:bg-gray-700 transition-colors flex items-center gap-1"
title="Refresh discovered servers"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
<span class="hidden sm:inline">Refresh</span>
</button>
</Show>
<button
type="button"
onClick={() => openCreateNode(activeAgent())}
class="px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors flex items-center gap-1"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span class="sm:hidden">Add</span>
<span class="hidden sm:inline">{activeConfig().addLabel}</span>
</button>
</div>
</div>
<Show when={activeConfiguredNodes().length > 0}>{renderConfiguredTable()}</Show>
<Show
when={
activeConfiguredNodes().length === 0 && activeDiscoveredNodes().length === 0
}
>
<div class="flex flex-col items-center justify-center py-12 px-4 text-center">
<div class="rounded-full bg-surface-alt p-4 mb-4">
<Dynamic component={activeConfig().emptyIcon} class="h-8 w-8 text-muted" />
</div>
<p class="text-base font-medium text-base-content mb-1">
{activeConfig().emptyTitle}
</p>
<p class="text-sm text-muted">{activeConfig().emptyDescription}</p>
</div>
</Show>
</div>
</Card>
</Show>
<ProxmoxDirectConnectionsCard
activeAgent={activeAgent()}
activeConfig={activeConfig()}
activeConfiguredNodes={activeConfiguredNodes()}
activeDiscoveredNodes={activeDiscoveredNodes()}
configuredTable={renderConfiguredTable()}
discoveryEnabled={props.discoveryEnabled()}
envOverrides={props.envOverrides()}
initialLoadComplete={props.initialLoadComplete()}
onDiscoveryToggle={handleDiscoveryToggle}
onOpenCreateNode={openCreateNode}
onRefreshDiscovery={handleRefreshDiscovery}
savingDiscoverySettings={props.savingDiscoverySettings()}
/>
<Show when={props.discoveryEnabled()}>
<Card padding="lg" class="rounded-xl border border-border shadow-sm">
<div class="space-y-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h4 class="text-base font-semibold text-base-content">Discovery results</h4>
<p class="text-sm text-muted">
Found servers that can be turned into direct connections for this Proxmox
type.
</p>
</div>
<div class="text-xs text-muted">
<Show when={props.discoveryScanStatus().scanning}>
<span class="flex items-center gap-2">
<Loader class="h-4 w-4 animate-spin" />
<span>{activeConfig().scanningLabel}</span>
</span>
</Show>
<Show
when={
!props.discoveryScanStatus().scanning &&
(props.discoveryScanStatus().lastResultAt ||
props.discoveryScanStatus().lastScanStartedAt)
}
>
<span>
Last scan{' '}
{formatRelativeTime(
props.discoveryScanStatus().lastResultAt ??
props.discoveryScanStatus().lastScanStartedAt,
{ compact: true },
)}
</span>
</Show>
</div>
</div>
<Show
when={
props.discoveryScanStatus().errors && props.discoveryScanStatus().errors!.length
}
>
<div class="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900 border border-amber-200 dark:border-amber-800 rounded-md p-2">
<span class="font-medium">Discovery issues:</span>
<ul class="list-disc ml-4 mt-1 space-y-0.5">
<For each={props.discoveryScanStatus().errors || []}>
{(error) => <li>{error}</li>}
</For>
</ul>
<Show when={hasDiscoveryTimeouts()}>
<p class="mt-2 text-[0.7rem] font-medium text-amber-700 dark:text-amber-300">
Large networks can time out in auto mode. Switch to a custom subnet for
faster, targeted scans.
</p>
</Show>
</div>
</Show>
<Show
when={
props.discoveryScanStatus().scanning && activeDiscoveredNodes().length === 0
}
>
<div class="flex items-center gap-2 text-xs text-muted">
<Loader class="h-4 w-4 animate-spin" />
<span>
Waiting for responses… this can take up to a minute depending on your network
size.
</span>
</div>
</Show>
<Show
when={activeDiscoveredNodes().length > 0}
fallback={
<div class="rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted">
No discovery matches for this Proxmox type yet. You can still add a direct
connection manually.
</div>
}
>
<div class="space-y-3">
<For each={activeDiscoveredNodes()}>
{(server) => (
<button
type="button"
class="w-full bg-surface-hover rounded-md p-4 border border-border text-left opacity-75 hover:opacity-100 hover:border-blue-300 dark:hover:border-blue-700 transition-all"
onClick={() => openDiscoveredNode(server)}
>
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-3 h-3 mt-1.5 rounded-full bg-gray-400 animate-pulse"></div>
<div class="flex-1 min-w-0">
<h4 class="font-medium text-base-content">
{activeConfig().titleFromServer(server)}
</h4>
<p class="text-sm mt-1">
{server.ip}:{server.port}
</p>
<div class="flex items-center gap-2 mt-2">
<span class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-400 rounded">
Discovered
</span>
<span class="text-xs text-muted">
Click to configure this connection
</span>
</div>
</div>
</div>
</div>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
class="mt-1"
>
<path
d="M12 5v14m-7-7h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</div>
</button>
)}
</For>
</div>
</Show>
</div>
</Card>
<ProxmoxDiscoveryResultsCard
activeConfig={activeConfig()}
activeDiscoveredNodes={activeDiscoveredNodes()}
discoveryScanStatus={props.discoveryScanStatus()}
hasDiscoveryTimeouts={hasDiscoveryTimeouts()}
onOpenDiscoveredNode={openDiscoveredNode}
/>
</Show>
</div>
</div>
<Show when={props.showDeleteNodeModal()}>
<Dialog
isOpen={true}
onClose={props.cancelDeleteNode}
panelClass="max-w-lg"
closeOnBackdrop={false}
ariaLabel={`Remove ${props.nodePendingDeleteLabel()}`}
>
<Card padding="lg" class="w-full max-w-lg space-y-5">
<SectionHeader
title={`Remove ${props.nodePendingDeleteLabel()}`}
size="md"
class="mb-1"
/>
<div class="space-y-3 text-sm text-gray-600">
<p>
Removing this {props.nodePendingDeleteTypeLabel().toLowerCase()} also scrubs the
Pulse footprint on the host — the proxy service, SSH key, API token, and bind mount
are all cleaned up automatically.
</p>
<div class="rounded-md border border-blue-200 bg-blue-50 p-3 text-sm leading-relaxed dark:border-blue-800 dark:bg-blue-900 dark:text-blue-100">
<p class="font-medium text-blue-900 dark:text-blue-100">What happens next</p>
<ul class="mt-2 list-disc space-y-1 pl-4 text-blue-800 dark:text-blue-200 text-sm">
<li>Pulse removes the node entry and clears related alerts.</li>
<li>
{props.nodePendingDeleteHost() ? (
<>
The host <span class="font-semibold">{props.nodePendingDeleteHost()}</span>{' '}
loses the proxy service, SSH key, and API token.
</>
) : (
'The host loses the proxy service, SSH key, and API token.'
)}
</li>
<li>
If the host comes back later, rerunning the setup script reinstalls everything
with a fresh key.
</li>
<Show when={props.nodePendingDeleteType() === 'pbs'}>
<li>
Backup user tokens on the PBS are removed, so jobs referencing them will no
longer authenticate until the node is re-added.
</li>
</Show>
<Show when={props.nodePendingDeleteType() === 'pmg'}>
<li>
Mail gateway tokens are removed as part of the cleanup; re-enroll to restore
outbound telemetry.
</li>
</Show>
</ul>
</div>
</div>
<div class="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={props.cancelDeleteNode}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover"
disabled={props.deleteNodeLoading()}
>
Keep node
</button>
<button
type="button"
onClick={props.deleteNode}
disabled={props.deleteNodeLoading()}
class="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-red-500 dark:hover:bg-red-400"
>
{props.deleteNodeLoading() ? 'Removing…' : 'Remove node'}
</button>
</div>
</Card>
</Dialog>
<ProxmoxDeleteNodeDialog
deleteNodeLoading={props.deleteNodeLoading()}
nodePendingDeleteHost={props.nodePendingDeleteHost()}
nodePendingDeleteLabel={props.nodePendingDeleteLabel()}
nodePendingDeleteType={props.nodePendingDeleteType()}
nodePendingDeleteTypeLabel={props.nodePendingDeleteTypeLabel()}
onCancel={props.cancelDeleteNode}
onDelete={props.deleteNode}
/>
</Show>
{renderNodeModal('pve')}

View file

@ -110,6 +110,9 @@ import unifiedAgentStatusPresentationSource from '@/utils/unifiedAgentStatusPres
import unifiedAgentInventoryPresentationSource from '@/utils/unifiedAgentInventoryPresentation.ts?raw';
import relayPresentationSource from '@/utils/relayPresentation.ts?raw';
import relaySettingsPanelSource from '../RelaySettingsPanel.tsx?raw';
import proxmoxDeleteNodeDialogSource from '../ProxmoxDeleteNodeDialog.tsx?raw';
import proxmoxDirectConnectionsCardSource from '../ProxmoxDirectConnectionsCard.tsx?raw';
import proxmoxDiscoveryResultsCardSource from '../ProxmoxDiscoveryResultsCard.tsx?raw';
import proxmoxSettingsPanelSource from '../ProxmoxSettingsPanel.tsx?raw';
import relayOnboardingCardSource from '@/components/Dashboard/RelayOnboardingCard.tsx?raw';
import generalSettingsPanelSource from '../GeneralSettingsPanel.tsx?raw';
@ -298,9 +301,16 @@ describe('monitored-system model guardrails', () => {
expect(relaySettingsPanelSource).not.toContain('const connectionStatusVariant =');
expect(relaySettingsPanelSource).not.toContain('const connectionStatusText =');
expect(relaySettingsPanelSource).not.toContain('Loading configuration...');
expect(proxmoxSettingsPanelSource).toContain('getSettingsConfigurationLoadingState');
expect(proxmoxSettingsPanelSource).toContain('CalloutCard');
expect(proxmoxSettingsPanelSource).not.toContain('Loading configuration...');
expect(proxmoxSettingsPanelSource).toContain('./ProxmoxDirectConnectionsCard');
expect(proxmoxSettingsPanelSource).toContain('./ProxmoxDiscoveryResultsCard');
expect(proxmoxSettingsPanelSource).toContain('./ProxmoxDeleteNodeDialog');
expect(proxmoxSettingsPanelSource).not.toContain('No discovery matches for this Proxmox type yet.');
expect(proxmoxSettingsPanelSource).not.toContain('What happens next');
expect(proxmoxDirectConnectionsCardSource).toContain('getSettingsConfigurationLoadingState');
expect(proxmoxDiscoveryResultsCardSource).toContain('formatRelativeTime');
expect(proxmoxDeleteNodeDialogSource).toContain('SectionHeader');
expect(relayOnboardingCardSource).toContain('@/utils/relayPresentation');
expect(relayOnboardingCardSource).toContain('RELAY_ONBOARDING_TITLE');
expect(relayOnboardingCardSource).toContain('RELAY_ONBOARDING_DESCRIPTION');

View file

@ -43,6 +43,10 @@ import organizationOverviewPanelSource from '../OrganizationOverviewPanel.tsx?ra
import organizationAccessPanelSource from '../OrganizationAccessPanel.tsx?raw';
import organizationSharingPanelSource from '../OrganizationSharingPanel.tsx?raw';
import organizationBillingPanelSource from '../OrganizationBillingPanel.tsx?raw';
import proxmoxDeleteNodeDialogSource from '../ProxmoxDeleteNodeDialog.tsx?raw';
import proxmoxDirectConnectionsCardSource from '../ProxmoxDirectConnectionsCard.tsx?raw';
import proxmoxDiscoveryResultsCardSource from '../ProxmoxDiscoveryResultsCard.tsx?raw';
import proxmoxSettingsPanelSource from '../ProxmoxSettingsPanel.tsx?raw';
import securityOverviewPanelSource from '../SecurityOverviewPanel.tsx?raw';
import securityAuthPanelSource from '../SecurityAuthPanel.tsx?raw';
import ssoProvidersPanelSource from '../SSOProvidersPanel.tsx?raw';
@ -93,6 +97,9 @@ const extractedModules = [
'../useSSOProvidersState.ts',
'../ssoProvidersModel.ts',
'../ProxmoxSettingsPanel.tsx',
'../ProxmoxDeleteNodeDialog.tsx',
'../ProxmoxDirectConnectionsCard.tsx',
'../ProxmoxDiscoveryResultsCard.tsx',
'../SettingsDialogs.tsx',
'../SettingsPageShell.tsx',
'../useDiscoverySettingsState.ts',
@ -305,6 +312,20 @@ describe('Settings architecture guardrails', () => {
expect(settingsLineCount).toBeLessThanOrEqual(maxSettingsLines);
});
it('keeps the direct Proxmox settings workspace split into section owners', () => {
expect(proxmoxSettingsPanelSource).toContain('./ProxmoxDirectConnectionsCard');
expect(proxmoxSettingsPanelSource).toContain('./ProxmoxDiscoveryResultsCard');
expect(proxmoxSettingsPanelSource).toContain('./ProxmoxDeleteNodeDialog');
expect(proxmoxSettingsPanelSource).not.toContain('No discovery matches for this Proxmox type yet.');
expect(proxmoxSettingsPanelSource).not.toContain('What happens next');
expect(proxmoxDirectConnectionsCardSource).toContain('getSettingsConfigurationLoadingState');
expect(proxmoxDiscoveryResultsCardSource).toContain('Discovery issues:');
expect(proxmoxDiscoveryResultsCardSource).toContain(
'No discovery matches for this Proxmox type yet. You can still add a direct',
);
expect(proxmoxDeleteNodeDialogSource).toContain('What happens next');
});
it('uses lazy() imports for panel components in settingsPanelRegistry', async () => {
const source = (await import('../settingsPanelRegistry.ts?raw')).default;

View file

@ -273,6 +273,7 @@ import configuredNodeStatusPresentationSource from '@/utils/configuredNodeStatus
import rbacPresentationSource from '@/utils/rbacPresentation.ts?raw';
import upgradePresentationSource from '@/utils/upgradePresentation.ts?raw';
import agentProfilesPanelSource from '@/components/Settings/AgentProfilesPanel.tsx?raw';
import agentProfilesPanelStateSource from '@/components/Settings/useAgentProfilesPanelState.ts?raw';
import agentProfilesPresentationSource from '@/utils/agentProfilesPresentation.ts?raw';
import agentProfileSuggestionPresentationSource from '@/utils/agentProfileSuggestionPresentation.ts?raw';
import organizationAccessPanelSource from '@/components/Settings/OrganizationAccessPanel.tsx?raw';
@ -799,7 +800,8 @@ describe('frontend resource type boundaries', () => {
expect(reportingPanelSource).not.toContain('window.URL.createObjectURL');
expect(rolesPanelSource).toContain('@/utils/upgradePresentation');
expect(userAssignmentsPanelSource).toContain('@/utils/upgradePresentation');
expect(agentProfilesPanelSource).toContain('@/utils/upgradePresentation');
expect(agentProfilesPanelSource).toContain('./useAgentProfilesPanelState');
expect(agentProfilesPanelStateSource).toContain('@/utils/upgradePresentation');
expect(agentProfilesPanelSource).toContain('@/utils/agentProfilesPresentation');
expect(agentProfilesPanelSource).toContain('getAgentProfilesEmptyState');
expect(agentProfilesPanelSource).toContain('getAgentProfileAssignmentsEmptyState');

View file

@ -1838,6 +1838,48 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
],
)
def _assert_direct_proxmox_workspace_change_requires_agent_lifecycle(
self, touched_path: str
) -> None:
required = infer_impacted_subsystems([touched_path])
self.assertEqual(set(required), {"agent-lifecycle"})
lifecycle = required["agent-lifecycle"]
self.assertEqual(
lifecycle["contract"],
"docs/release-control/v6/internal/subsystems/agent-lifecycle.md",
)
self.assertEqual(lifecycle["touched_runtime_files"], [touched_path])
self.assertEqual(
lifecycle["verification_requirements"],
[
{
"id": "direct-proxmox-workspace-surface",
"label": "direct Proxmox workspace lifecycle proof",
"touched_runtime_files": [touched_path],
"allow_same_subsystem_tests": False,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx",
"frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx",
"frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts",
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts",
"frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts",
],
}
],
)
def test_proxmox_settings_panel_change_requires_agent_lifecycle(self):
self._assert_direct_proxmox_workspace_change_requires_agent_lifecycle(
"frontend-modern/src/components/Settings/ProxmoxSettingsPanel.tsx"
)
def test_infrastructure_settings_state_change_requires_agent_lifecycle(self):
self._assert_direct_proxmox_workspace_change_requires_agent_lifecycle(
"frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts"
)
def test_agent_install_backend_change_requires_lifecycle_and_api_contracts(self):
required = infer_impacted_subsystems(["internal/api/agent_install_command_shared.go"])
self.assertEqual(set(required), {"agent-lifecycle", "api-contracts"})

View file

@ -1219,6 +1219,40 @@ class SubsystemLookupTest(unittest.TestCase):
],
)
def test_lookup_paths_assigns_proxmox_settings_panel_to_agent_lifecycle(self) -> None:
result = lookup_paths(["frontend-modern/src/components/Settings/ProxmoxSettingsPanel.tsx"])
self.assertEqual(result["unowned_runtime_files"], [])
self.assertEqual(
{item["subsystem"] for item in result["impacted_subsystems"]},
{"agent-lifecycle"},
)
file_entry = result["files"][0]
self.assertEqual(file_entry["classification"], "runtime")
self.assertEqual(
{match["subsystem"] for match in file_entry["matches"]},
{"agent-lifecycle"},
)
match = file_entry["matches"][0]
self.assertEqual(
match["contract"],
"docs/release-control/v6/internal/subsystems/agent-lifecycle.md",
)
self.assertEqual(match["lane_context"]["lane_id"], "L16")
self.assertEqual(
match["verification_requirement"]["id"],
"direct-proxmox-workspace-surface",
)
self.assertEqual(
match["verification_requirement"]["exact_files"],
[
"frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx",
"frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx",
"frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts",
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts",
"frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts",
],
)
def test_lookup_paths_reports_agent_install_backend_as_shared_boundary(self) -> None:
result = lookup_paths(["internal/api/agent_install_command_shared.go"])
self.assertEqual(result["unowned_runtime_files"], [])