Split infrastructure operations render owners

This commit is contained in:
rcourtman 2026-03-21 01:01:07 +00:00
parent 0986f46fab
commit c6f627dc2f
17 changed files with 1999 additions and 1691 deletions

View file

@ -59,6 +59,11 @@ management, and fleet control surfaces.
35. `frontend-modern/src/utils/agentProfilesPresentation.ts`
36. `frontend-modern/src/utils/agentInstallCommand.ts`
37. `frontend-modern/src/api/nodes.ts`
38. `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`
39. `frontend-modern/src/components/Settings/InfrastructureInventorySection.tsx`
40. `frontend-modern/src/components/Settings/InfrastructureActiveRowDetails.tsx`
41. `frontend-modern/src/components/Settings/InfrastructureIgnoredRowDetails.tsx`
42. `frontend-modern/src/components/Settings/InfrastructureStopMonitoringDialog.tsx`
## Shared Boundaries
@ -85,7 +90,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, the direct Proxmox workspace shell, route model, reporting summary owner, local panel-state owner, 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/InfrastructureDirectConnectionsSummaryCard.tsx`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`, `frontend-modern/src/components/Settings/ProxmoxSettingsPanel.tsx`, `frontend-modern/src/components/Settings/ProxmoxConfiguredNodesTable.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/ProxmoxNodeModalStack.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/useProxmoxSettingsPanelState.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`.
6. Add or change profile management, the extracted agent profiles runtime owner, the pure unified-agent inventory/install model, the direct Proxmox workspace shell, route model, reporting summary owner, shared install/inventory/dialog section owners, local panel-state owner, 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/InfrastructureInstallerSection.tsx`, `frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInventorySection.tsx`, `frontend-modern/src/components/Settings/InfrastructureActiveRowDetails.tsx`, `frontend-modern/src/components/Settings/InfrastructureIgnoredRowDetails.tsx`, `frontend-modern/src/components/Settings/InfrastructureStopMonitoringDialog.tsx`, `frontend-modern/src/components/Settings/InfrastructureDirectConnectionsSummaryCard.tsx`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`, `frontend-modern/src/components/Settings/ProxmoxSettingsPanel.tsx`, `frontend-modern/src/components/Settings/ProxmoxConfiguredNodesTable.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/ProxmoxNodeModalStack.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/useProxmoxSettingsPanelState.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
@ -155,13 +160,17 @@ 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`,
`infrastructureWorkspaceModel.ts`,
`InfrastructureInstallPanel.tsx`, `InfrastructureReportingPanel.tsx`,
`InfrastructureInstallPanel.tsx`, `InfrastructureInstallerSection.tsx`,
`InfrastructureReportingPanel.tsx`, `InfrastructureInventorySection.tsx`,
`InfrastructureActiveRowDetails.tsx`,
`InfrastructureIgnoredRowDetails.tsx`,
`InfrastructureStopMonitoringDialog.tsx`,
`InfrastructureDirectConnectionsSummaryCard.tsx`,
`ProxmoxSettingsPanel.tsx`, `ProxmoxConfiguredNodesTable.tsx`,
`ProxmoxDirectConnectionsCard.tsx`, `ProxmoxDiscoveryResultsCard.tsx`,
`ProxmoxDeleteNodeDialog.tsx`, `ProxmoxNodeModalStack.tsx`,
`ConfiguredNodeTables.tsx`, `SettingsSectionNav.tsx`,
`useInfrastructureSettingsState.ts`, and
`useInfrastructureOperationsState.ts`, `useInfrastructureSettingsState.ts`, and
`useProxmoxSettingsPanelState.ts` own the fallback install/direct/reporting
operator flow instead of leaving those panels ungoverned beside the canonical
unified-agent install path.

View file

@ -105,7 +105,7 @@ Own canonical runtime payload shapes between backend and frontend.
and the shared `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` and `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx` cards' infrastructure resource-link default, so the Patrol page, resource drawer, and problem-resource dashboard panels inherit the canonical resource-filter path construction instead of rebuilding infrastructure URLs inline
8. Route frontend API-client parsed error propagation, API-error-status fallback handling, allowed-status handling, custom status-specific error handling, command-trigger success envelope handling, shared response parsing pipelines, missing-resource lookup handling, metadata CRUD routing, stream event consumption, response status, collection normalization, scalar payload coercion, and structured error normalization through canonical shared helpers under `frontend-modern/src/api/`
9. Add or change API token scope, assignment, and revocation presentation through `frontend-modern/src/components/Settings/APITokenManager.tsx`, `frontend-modern/src/components/Settings/apiTokenManagerModel.ts`, and `frontend-modern/src/components/Settings/useAPITokenManagerState.ts`
10. Add or change infrastructure operations token generation, lookup, assignment, the pure unified-agent inventory/install model, and reporting/install presentation through `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, and `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`
10. Add or change infrastructure operations token generation, lookup, assignment, the pure unified-agent inventory/install model, the shared infrastructure-operations state provider/context, and reporting/install presentation through `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, and `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`
11. Keep `internal/api/session_store.go` on a fail-closed auth-persistence boundary: persisted OIDC refresh tokens may only round-trip through encrypted-at-rest session payloads, and any missing-crypto or invalid-ciphertext path must drop the token instead of preserving plaintext-at-rest session state.
12. Keep tenant AI handler wiring on canonical provider ownership: `internal/api/ai_handlers.go` may wire tenant `ReadState` and tenant-scoped unified-resource providers into AI services, but it must not revive tenant snapshot-provider bridges once Patrol can initialize and verify from those canonical providers directly.
@ -277,6 +277,11 @@ The same shared-boundary rule now applies to `frontend-modern/src/api/agentProfi
`internal/api/config_setup_handlers.go`, and `internal/api/unified_agent.go`:
agent install/register/profile control changes must preserve canonical API
payload behavior instead of drifting into subsystem-local transport rules.
That same shared boundary now assumes `InfrastructureOperationsController.tsx`
is only the shell, while `useInfrastructureOperationsState.tsx` owns the API-backed
state/provider contract and the extracted installer, inventory, and stop-monitoring
section owners stay render-side consumers of that canonical state instead of
embedding their own transport calls or ad hoc API parsing.
That shared `frontend-modern/src/api/agentProfiles.ts` boundary must also stay
under explicit proof routing on both sides instead of remaining a generic
frontend-client match on the API-contract side: assignment, delete, unassign,

View file

@ -369,11 +369,16 @@
"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/InfrastructureActiveRowDetails.tsx",
"frontend-modern/src/components/Settings/InfrastructureDirectConnectionsSummaryCard.tsx",
"frontend-modern/src/components/Settings/InfrastructureIgnoredRowDetails.tsx",
"frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx",
"frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx",
"frontend-modern/src/components/Settings/InfrastructureInventorySection.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/InfrastructureStopMonitoringDialog.tsx",
"frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx",
"frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts",
"frontend-modern/src/components/Settings/NodeModal.tsx",
@ -589,8 +594,13 @@
"label": "unified agent settings lifecycle proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/components/Settings/InfrastructureActiveRowDetails.tsx",
"frontend-modern/src/components/Settings/InfrastructureIgnoredRowDetails.tsx",
"frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx",
"frontend-modern/src/components/Settings/InfrastructureInventorySection.tsx",
"frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx",
"frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx",
"frontend-modern/src/components/Settings/InfrastructureStopMonitoringDialog.tsx",
"frontend-modern/src/components/Settings/UnifiedAgents.tsx",
"frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx"
],

View file

@ -0,0 +1,374 @@
import type { Accessor, Component } from 'solid-js';
import { For, Show } from 'solid-js';
import { copyToClipboard } from '@/utils/clipboard';
import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format';
import { notificationStore } from '@/stores/notifications';
import {
getAgentCapabilityBadgeClass,
getAgentCapabilityLabel,
} from '@/utils/agentCapabilityPresentation';
import {
getUnifiedAgentClipboardCopyErrorMessage,
getUnifiedAgentClipboardCopySuccessMessage,
} from '@/utils/unifiedAgentInventoryPresentation';
import {
createSurfaceScopedRow,
getRowSurfaceBreakdown,
type UnifiedAgentRow,
} from './infrastructureOperationsModel';
import { useInfrastructureOperationsContext } from './useInfrastructureOperationsState';
export interface InfrastructureActiveRowDetailsProps {
rowAccessor: Accessor<UnifiedAgentRow>;
}
export const InfrastructureActiveRowDetails: Component<InfrastructureActiveRowDetailsProps> = (
props,
) => {
const state = useInfrastructureOperationsContext();
const row = () => props.rowAccessor();
const isKubernetes = () =>
row().capabilities.includes('kubernetes') && !row().capabilities.includes('agent');
const resolvedAgentId = () => row().agentId || '';
const assignment = () =>
resolvedAgentId() ? state.assignmentByAgent().get(resolvedAgentId()) : undefined;
const isScopeUpdating = () =>
resolvedAgentId() ? Boolean(state.pendingScopeUpdates()[resolvedAgentId()]) : false;
const agentName = () => row().displayName || row().hostname || row().name;
const surfaces = () => getRowSurfaceBreakdown(row());
return (
<div id={`agent-details-${row().rowKey}`} class="flex h-full flex-col overflow-y-auto">
<div class="border-b border-border bg-surface-alt px-4 py-4">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 space-y-3">
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted">
Selected reporting item
</div>
<div class="mt-2 text-lg font-semibold text-base-content">{row().name}</div>
<Show when={row().displayName && row().hostname && row().displayName !== row().hostname}>
<div class="mt-1 text-xs text-muted">{row().hostname}</div>
</Show>
<div class="mt-2 text-sm text-base-content">
Use surface controls to stop specific reporting without removing the machine.
</div>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<For each={row().capabilities}>
{(cap) => (
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${getAgentCapabilityBadgeClass(cap)}`}
>
{getAgentCapabilityLabel(cap)}
</span>
)}
</For>
<Show when={row().isOutdatedBinary}>
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-900 dark:text-amber-200">
Outdated
</span>
</Show>
<Show when={row().linkedNodeId}>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-300">
Linked
</span>
</Show>
</div>
</div>
<button
type="button"
onClick={() => state.setExpandedRowKey(null)}
class="rounded-md p-1 hover:bg-surface-hover hover:text-base-content"
aria-label="Close"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<div class="space-y-4 p-4 text-sm text-muted">
<div class="rounded-lg border border-border bg-surface-alt px-4 py-4">
<div class="text-xs font-semibold uppercase tracking-wide text-muted">Machine overview</div>
<div class="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-1">
<div class="space-y-2 text-xs text-muted">
<div>
Item ID: <span class="font-mono text-base-content">{row().id}</span>
</div>
<Show when={row().agentActionId && row().agentActionId !== row().id}>
<div>
Agent ID: <span class="font-mono text-base-content">{row().agentActionId}</span>
</div>
</Show>
<Show when={row().dockerActionId && row().dockerActionId !== row().id}>
<div>
Container Agent ID:{' '}
<span class="font-mono text-base-content">{row().dockerActionId}</span>
</div>
</Show>
<Show when={row().kubernetesActionId && row().kubernetesActionId !== row().id}>
<div>
Cluster ID:{' '}
<span class="font-mono text-base-content">{row().kubernetesActionId}</span>
</div>
</Show>
<Show when={row().agentId && row().agentId !== row().id}>
<div>
Reporting agent ID:{' '}
<span class="font-mono text-base-content">{row().agentId}</span>
</div>
</Show>
<Show when={row().linkedNodeId}>
<div>
Linked node ID:{' '}
<span class="font-mono text-base-content">{row().linkedNodeId}</span>
</div>
</Show>
</div>
<div class="space-y-2 text-xs text-muted">
<Show when={row().lastSeen}>
<div>
Last seen {formatRelativeTime(row().lastSeen!)} ({formatAbsoluteTime(row().lastSeen!)})
</div>
</Show>
<Show when={row().scope.category !== 'na'}>
<div>
<div class="mb-1">Scope profile</div>
<Show
when={resolvedAgentId()}
fallback={
<span class="text-base-content" title={row().scope.detail}>
{row().scope.label}
</span>
}
>
<Show
when={isKubernetes()}
fallback={
<Show
when={state.profiles().length > 0}
fallback={
<span class="text-base-content" title={row().scope.detail}>
{row().scope.label}
</span>
}
>
<div class="flex items-center gap-2">
<select
value={assignment()?.profile_id || ''}
onChange={(event) => {
const nextValue = event.currentTarget.value;
const currentValue = assignment()?.profile_id || '';
if (nextValue === currentValue) {
return;
}
void state.updateScopeAssignment(
resolvedAgentId(),
nextValue || null,
agentName(),
);
}}
disabled={isScopeUpdating()}
class="min-h-10 sm:min-h-9 rounded-md border border-border bg-surface px-2.5 py-1.5 text-sm text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-60 dark:focus:border-blue-400 dark:focus:ring-blue-800"
>
<option value="">Default (Auto-detect)</option>
<Show
when={
assignment()?.profile_id &&
!state.profileById().has(assignment()!.profile_id)
}
>
<option value={assignment()!.profile_id}>
{state.getProfileOptionLabel(assignment()!.profile_id)}
</option>
</Show>
<For each={state.profiles()}>
{(profile) => (
<option value={profile.id}>
{state.getProfileOptionLabel(profile.id)}
</option>
)}
</For>
</select>
<Show when={isScopeUpdating()}>
<span class="text-[10px] text-muted">Updating…</span>
</Show>
</div>
</Show>
}
>
<span class="text-base-content" title={row().scope.detail}>
{row().scope.label}
</span>
</Show>
</Show>
</div>
</Show>
<Show
when={
row().kubernetesInfo &&
(row().kubernetesInfo?.server ||
row().kubernetesInfo?.context ||
row().kubernetesInfo?.tokenName)
}
>
<div class="space-y-1 pt-1">
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted">
Kubernetes connection
</div>
<Show when={row().kubernetesInfo?.server}>
<div>
Server: <span class="text-base-content">{row().kubernetesInfo?.server}</span>
</div>
</Show>
<Show when={row().kubernetesInfo?.context}>
<div>
Context: <span class="text-base-content">{row().kubernetesInfo?.context}</span>
</div>
</Show>
<Show when={row().kubernetesInfo?.tokenName}>
<div>
Token: <span class="text-base-content">{row().kubernetesInfo?.tokenName}</span>
</div>
</Show>
</div>
</Show>
</div>
</div>
<Show when={assignment()}>
<div class="mt-3 border-t border-border pt-3">
<div class="text-[11px] text-amber-600 dark:text-amber-400">
Restart required to apply scope changes.
</div>
<button
type="button"
onClick={() => void state.handleResetScope(resolvedAgentId(), agentName() || resolvedAgentId())}
class="mt-2 text-left text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
Reset to default
</button>
</div>
</Show>
</div>
<Show when={surfaces().length > 0}>
<div class="rounded-lg border border-border bg-surface-alt px-4 py-4">
<div class="flex flex-col gap-1">
<div class="text-xs font-semibold uppercase tracking-wide text-muted">Surface controls</div>
<div class="text-xs text-muted">
Stop a specific surface. Other surfaces keep reporting.
</div>
</div>
<div class="mt-3 overflow-hidden rounded-md border border-border">
<div class="grid grid-cols-[minmax(0,1.1fr)_minmax(0,1.35fr)_minmax(0,0.9fr)_auto] gap-0 border-b border-border bg-surface px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted">
<div>Surface</div>
<div>What Pulse receives</div>
<div>ID</div>
<div class="text-right">Control</div>
</div>
<For each={surfaces()}>
{(surface) => (
<div class="grid grid-cols-[minmax(0,1.1fr)_minmax(0,1.35fr)_minmax(0,0.9fr)_auto] gap-0 border-b border-border bg-surface-alt px-3 py-2 text-xs last:border-b-0">
<div class="pr-3 font-medium text-base-content">{surface.label}</div>
<div class="pr-3 text-muted">{surface.detail}</div>
<div class="text-muted">
<Show
when={surface.idLabel && surface.idValue}
fallback={<span class="text-muted">Not separately addressed</span>}
>
<div class="space-y-1">
<div class="text-[11px]">{surface.idLabel}</div>
<div class="font-mono text-base-content">{surface.idValue}</div>
</div>
</Show>
</div>
<div class="pl-3 text-right">
<Show
when={
surface.key === 'docker' ||
surface.key === 'agent' ||
surface.key === 'kubernetes'
}
fallback={<span class="text-[11px] text-muted">Managed with host telemetry</span>}
>
<button
type="button"
data-row-action
onClick={(event) => {
event.stopPropagation();
state.openStopMonitoringDialog(
createSurfaceScopedRow(
row(),
surface.key as 'agent' | 'docker' | 'kubernetes',
),
);
}}
class="inline-flex min-h-9 items-center rounded-md px-2.5 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 hover:text-red-900 dark:text-red-400 dark:hover:bg-red-900 dark:hover:text-red-300"
>
Stop this surface
</button>
</Show>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
<div class="rounded-lg border border-border bg-surface-alt px-4 py-4">
<div class="text-xs font-semibold uppercase tracking-wide text-muted">Machine actions</div>
<div class="mt-2 text-xs text-muted">Machine-level utilities.</div>
<div class="mt-4 flex flex-col gap-2">
<Show when={!isKubernetes()}>
<button
type="button"
onClick={async () => {
const success = await copyToClipboard(
state.getPlatformUninstallCommand(row().upgradePlatform, row()),
);
if (success) {
notificationStore.success(getUnifiedAgentClipboardCopySuccessMessage());
} else {
notificationStore.error(getUnifiedAgentClipboardCopyErrorMessage());
}
}}
class="rounded-md border border-border px-3 py-2 text-left text-xs text-slate-600 hover:bg-surface hover:text-base-content"
>
Copy uninstall command
</button>
</Show>
<Show when={row().isOutdatedBinary}>
<button
type="button"
onClick={async () => {
const success = await copyToClipboard(state.getUpgradeCommand(row()));
if (success) {
notificationStore.success(getUnifiedAgentClipboardCopySuccessMessage());
} else {
notificationStore.error(getUnifiedAgentClipboardCopyErrorMessage());
}
}}
class="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-left text-xs text-amber-700 hover:bg-amber-100 hover:text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300 dark:hover:bg-amber-900/60 dark:hover:text-amber-200"
>
Copy upgrade command
</button>
</Show>
<div class="rounded-md border border-blue-200 bg-blue-50 px-3 py-3 text-xs text-blue-900 dark:border-blue-800 dark:bg-blue-950/40 dark:text-blue-100">
Use surface controls above to stop reporting without uninstalling.
</div>
</div>
</div>
</div>
</div>
);
};
export default InfrastructureActiveRowDetails;

View file

@ -0,0 +1,113 @@
import type { Accessor, Component } from 'solid-js';
import {
getReconnectActionLabel,
getCapabilitySurfaceLabel,
type UnifiedAgentRow,
} from './infrastructureOperationsModel';
import { useInfrastructureOperationsContext } from './useInfrastructureOperationsState';
export interface InfrastructureIgnoredRowDetailsProps {
rowAccessor: Accessor<UnifiedAgentRow>;
}
export const InfrastructureIgnoredRowDetails: Component<InfrastructureIgnoredRowDetailsProps> = (
props,
) => {
const state = useInfrastructureOperationsContext();
const row = () => props.rowAccessor();
const pendingAction = () => state.getPendingInventoryAction(row().rowKey);
const isAllowingReconnect = () => pendingAction() === 'allow-reconnect';
const reconnectLabel = () => getReconnectActionLabel(row());
const blockedId = () =>
row().dockerActionId || row().kubernetesActionId || row().agentActionId || row().id;
return (
<div
id={`ignored-details-${row().rowKey}`}
class="flex h-full flex-col overflow-y-auto bg-amber-50/70 dark:bg-amber-950/30"
>
<div class="border-b border-amber-200 bg-amber-100/80 px-4 py-4 dark:border-amber-800 dark:bg-amber-900/30">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-900 dark:text-amber-100">
Selected ignored item
</div>
<div class="mt-2 text-lg font-semibold text-base-content">{row().name}</div>
<div class="mt-2 text-xs font-medium uppercase tracking-wide text-amber-800 dark:text-amber-200">
Ignored by Pulse
</div>
<div class="mt-2 text-sm text-amber-950 dark:text-amber-100">
Pulse is blocking reports from this surface.
</div>
</div>
<button
type="button"
onClick={() => state.setSelectedIgnoredRowKey(null)}
class="rounded-md p-1 hover:bg-amber-200/70 hover:text-base-content dark:hover:bg-amber-800/50"
aria-label="Close"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<div class="space-y-4 p-4 text-sm text-muted">
<div class="rounded-lg border border-amber-200/80 bg-white/70 px-4 py-4 dark:border-amber-800/80 dark:bg-amber-950/20">
<div class="text-xs font-semibold uppercase tracking-wide text-muted">Ignored surface</div>
<div class="mt-3 overflow-hidden rounded-md border border-amber-200/80 dark:border-amber-800/80">
<div class="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1.5fr)_minmax(0,1fr)_auto] gap-0 border-b border-amber-200/80 bg-white/80 px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-amber-900 dark:border-amber-800/80 dark:bg-amber-950/30 dark:text-amber-100">
<div>Ignored surface</div>
<div>What Pulse is ignoring</div>
<div>ID</div>
<div class="text-right">Recovery</div>
</div>
<div class="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1.5fr)_minmax(0,1fr)_auto] gap-0 bg-transparent px-3 py-2 text-xs">
<div class="pr-3 font-medium text-base-content">
{row().capabilities.map(getCapabilitySurfaceLabel).join(', ')}
</div>
<div class="pr-3 text-muted">
Pulse will ignore new reports for this surface until reconnect is allowed.
</div>
<div class="pr-3 text-muted">
<span class="font-mono text-base-content">{blockedId()}</span>
</div>
<div class="text-right text-[11px] text-muted">Ready to return</div>
</div>
</div>
</div>
<div class="rounded-lg border border-amber-200/80 bg-white/70 px-4 py-4 dark:border-amber-800/80 dark:bg-amber-950/20">
<div class="text-xs font-semibold uppercase tracking-wide text-muted">Recovery action</div>
<div class="mt-2 text-xs text-muted">Allow this blocked ID to report again.</div>
<div class="mt-4 flex flex-col gap-3">
<button
onClick={() =>
row().capabilities.includes('docker')
? void state.handleAllowDockerReconnect(row())
: row().capabilities.includes('kubernetes')
? void state.handleAllowKubernetesReconnect(row())
: void state.handleAllowHostReconnect(row())
}
disabled={Boolean(pendingAction())}
class="inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md bg-white px-3 py-2 text-sm font-medium text-blue-600 shadow-sm ring-1 ring-border hover:bg-blue-50 hover:text-blue-900 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-slate-900 dark:text-blue-400 dark:ring-slate-700 dark:hover:bg-blue-900 dark:hover:text-blue-300"
>
{isAllowingReconnect() ? 'Allowing reconnect…' : reconnectLabel()}
</button>
<div class="text-xs text-muted">
This only changes Pulse. It does not reinstall software.
</div>
</div>
</div>
</div>
</div>
);
};
export default InfrastructureIgnoredRowDetails;

View file

@ -1,10 +1,13 @@
import type { Component } from 'solid-js';
import { useInfrastructureOperationsState } from './useInfrastructureOperationsState';
import { InfrastructureInstallerSection } from './InfrastructureInstallerSection';
import { InfrastructureOperationsStateProvider } from './useInfrastructureOperationsState';
export const InfrastructureInstallPanel: Component = () => {
const state = useInfrastructureOperationsState({ embedded: true });
return state.renderInstallerSection();
return (
<InfrastructureOperationsStateProvider embedded>
<InfrastructureInstallerSection />
</InfrastructureOperationsStateProvider>
);
};
export default InfrastructureInstallPanel;

View file

@ -0,0 +1,664 @@
import type { Component } from 'solid-js';
import { For, Show } from 'solid-js';
import Server from 'lucide-solid/icons/server';
import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { copyToClipboard } from '@/utils/clipboard';
import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format';
import { notificationStore } from '@/stores/notifications';
import { getUnifiedAgentLookupStatusPresentation } from '@/utils/unifiedAgentStatusPresentation';
import {
getUnifiedAgentClipboardCopyErrorMessage,
getUnifiedAgentClipboardCopySuccessMessage,
} from '@/utils/unifiedAgentInventoryPresentation';
import { trackAgentInstallCommandCopied } from '@/utils/upgradeMetrics';
import {
INSTALL_PROFILE_OPTIONS,
normalizeTelemetryPart,
type InstallProfile,
UNIFIED_AGENT_TELEMETRY_SURFACE,
} from './infrastructureOperationsModel';
import { useInfrastructureOperationsContext } from './useInfrastructureOperationsState';
export const InfrastructureInstallerSection: Component = () => {
const state = useInfrastructureOperationsContext();
return (
<SettingsPanel
title={state.isEmbedded() ? 'Install on a host' : 'Infrastructure'}
description={
state.isEmbedded()
? 'Use the unified agent as the default path for hosts, Docker, Kubernetes, and agent-managed Proxmox.'
: 'Primary setup hub for unified agents across systems, Docker, Kubernetes, Proxmox, and related infrastructure.'
}
icon={<Server class="h-5 w-5" strokeWidth={2} />}
bodyClass="space-y-5"
>
<Show when={state.setupHandoff()}>
{(handoff) => (
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm text-emerald-950 dark:border-emerald-700 dark:bg-emerald-900 dark:text-emerald-50">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="space-y-2">
<p class="font-semibold">Security configured. Save these first-run credentials now.</p>
<p class="text-xs text-emerald-800 dark:text-emerald-200">
This is the canonical handoff from first-run setup into Infrastructure Install.
Generate a scoped install token below before copying agent commands.
</p>
<div class="grid gap-3 sm:grid-cols-3">
<div class="rounded-md border border-emerald-200 bg-white px-3 py-2 dark:border-emerald-800 dark:bg-emerald-950">
<div class="text-[11px] font-medium uppercase tracking-wide text-emerald-700 dark:text-emerald-300">
Username
</div>
<div class="mt-1 font-mono text-sm text-base-content">{handoff().username}</div>
</div>
<div class="rounded-md border border-emerald-200 bg-white px-3 py-2 dark:border-emerald-800 dark:bg-emerald-950">
<div class="text-[11px] font-medium uppercase tracking-wide text-emerald-700 dark:text-emerald-300">
Password
</div>
<div class="mt-1 break-all font-mono text-sm text-base-content">
{handoff().password}
</div>
</div>
<div class="rounded-md border border-emerald-200 bg-white px-3 py-2 dark:border-emerald-800 dark:bg-emerald-950">
<div class="text-[11px] font-medium uppercase tracking-wide text-emerald-700 dark:text-emerald-300">
Admin API Token
</div>
<div class="mt-1 break-all font-mono text-sm text-base-content">
{handoff().apiToken}
</div>
</div>
</div>
</div>
<div class="flex flex-wrap gap-2 lg:w-64 lg:flex-col">
<button
type="button"
onClick={() =>
void state.copySetupHandoffField(handoff().password, 'Copied first-run password.')
}
class="inline-flex items-center justify-center rounded-md border border-emerald-300 bg-white px-3 py-2 text-sm font-medium text-emerald-900 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-100 dark:hover:bg-emerald-800"
>
Copy password
</button>
<button
type="button"
onClick={() =>
void state.copySetupHandoffField(
handoff().apiToken,
'Copied first-run admin API token.',
)
}
class="inline-flex items-center justify-center rounded-md border border-emerald-300 bg-white px-3 py-2 text-sm font-medium text-emerald-900 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-100 dark:hover:bg-emerald-800"
>
Copy admin token
</button>
<button
type="button"
onClick={state.downloadSetupHandoff}
class="inline-flex items-center justify-center rounded-md border border-emerald-300 bg-white px-3 py-2 text-sm font-medium text-emerald-900 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-100 dark:hover:bg-emerald-800"
>
Download credentials
</button>
<button
type="button"
onClick={state.clearSetupHandoff}
class="inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-medium text-emerald-900 hover:bg-emerald-100 dark:text-emerald-100 dark:hover:bg-emerald-800"
>
Dismiss
</button>
</div>
</div>
</div>
)}
</Show>
<div class="rounded-md border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-900 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-100">
<p class="font-semibold">Unified Agent is the default monitoring gateway.</p>
<p class="mt-1 text-xs text-blue-800 dark:text-blue-200">
Install it on each system you want Pulse to monitor. The installer auto-detects
available platforms on that machine and enables the right integrations.
</p>
</div>
<Show when={!state.isEmbedded()}>
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-700 dark:bg-emerald-900 dark:text-emerald-100">
<div class="flex items-start gap-3">
<ProxmoxIcon class="mt-0.5 h-5 w-5 shrink-0 text-amber-500" />
<div class="flex-1">
<p class="text-sm">
Proxmox nodes can be added here with the unified agent for extra capabilities like
temperature monitoring and Pulse Patrol automation (auto-creates the required token
and links the node).
</p>
<button
type="button"
onClick={state.openDirectProxmoxSetup}
class="mt-2 inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-2 py-1.5 text-sm font-medium text-emerald-800 underline hover:bg-emerald-100 hover:text-emerald-900 dark:text-emerald-200 dark:hover:bg-emerald-900 dark:hover:text-emerald-100"
>
Need direct setup instead? Open Proxmox →
</button>
</div>
</div>
</div>
</Show>
<div class="space-y-5">
<div class="space-y-3">
<div class="space-y-1">
<p class="text-sm font-semibold text-base-content">
<span class="mr-1.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white">
1
</span>
Generate API token
</p>
<p class="ml-6 text-sm text-muted">
{state.requiresToken()
? 'Create a fresh token scoped for Agent, Docker, and Kubernetes monitoring.'
: 'Tokens are optional on this Pulse instance. Generate one if you want copied commands to preserve explicit credentialed transport.'}
</p>
</div>
<div class="flex gap-2">
<input
type="text"
value={state.tokenName()}
onInput={(event) => state.setTokenName(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !state.isGeneratingToken()) {
void state.handleGenerateToken();
}
}}
placeholder="Token name (optional)"
class="flex-1 rounded-md border border-border bg-surface px-3 py-2 text-sm text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:focus:border-blue-400 dark:focus:ring-blue-900"
/>
<button
type="button"
onClick={() => void state.handleGenerateToken()}
disabled={state.isGeneratingToken()}
class="inline-flex items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{state.isGeneratingToken()
? 'Generating…'
: state.hasToken()
? 'Generate another'
: 'Generate token'}
</button>
</div>
<Show when={state.latestRecord()}>
<div class="flex items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-4 py-2 text-xs text-blue-800 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-200">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span>
Token <strong>{state.latestRecord()?.name}</strong> created. Commands below now
include this credential.
</span>
</div>
</Show>
</div>
<Show when={!state.requiresToken()}>
<div class="space-y-3">
<div class="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-900 dark:text-amber-200">
Tokens are optional on this Pulse instance. Confirm to generate commands without
embedding a token.
</div>
<button
type="button"
onClick={state.acknowledgeNoToken}
disabled={state.confirmedNoToken()}
class={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors ${
state.confirmedNoToken()
? 'cursor-default bg-green-600 text-white'
: 'border border-border bg-surface text-base-content hover:bg-surface-hover'
}`}
>
{state.confirmedNoToken() ? 'No token confirmed' : 'Confirm without token'}
</button>
</div>
</Show>
<Show when={state.requiresToken() && !state.commandsUnlocked()}>
<div class="pointer-events-none select-none space-y-3 opacity-60">
<div class="flex items-center justify-between">
<div>
<h4 class="text-sm font-semibold text-base-content">
<span class="mr-1.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-slate-400 text-xs font-bold text-white">
2
</span>
Installation commands
</h4>
<p class="ml-6 mt-0.5 text-xs text-muted">
Generate a token above to unlock installation commands.
</p>
</div>
</div>
<div class="rounded-md border border-border bg-surface-hover px-4 py-6 text-center">
<svg
class="mx-auto mb-2 h-8 w-8 text-muted"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
/>
</svg>
<p class="text-sm text-muted">Click "Generate token" above to see installation commands</p>
</div>
</div>
</Show>
<Show when={state.commandsUnlocked()}>
<div class="space-y-3">
<div class="space-y-3">
<div class="flex items-center justify-between">
<div>
<h4 class="text-sm font-semibold text-base-content">
<Show when={state.requiresToken()}>
<span class="mr-1.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-green-600 text-xs font-bold text-white">
2
</span>
</Show>
Installation commands
</h4>
<p class={`mt-0.5 text-xs text-muted ${state.requiresToken() ? 'ml-6' : ''}`}>
The installer auto-detects Docker, Kubernetes, and Proxmox on the target
machine.
</p>
</div>
</div>
<div class="rounded-md border border-border bg-surface-hover px-4 py-3">
<label class="mb-1.5 block text-xs font-medium text-base-content">
Connection URL (Agent → Pulse)
</label>
<div class="flex gap-2">
<input
type="text"
value={state.customAgentUrl()}
onInput={(event) => state.setCustomAgentUrl(event.currentTarget.value)}
placeholder={state.agentUrl()}
class="flex-1 rounded-md border bg-surface px-3 py-1.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:border-blue-400 dark:focus:ring-blue-800"
/>
</div>
<p class="mt-1.5 text-xs text-muted">
Override the address agents use to connect to this server (e.g., use IP address{' '}
<code>http://192.0.2.50:7655</code> if DNS fails).
<Show when={!state.customAgentUrl()}>
<span class="ml-1 opacity-75">Currently using auto-detected: {state.agentUrl()}</span>
</Show>
</p>
</div>
<div class="rounded-md border border-border bg-surface-hover px-4 py-3">
<label
for="custom-ca-certificate-path"
class="mb-1.5 block text-xs font-medium text-base-content"
>
Custom CA certificate path (optional)
</label>
<div class="flex gap-2">
<input
id="custom-ca-certificate-path"
type="text"
value={state.customCaPath()}
onInput={(event) => state.setCustomCaPath(event.currentTarget.value)}
placeholder={
state.selectedAgentUrl().startsWith('http://')
? 'Not needed for plain HTTP'
: 'Examples: /etc/pulse/ca.pem or C:\\Pulse\\ca.cer'
}
class="flex-1 rounded-md border bg-surface px-3 py-1.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:border-blue-400 dark:focus:ring-blue-800"
/>
</div>
<p class="mt-1.5 text-xs text-muted">
Preserves custom trust for copied install, upgrade, and uninstall commands.
Shell commands pass <code>--cacert</code> to both the download and the installer.
Windows commands set <code>PULSE_CACERT</code> and use a transport-aware
PowerShell bootstrap for the initial script fetch.
</p>
</div>
<Show when={state.insecureMode()}>
<div class="rounded-md border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-900 dark:text-amber-200">
<span class="font-medium">TLS verification disabled</span> — skip cert checks for
self-signed setups. Not recommended for production.
</div>
</Show>
<label
class="inline-flex cursor-pointer items-center gap-2 text-sm text-base-content"
title="Skip TLS certificate verification (for self-signed certificates)"
>
<input
type="checkbox"
checked={state.insecureMode()}
onChange={(event) => state.setInsecureMode(event.currentTarget.checked)}
class="rounded text-blue-600 focus:ring-blue-500"
/>
Skip TLS certificate verification (self-signed certs; not recommended)
</label>
<label
class="inline-flex cursor-pointer items-center gap-2 text-sm text-base-content"
title="Allow Pulse Patrol to execute diagnostic and fix commands on this agent (auto-fix requires Pulse Pro)"
>
<input
type="checkbox"
checked={state.enableCommands()}
onChange={(event) => state.setEnableCommands(event.currentTarget.checked)}
class="rounded text-blue-600 focus:ring-blue-500"
/>
Enable Pulse command execution (for Patrol auto-fix)
</label>
<Show when={state.enableCommands()}>
<div class="rounded-md border border-blue-200 bg-blue-50 px-4 py-2 text-sm text-blue-800 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-200">
<span class="font-medium">Pulse commands enabled</span> — The agent will accept
diagnostic and fix commands from Pulse Patrol features.
</div>
</Show>
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm text-emerald-900 dark:border-emerald-700 dark:bg-emerald-900 dark:text-emerald-100">
<span class="font-medium">Config signing (optional)</span> — Require signed remote
config payloads with <code>PULSE_AGENT_CONFIG_SIGNATURE_REQUIRED=true</code>.
Provide keys via <code>PULSE_AGENT_CONFIG_SIGNING_KEY</code> (Pulse) and{' '}
<code>PULSE_AGENT_CONFIG_PUBLIC_KEYS</code> (agents).
</div>
<div class="rounded-md border border-border bg-surface-hover px-4 py-3">
<label for="install-profile-select" class="mb-1.5 block text-xs font-medium text-base-content">
Target profile (optional)
</label>
<select
id="install-profile-select"
value={state.installProfile()}
onChange={(event) =>
state.handleInstallProfileChange(event.currentTarget.value as InstallProfile)
}
class="w-full rounded-md border bg-surface px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:border-blue-400 dark:focus:ring-blue-800"
>
<For each={INSTALL_PROFILE_OPTIONS}>
{(option) => <option value={option.value}>{option.label}</option>}
</For>
</select>
<p class="mt-1.5 text-xs text-muted">{state.getSelectedInstallProfile().description}</p>
<Show when={state.getInstallProfileFlags().length > 0}>
<p class="mt-1.5 text-xs text-muted">
Adds flags to shell-based install commands:{' '}
<code>{state.getInstallProfileFlags().join(' ')}</code>
</p>
</Show>
</div>
</div>
<div class="space-y-4">
<For each={state.commandSections()}>
{(section) => (
<div class="space-y-3 rounded-md border border-border p-4">
<div class="space-y-1">
<h5 class="text-sm font-semibold text-base-content">{section.title}</h5>
<p class="text-xs text-muted">{section.description}</p>
</div>
<div class="space-y-3">
<For each={section.snippets}>
{(snippet) => {
const copyCommand = () => snippet.command;
const commandTelemetryCapability = () => {
const label = normalizeTelemetryPart(snippet.label) || 'install';
return `${section.platform}:${state.installProfile()}:${label}`;
};
return (
<div class="space-y-2">
<h6 class="text-xs font-semibold uppercase tracking-wide text-muted">
{snippet.label}
</h6>
<div class="relative">
<button
type="button"
onClick={async () => {
const success = await copyToClipboard(copyCommand());
if (success) {
trackAgentInstallCommandCopied(
UNIFIED_AGENT_TELEMETRY_SURFACE,
commandTelemetryCapability(),
);
notificationStore.success(
getUnifiedAgentClipboardCopySuccessMessage(),
);
} else {
notificationStore.error(
getUnifiedAgentClipboardCopyErrorMessage(),
);
}
}}
class="absolute right-2 top-2 inline-flex min-h-10 min-w-10 items-center justify-center rounded-md bg-surface-hover p-2 transition-colors hover:text-slate-200 sm:min-h-9 sm:min-w-9"
title="Copy command"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
</svg>
</button>
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
<code>{copyCommand()}</code>
</pre>
</div>
<Show when={snippet.note}>
<p class="text-xs text-muted">{snippet.note}</p>
</Show>
</div>
);
}}
</For>
</div>
</div>
)}
</For>
</div>
<div class="space-y-3 rounded-md border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-900 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-100">
<div class="flex items-center justify-between gap-3">
<h5 class="text-sm font-semibold">Check installation status</h5>
<button
type="button"
onClick={() => void state.handleLookup()}
disabled={state.lookupLoading()}
class="rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{state.lookupLoading() ? 'Checking…' : 'Check status'}
</button>
</div>
<p class="text-xs text-blue-800 dark:text-blue-200">
Enter the hostname (or agent ID) from the machine you just installed. Pulse returns
the latest status instantly.
</p>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<input
type="text"
value={state.lookupValue()}
onInput={(event) => {
state.setLookupValue(event.currentTarget.value);
state.clearLookupState();
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void state.handleLookup();
}
}}
placeholder="Hostname or agent ID"
class="flex-1 rounded-md border border-blue-200 bg-surface px-3 py-2 text-sm text-blue-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-100 dark:focus:border-blue-300 dark:focus:ring-blue-800"
/>
</div>
<Show when={state.lookupError()}>
<p class="text-xs font-medium text-red-600 dark:text-red-300">{state.lookupError()}</p>
</Show>
<Show when={state.lookupResult()}>
{(result) => {
const agent = () => result().agent!;
const lookupStatusPresentation = () =>
getUnifiedAgentLookupStatusPresentation(agent().connected);
return (
<div class="space-y-1 rounded-md border border-blue-200 bg-surface px-3 py-2 text-xs text-blue-900 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-100">
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div class="text-sm font-semibold">{agent().displayName || agent().hostname}</div>
<div class="flex items-center gap-2">
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold ${lookupStatusPresentation().badgeClass}`}
>
{lookupStatusPresentation().label}
</span>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium text-blue-700 dark:bg-blue-900 dark:text-blue-200">
{agent().status || 'unknown'}
</span>
</div>
</div>
<div>
Last seen {formatRelativeTime(agent().lastSeen)} (
{formatAbsoluteTime(agent().lastSeen)})
</div>
<Show when={agent().agentVersion}>
<div class="text-xs text-blue-700 dark:text-blue-200">
Agent version {agent().agentVersion}
</div>
</Show>
</div>
);
}}
</Show>
</div>
<details class="rounded-md border border-border bg-surface-hover px-4 py-3 text-sm">
<summary class="cursor-pointer text-sm font-medium text-base-content">
Troubleshooting
</summary>
<div class="mt-3 space-y-4">
<div>
<p class="text-xs uppercase tracking-wide text-muted">Auto-detection not working?</p>
<p class="mt-1 text-xs text-muted">
If Docker, Kubernetes, or Proxmox isn't detected automatically, add these flags
to the install command:
</p>
<ul class="mt-2 list-inside list-disc space-y-1 text-xs text-muted">
<li>
<code class="rounded bg-surface-hover px-1">--enable-docker</code> — Force
enable Docker/Podman monitoring
</li>
<li>
<code class="rounded bg-surface-hover px-1">--enable-kubernetes</code> — Force
enable Kubernetes monitoring
</li>
<li>
<code class="rounded bg-surface-hover px-1">--enable-proxmox</code> — Force
enable Proxmox integration (creates API token)
</li>
<li>
<code class="rounded bg-surface-hover px-1">--proxmox-type pve|pbs</code> —
Set Proxmox node mode explicitly
</li>
<li>
<code class="rounded bg-surface-hover px-1">--disable-docker</code> — Skip
Docker even if detected
</li>
</ul>
</div>
</div>
</details>
</div>
</Show>
<div class="mt-4 border-t border-border pt-4">
<div class="space-y-3">
<h4 class="text-sm font-semibold text-base-content">Uninstall agent</h4>
<p class="text-xs text-muted">
Run the appropriate command on your machine to remove the Pulse agent:
</p>
<div class="space-y-1">
<span class="text-xs font-medium text-muted">Linux / macOS / FreeBSD</span>
<div class="relative">
<button
type="button"
onClick={async () => {
const success = await copyToClipboard(state.getUninstallCommand());
if (success) {
notificationStore.success(getUnifiedAgentClipboardCopySuccessMessage());
} else {
notificationStore.error(getUnifiedAgentClipboardCopyErrorMessage());
}
}}
class="absolute right-2 top-2 inline-flex min-h-10 min-w-10 items-center justify-center rounded-md bg-surface-hover p-2 text-slate-400 transition-colors hover:bg-slate-700 hover:text-slate-200 sm:min-h-9 sm:min-w-9"
title="Copy command"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
</svg>
</button>
<pre class="overflow-x-auto rounded-md bg-slate-950 p-3 pr-12 font-mono text-xs text-red-400">
<code>{state.getUninstallCommand()}</code>
</pre>
</div>
</div>
<p class="text-xs italic text-muted">
If the agent can't reach this server, run directly on the machine:{' '}
<code class="rounded bg-surface-hover px-1 not-italic">
sudo bash /var/lib/pulse-agent/install.sh --uninstall
</code>{' '}
(TrueNAS:{' '}
<code class="rounded bg-surface-hover px-1 not-italic">
/data/pulse-agent/install.sh
</code>
, Unraid:{' '}
<code class="rounded bg-surface-hover px-1 not-italic">
/boot/config/plugins/pulse-agent/install.sh
</code>
)
</p>
<div class="space-y-1">
<span class="text-xs font-medium text-muted">Windows (PowerShell as Administrator)</span>
<div class="relative">
<button
type="button"
onClick={async () => {
const success = await copyToClipboard(state.getWindowsUninstallCommand());
if (success) {
notificationStore.success(getUnifiedAgentClipboardCopySuccessMessage());
} else {
notificationStore.error(getUnifiedAgentClipboardCopyErrorMessage());
}
}}
class="absolute right-2 top-2 inline-flex min-h-10 min-w-10 items-center justify-center rounded-md bg-surface-hover p-2 text-slate-400 transition-colors hover:bg-slate-700 hover:text-slate-200 sm:min-h-9 sm:min-w-9"
title="Copy command"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
</svg>
</button>
<pre class="overflow-x-auto rounded-md bg-slate-950 p-3 pr-12 font-mono text-xs text-red-400">
<code>{state.getWindowsUninstallCommand()}</code>
</pre>
</div>
</div>
</div>
</div>
</div>
</SettingsPanel>
);
};
export default InfrastructureInstallerSection;

View file

@ -0,0 +1,374 @@
import type { Component } from 'solid-js';
import { For, Show } from 'solid-js';
import Users from 'lucide-solid/icons/users';
import { Dialog } from '@/components/shared/Dialog';
import { PulseDataGrid } from '@/components/shared/PulseDataGrid';
import { SearchField } from '@/components/shared/SearchField';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format';
import { getMonitoringStoppedEmptyState, getRemovedUnifiedAgentItemLabel } from '@/utils/unifiedAgentInventoryPresentation';
import type { AgentCapability } from '@/utils/agentCapabilityPresentation';
import type { ScopeCategory } from './infrastructureOperationsModel';
import { getCapabilitySurfaceLabel } from './infrastructureOperationsModel';
import { InfrastructureActiveRowDetails } from './InfrastructureActiveRowDetails';
import { InfrastructureIgnoredRowDetails } from './InfrastructureIgnoredRowDetails';
import { useInfrastructureOperationsContext } from './useInfrastructureOperationsState';
export const InfrastructureInventorySection: Component = () => {
const state = useInfrastructureOperationsContext();
return (
<div class="space-y-6">
<div class="rounded-md border border-border bg-surface-alt px-4 py-3 text-sm">
<p class="text-base-content">{state.inventoryStatusSummaryText()}</p>
</div>
<SettingsPanel
title="Reporting now"
description="Hosts and runtimes currently checking in to Pulse."
icon={<Users class="h-5 w-5" strokeWidth={2} />}
bodyClass="space-y-4"
>
<div class="rounded-md border border-border bg-surface-alt px-4 py-3">
<p class="text-sm font-medium text-base-content">{state.reportingCoverageSummaryText()}</p>
<p class="mt-2 text-xs text-muted">
This workspace does not list every asset Pulse has discovered. It focuses on systems
and runtimes that are actively checking in right now.
</p>
</div>
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-4 dark:border-emerald-800 dark:bg-emerald-950/40">
<p class="text-xs font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-300">
Active reporting
</p>
<p class="mt-2 text-2xl font-semibold text-emerald-900 dark:text-emerald-100">
{state.filteredActiveRows().length}
</p>
<p class="mt-2 text-sm text-emerald-900 dark:text-emerald-200">
Item{state.filteredActiveRows().length === 1 ? '' : 's'} actively checking in to Pulse.
</p>
</div>
<Show when={state.hasLinkedAgents()}>
<div class="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 dark:border-blue-800 dark:bg-blue-900">
<svg
class="mt-0.5 h-4 w-4 shrink-0 text-blue-500 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p class="text-xs text-blue-700 dark:text-blue-300">
<span class="font-medium">{state.linkedAgents().length}</span> agent
{state.linkedAgents().length > 1 ? 's are' : ' is'} linked to Proxmox node
{state.linkedAgents().length > 1 ? 's' : ''} and flagged with a{' '}
<span class="font-medium text-blue-700 dark:text-blue-300">Linked</span> badge.
</p>
</div>
</Show>
<Show when={state.hasOutdatedAgents()}>
<div class="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-700 dark:bg-amber-900">
<div class="flex items-start gap-3">
<svg
class="mt-0.5 h-5 w-5 shrink-0 text-amber-500 dark:text-amber-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd"
/>
</svg>
<div class="flex-1 space-y-1">
<p class="text-sm font-medium text-amber-800 dark:text-amber-200">
{state.outdatedAgents().length} outdated agent
{state.outdatedAgents().length > 1 ? ' binaries' : ' binary'} detected
</p>
<p class="text-sm text-amber-700 dark:text-amber-300">
Older standalone agent binaries are deprecated. Expand a row to copy the upgrade
command.
</p>
</div>
</div>
</div>
</Show>
<div class="space-y-3">
<div class="min-w-[220px] space-y-1">
<label for="agent-filter-search" class="text-xs font-medium text-muted">
Search reporting items
</label>
<SearchField
placeholder="Search name, hostname, or ID"
value={state.filterSearch()}
onChange={state.setFilterSearch}
class="w-full"
inputClass="min-h-10 sm:min-h-9 px-3 py-2 sm:py-1.5 shadow-sm focus:ring-1"
/>
</div>
<div class="flex flex-wrap items-end gap-3">
<div class="pb-2 text-xs font-medium uppercase tracking-wide text-muted">
Refine results
</div>
<div class="space-y-1">
<label for="agent-filter-capability" class="text-xs font-medium text-muted">
Capability
</label>
<select
id="agent-filter-capability"
value={state.filterCapability()}
onChange={(event) =>
state.setFilterCapability(event.currentTarget.value as 'all' | AgentCapability)
}
class="min-h-10 sm:min-h-9 rounded-md border border-border bg-surface px-2.5 py-2 text-sm text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:border-blue-400 dark:focus:ring-blue-800 sm:py-1.5"
>
<option value="all">All capabilities</option>
<option value="agent">Agent</option>
<option value="docker">Docker</option>
<option value="kubernetes">Kubernetes</option>
<option value="proxmox">Proxmox</option>
<option value="pbs">PBS</option>
<option value="pmg">PMG</option>
</select>
</div>
<div class="space-y-1">
<label for="agent-filter-scope" class="text-xs font-medium text-muted">
Scope
</label>
<select
id="agent-filter-scope"
value={state.filterScope()}
onChange={(event) =>
state.setFilterScope(
event.currentTarget.value as 'all' | Exclude<ScopeCategory, 'na'>,
)
}
class="min-h-10 sm:min-h-9 rounded-md border border-border bg-surface px-2.5 py-2 text-sm text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:border-blue-400 dark:focus:ring-blue-800 sm:py-1.5"
>
<option value="all">All scopes</option>
<option value="default">Default</option>
<option value="profile">Profile assigned</option>
<option value="ai-managed">Patrol-managed</option>
</select>
</div>
<button
type="button"
onClick={state.resetFilters}
disabled={!state.hasFilters()}
class={`min-h-10 sm:min-h-9 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
state.hasFilters()
? 'text-base-content hover:bg-surface-alt'
: 'cursor-not-allowed text-slate-400'
}`}
>
Clear
</button>
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-3 text-xs text-muted">
<span>
Showing {state.filteredActiveRows().length} of {state.activeRows().length} active
records.
</span>
<Show when={state.filteredMonitoringStoppedRows().length > 0}>
<span>{state.filteredMonitoringStoppedRows().length} item(s) are currently ignored by Pulse.</span>
</Show>
</div>
<div class="rounded-md border border-border bg-surface-hover px-4 py-3 text-sm text-muted">
Stop monitoring removes an item from active reporting and moves it into the Ignored by
Pulse list. The remote system keeps running; Pulse simply ignores new reports until you
allow reconnect.
</div>
<Show when={state.inventoryActionNotice()}>
{(notice) => (
<div
class={`rounded-md border px-4 py-3 text-sm ${
notice().tone === 'success'
? 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-700 dark:bg-emerald-900 dark:text-emerald-100'
: 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-100'
}`}
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1">
<p class="font-semibold">{notice().title}</p>
<p class="text-xs opacity-90">{notice().detail}</p>
</div>
<div class="flex items-center gap-2">
<Show when={notice().showRecoveryQueueLink}>
<button
type="button"
onClick={state.scrollToRecoveryQueue}
class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-2.5 py-1.5 text-xs font-medium underline"
>
View ignored items
</button>
</Show>
<button
type="button"
onClick={() => state.setInventoryActionNotice(null)}
class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-2.5 py-1.5 text-xs font-medium underline"
aria-label="Dismiss inventory action message"
>
Dismiss
</button>
</div>
</div>
</div>
)}
</Show>
<div class="grid gap-4">
<div class="overflow-hidden rounded-xl border border-border bg-surface shadow-sm">
<div class="border-b border-border bg-surface-alt px-4 py-3">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted">
Browse reporting items
</div>
<div class="mt-2 text-sm text-base-content">
Select a reporting item to open its details drawer.
</div>
</div>
<PulseDataGrid
data={state.filteredActiveRows()}
emptyState={
state.hasFilters()
? 'No reporting items match the current filters.'
: 'Nothing is actively reporting to Pulse yet.'
}
desktopMinWidth="960px"
columns={state.reportingColumns}
keyExtractor={(row) => row.rowKey}
onRowClick={(row) => state.toggleAgentDetails(row.rowKey)}
/>
</div>
<Dialog
isOpen={Boolean(state.selectedActiveRow())}
onClose={() => state.setExpandedRowKey(null)}
layout="drawer-right"
panelClass="max-w-[760px]"
ariaLabel="Reporting item details"
>
<Show when={state.selectedActiveRow()}>
{(rowAccessor) => <InfrastructureActiveRowDetails rowAccessor={rowAccessor} />}
</Show>
</Dialog>
</div>
</SettingsPanel>
<Show when={state.showMonitoringStoppedSection()}>
<div ref={state.setRecoveryQueueSectionRef}>
<SettingsPanel
title="Ignored by Pulse"
description="Items you explicitly told Pulse to ignore stay out of live reporting until reconnect is allowed."
icon={<Users class="h-5 w-5" strokeWidth={2} />}
bodyClass="space-y-4"
>
<Show
when={state.filteredMonitoringStoppedRows().length > 0}
fallback={
<div class="rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted">
{getMonitoringStoppedEmptyState(state.hasFilters())}
</div>
}
>
<div class="grid gap-4">
<div class="overflow-hidden rounded-lg border border-amber-200 bg-amber-50/70 dark:border-amber-800 dark:bg-amber-950/30">
<div class="border-b border-amber-200 px-4 py-3 dark:border-amber-800">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-900 dark:text-amber-100">
Browse ignored items
</div>
<div class="mt-2 text-sm text-amber-950 dark:text-amber-100">
Select an ignored item to open its recovery drawer.
</div>
</div>
<div class="divide-y divide-amber-200/80 dark:divide-amber-800/80">
<For each={state.filteredMonitoringStoppedRows()}>
{(row) => {
const pendingAction = () => state.getPendingInventoryAction(row.rowKey);
const isSelected = () => state.selectedIgnoredRowKey() === row.rowKey;
return (
<button
type="button"
onClick={() => state.setSelectedIgnoredRowKey(row.rowKey)}
class={`flex w-full flex-col gap-2 px-4 py-3 text-left transition-colors ${
isSelected()
? 'bg-amber-100/80 ring-1 ring-inset ring-amber-300 dark:bg-amber-900/40 dark:ring-amber-700'
: 'hover:bg-amber-100/50 dark:hover:bg-amber-900/20'
}`}
>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h4 class="truncate text-sm font-semibold text-base-content">
{row.name}
</h4>
<span class="inline-flex items-center rounded-full bg-white/80 px-2 py-0.5 text-[11px] font-medium uppercase tracking-wide text-amber-800 dark:bg-amber-900/60 dark:text-amber-200">
{getRemovedUnifiedAgentItemLabel(row)}
</span>
</div>
<div class="mt-1 text-xs text-muted">
{row.capabilities.map(getCapabilitySurfaceLabel).join(', ')}
</div>
</div>
<div class="text-[11px] text-muted">
{pendingAction() === 'allow-reconnect'
? 'Reconnect in progress'
: 'Select to review'}
</div>
</div>
<div class="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted">
<Show
when={
row.displayName && row.hostname && row.displayName !== row.hostname
}
>
<span>Hostname: {row.hostname}</span>
</Show>
<span>
Stopped{' '}
{row.removedAt
? `${formatRelativeTime(row.removedAt)} (${formatAbsoluteTime(row.removedAt)})`
: 'at an unknown time'}
</span>
</div>
</button>
);
}}
</For>
</div>
</div>
<Dialog
isOpen={Boolean(state.selectedIgnoredRow())}
onClose={() => state.setSelectedIgnoredRowKey(null)}
layout="drawer-right"
panelClass="max-w-[720px]"
ariaLabel="Ignored item details"
>
<Show when={state.selectedIgnoredRow()}>
{(rowAccessor) => <InfrastructureIgnoredRowDetails rowAccessor={rowAccessor} />}
</Show>
</Dialog>
</div>
</Show>
</SettingsPanel>
</div>
</Show>
</div>
);
};
export default InfrastructureInventorySection;

View file

@ -1,8 +1,11 @@
import type { Component } from 'solid-js';
import { Show } from 'solid-js';
import { InfrastructureInstallerSection } from './InfrastructureInstallerSection';
import { InfrastructureInventorySection } from './InfrastructureInventorySection';
import { InfrastructureStopMonitoringDialog } from './InfrastructureStopMonitoringDialog';
import {
InfrastructureOperationsStateProvider,
type InfrastructureOperationsStateOptions,
useInfrastructureOperationsState,
} from './useInfrastructureOperationsState';
export interface InfrastructureOperationsControllerProps extends InfrastructureOperationsStateOptions {
@ -13,14 +16,20 @@ export interface InfrastructureOperationsControllerProps extends InfrastructureO
export const InfrastructureOperationsController: Component<
InfrastructureOperationsControllerProps
> = (props) => {
const state = useInfrastructureOperationsState({ embedded: props.embedded });
return (
<div class="space-y-6">
<Show when={props.showInventory ?? true}>{state.renderStopMonitoringDialog()}</Show>
<Show when={props.showInstaller ?? true}>{state.renderInstallerSection()}</Show>
<Show when={props.showInventory ?? true}>{state.renderInventorySection()}</Show>
</div>
<InfrastructureOperationsStateProvider embedded={props.embedded}>
<div class="space-y-6">
<Show when={props.showInventory ?? true}>
<InfrastructureStopMonitoringDialog />
</Show>
<Show when={props.showInstaller ?? true}>
<InfrastructureInstallerSection />
</Show>
<Show when={props.showInventory ?? true}>
<InfrastructureInventorySection />
</Show>
</div>
</InfrastructureOperationsStateProvider>
);
};

View file

@ -1,8 +1,10 @@
import type { Component } from 'solid-js';
import { AgentProfilesPanel } from './AgentProfilesPanel';
import { InfrastructureDirectConnectionsSummaryCard } from './InfrastructureDirectConnectionsSummaryCard';
import { InfrastructureInventorySection } from './InfrastructureInventorySection';
import { InfrastructureStopMonitoringDialog } from './InfrastructureStopMonitoringDialog';
import type { ProxmoxSettingsPanelProps } from './ProxmoxSettingsPanel';
import { useInfrastructureOperationsState } from './useInfrastructureOperationsState';
import { InfrastructureOperationsStateProvider } from './useInfrastructureOperationsState';
interface InfrastructureReportingPanelProps extends ProxmoxSettingsPanelProps {
onManageDirectConnections: () => void;
@ -11,12 +13,12 @@ interface InfrastructureReportingPanelProps extends ProxmoxSettingsPanelProps {
export const InfrastructureReportingPanel: Component<InfrastructureReportingPanelProps> = (
props,
) => {
const state = useInfrastructureOperationsState();
return (
<div class="space-y-6">
{state.renderStopMonitoringDialog()}
{state.renderInventorySection()}
<InfrastructureOperationsStateProvider>
<InfrastructureStopMonitoringDialog />
<InfrastructureInventorySection />
</InfrastructureOperationsStateProvider>
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
<InfrastructureDirectConnectionsSummaryCard

View file

@ -0,0 +1,114 @@
import type { Component } from 'solid-js';
import { For, Show } from 'solid-js';
import { Dialog } from '@/components/shared/Dialog';
import { getStopMonitoringSurfaces } from './infrastructureOperationsModel';
import { useInfrastructureOperationsContext } from './useInfrastructureOperationsState';
export const InfrastructureStopMonitoringDialog: Component = () => {
const state = useInfrastructureOperationsContext();
return (
<Dialog
isOpen={Boolean(state.stopMonitoringDialog())}
onClose={() => {
if (!state.stopMonitoringDialog()) return;
const row = state.stopMonitoringDialog()!.row;
if (state.getPendingInventoryAction(row.rowKey)) return;
state.setStopMonitoringDialog(null);
}}
panelClass="max-w-lg"
closeOnBackdrop={
!state.stopMonitoringDialog() ||
!state.getPendingInventoryAction(state.stopMonitoringDialog()!.row.rowKey)
}
ariaLabel="Confirm stop monitoring"
>
<Show when={state.stopMonitoringDialog()}>
{(dialog) => {
const row = () => dialog().row;
const pending = () => state.getPendingInventoryAction(row().rowKey) === 'stop-monitoring';
const isKubernetes = () =>
row().capabilities.includes('kubernetes') && !row().capabilities.includes('agent');
const affectedSurfaces = () => getStopMonitoringSurfaces(row());
return (
<div class="flex max-h-[90vh] flex-col">
<div class="border-b border-border px-6 py-4">
<h2 class="text-lg font-semibold text-base-content">Stop monitoring?</h2>
<p class="mt-1 text-sm text-muted">
Pulse will remove{' '}
<span class="font-medium text-base-content">{dialog().subject}</span> from
active reporting.
</p>
</div>
<div class="space-y-4 px-6 py-4">
<div class="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-900 dark:text-amber-100">
<p class="font-medium">{dialog().scopeLabel} will stop in Pulse.</p>
<p class="mt-1 text-xs opacity-90">
The remote system keeps running. Pulse will ignore future reports and move this
item into Ignored by Pulse until you allow reconnect.
</p>
</div>
<Show when={affectedSurfaces().length > 0}>
<div class="rounded-md border border-border bg-surface-hover px-4 py-3 text-sm text-muted">
<p class="font-medium text-base-content">
Pulse will stop these reporting surfaces
</p>
<div class="mt-3 grid gap-2">
<For each={affectedSurfaces()}>
{(surface) => (
<div class="rounded-md border border-border bg-surface px-3 py-2">
<div class="text-sm font-medium text-base-content">{surface.label}</div>
<div class="mt-1 text-xs text-muted">{surface.detail}</div>
<Show when={surface.idLabel && surface.idValue}>
<div class="mt-2 text-[11px] text-muted">
{surface.idLabel}:{' '}
<span class="font-mono text-base-content">{surface.idValue}</span>
</div>
</Show>
</div>
)}
</For>
</div>
</div>
</Show>
<div class="rounded-md border border-border bg-surface-hover px-4 py-3 text-sm text-muted">
<p class="font-medium text-base-content">What stays unchanged</p>
<p class="mt-1 text-xs">
{isKubernetes()
? 'The cluster itself is not uninstalled or shut down.'
: 'The host, containers, and installed agent binaries are not uninstalled or shut down.'}
</p>
</div>
</div>
<div class="flex flex-col-reverse gap-2 border-t border-border px-6 py-4 sm:flex-row sm:justify-end">
<button
type="button"
onClick={() => state.setStopMonitoringDialog(null)}
disabled={pending()}
class="inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md border border-border px-4 py-2 text-sm font-medium text-base-content hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60"
>
Cancel
</button>
<button
type="button"
onClick={() =>
isKubernetes()
? void state.handleRemoveKubernetesCluster(row())
: void state.handleRemoveAgent(row())
}
disabled={pending()}
class="inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{pending() ? 'Stopping…' : 'Confirm stop monitoring'}
</button>
</div>
</div>
);
}}
</Show>
</Dialog>
);
};
export default InfrastructureStopMonitoringDialog;

View file

@ -5,11 +5,14 @@ import { createStore } from 'solid-js/store';
import { Router, Route } from '@solidjs/router';
import { UnifiedAgents } from '../UnifiedAgents';
import infrastructureInstallPanelSource from '../InfrastructureInstallPanel.tsx?raw';
import infrastructureInstallerSectionSource from '../InfrastructureInstallerSection.tsx?raw';
import infrastructureOperationsControllerSource from '../InfrastructureOperationsController.tsx?raw';
import infrastructureOperationsModelSource from '../infrastructureOperationsModel.tsx?raw';
import infrastructureReportingPanelSource from '../InfrastructureReportingPanel.tsx?raw';
import infrastructureDirectConnectionsSummaryCardSource from '../InfrastructureDirectConnectionsSummaryCard.tsx?raw';
import infrastructureInventorySectionSource from '../InfrastructureInventorySection.tsx?raw';
import infrastructureOperationsStateSource from '../useInfrastructureOperationsState.tsx?raw';
import infrastructureStopMonitoringDialogSource from '../InfrastructureStopMonitoringDialog.tsx?raw';
import type {
Agent,
ConnectedInfrastructureItem,
@ -50,9 +53,15 @@ let securityStatusResponse = { requiresAuth: true, apiTokenConfigured: false };
describe('UnifiedAgents ownership guardrails', () => {
it('routes controller and workspace panels through the shared infrastructure operations state owner', () => {
expect(infrastructureOperationsControllerSource).toContain('useInfrastructureOperationsState');
expect(infrastructureInstallPanelSource).toContain('useInfrastructureOperationsState');
expect(infrastructureReportingPanelSource).toContain('useInfrastructureOperationsState');
expect(infrastructureOperationsControllerSource).toContain('InfrastructureOperationsStateProvider');
expect(infrastructureOperationsControllerSource).toContain('InfrastructureInstallerSection');
expect(infrastructureOperationsControllerSource).toContain('InfrastructureInventorySection');
expect(infrastructureOperationsControllerSource).toContain('InfrastructureStopMonitoringDialog');
expect(infrastructureInstallPanelSource).toContain('InfrastructureOperationsStateProvider');
expect(infrastructureInstallPanelSource).toContain('InfrastructureInstallerSection');
expect(infrastructureReportingPanelSource).toContain('InfrastructureOperationsStateProvider');
expect(infrastructureReportingPanelSource).toContain('InfrastructureInventorySection');
expect(infrastructureReportingPanelSource).toContain('InfrastructureStopMonitoringDialog');
expect(infrastructureReportingPanelSource).toContain(
'./InfrastructureDirectConnectionsSummaryCard',
);
@ -61,9 +70,18 @@ describe('UnifiedAgents ownership guardrails', () => {
'Direct Proxmox connections',
);
expect(infrastructureOperationsStateSource).toContain("./infrastructureOperationsModel");
expect(infrastructureOperationsStateSource).toContain('renderInstallerSection');
expect(infrastructureOperationsStateSource).toContain('renderInventorySection');
expect(infrastructureOperationsStateSource).toContain('renderStopMonitoringDialog');
expect(infrastructureOperationsStateSource).toContain(
'export const InfrastructureOperationsStateProvider',
);
expect(infrastructureOperationsStateSource).toContain(
'export const useInfrastructureOperationsContext',
);
expect(infrastructureOperationsStateSource).not.toContain('renderInstallerSection');
expect(infrastructureOperationsStateSource).not.toContain('renderInventorySection');
expect(infrastructureOperationsStateSource).not.toContain('renderStopMonitoringDialog');
expect(infrastructureInstallerSectionSource).toContain('useInfrastructureOperationsContext');
expect(infrastructureInventorySectionSource).toContain('useInfrastructureOperationsContext');
expect(infrastructureStopMonitoringDialogSource).toContain('useInfrastructureOperationsContext');
expect(infrastructureOperationsModelSource).toContain('export const getRowReportingSummary');
expect(infrastructureOperationsModelSource).toContain(
'export const getPowerShellInstallProfileEnvFromFlags',

View file

@ -4,7 +4,12 @@ import agentProfilesPanelStateSource from '../useAgentProfilesPanelState.ts?raw'
import apiTokenManagerSource from '../APITokenManager.tsx?raw';
import apiTokenManagerModelSource from '../apiTokenManagerModel.ts?raw';
import apiTokenManagerStateSource from '../useAPITokenManagerState.ts?raw';
import infrastructureActiveRowDetailsSource from '../InfrastructureActiveRowDetails.tsx?raw';
import infrastructureIgnoredRowDetailsSource from '../InfrastructureIgnoredRowDetails.tsx?raw';
import infrastructureInstallerSectionSource from '../InfrastructureInstallerSection.tsx?raw';
import infrastructureInventorySectionSource from '../InfrastructureInventorySection.tsx?raw';
import infrastructureOperationsStateSource from '../useInfrastructureOperationsState.tsx?raw';
import infrastructureStopMonitoringDialogSource from '../InfrastructureStopMonitoringDialog.tsx?raw';
import agentLedgerPanelSource from '../MonitoredSystemLedgerPanel.tsx?raw';
import alertsPageSource from '@/pages/Alerts.tsx?raw';
import setupCompletionPanelSource from '@/components/SetupWizard/SetupCompletionPanel.tsx?raw';
@ -97,6 +102,14 @@ const aiSettingsSource = [
aiSettingsModelSource,
aiSettingsStateSource,
].join('\n');
const infrastructureOperationsSource = [
infrastructureOperationsStateSource,
infrastructureInstallerSectionSource,
infrastructureInventorySectionSource,
infrastructureActiveRowDetailsSource,
infrastructureIgnoredRowDetailsSource,
infrastructureStopMonitoringDialogSource,
].join('\n');
import dashboardGuestPresentationSource from '@/utils/dashboardGuestPresentation.ts?raw';
import containerUpdatesSource from '@/stores/containerUpdates.ts?raw';
import websocketStoreSource from '@/stores/websocket.ts?raw';
@ -218,15 +231,15 @@ describe('monitored-system model guardrails', () => {
it('keeps UnifiedAgents free of v5 merge-workaround patterns', () => {
expect(infrastructureOperationsStateSource).not.toContain('previousHostTypes');
expect(infrastructureOperationsStateSource).not.toContain('const allHosts = createMemo(');
expect(infrastructureOperationsStateSource).toContain('@/utils/unifiedAgentStatusPresentation');
expect(infrastructureOperationsStateSource).not.toContain(
expect(infrastructureOperationsSource).toContain('@/utils/unifiedAgentStatusPresentation');
expect(infrastructureOperationsSource).not.toContain(
'const MONITORING_STOPPED_STATUS_LABEL =',
);
expect(infrastructureOperationsStateSource).not.toContain('const ALLOW_RECONNECT_LABEL =');
expect(infrastructureOperationsSource).not.toContain('const ALLOW_RECONNECT_LABEL =');
expect(infrastructureOperationsStateSource).toContain('withPrivilegeEscalation');
expect(infrastructureOperationsStateSource).toContain('@/utils/agentCapabilityPresentation');
expect(infrastructureOperationsStateSource).not.toContain('const getCapabilityLabel =');
expect(infrastructureOperationsStateSource).not.toContain('const getCapabilityBadgeClass =');
expect(infrastructureOperationsSource).toContain('@/utils/agentCapabilityPresentation');
expect(infrastructureOperationsSource).not.toContain('const getCapabilityLabel =');
expect(infrastructureOperationsSource).not.toContain('const getCapabilityBadgeClass =');
expect(agentCapabilityPresentationSource).toContain("export type AgentCapability = 'agent'");
expect(agentCapabilityPresentationSource).toContain('export function getAgentCapabilityLabel');
expect(agentCapabilityPresentationSource).toContain(
@ -237,10 +250,10 @@ describe('monitored-system model guardrails', () => {
expect(agentProfilesPanelStateSource).toContain('isConnectedHealthStatus');
expect(agentProfilesPanelSource).not.toContain('const connectedFromStatus =');
expect(statusUtilsSource).toContain('export function isConnectedHealthStatus');
expect(infrastructureOperationsStateSource).toContain('@/utils/unifiedAgentStatusPresentation');
expect(infrastructureOperationsStateSource).not.toContain('const statusBadgeClass =');
expect(infrastructureOperationsStateSource).not.toContain('const statusBadgeClasses =');
expect(infrastructureOperationsStateSource).toContain(
expect(infrastructureOperationsSource).toContain('@/utils/unifiedAgentStatusPresentation');
expect(infrastructureOperationsSource).not.toContain('const statusBadgeClass =');
expect(infrastructureOperationsSource).not.toContain('const statusBadgeClasses =');
expect(infrastructureOperationsSource).toContain(
'getUnifiedAgentLookupStatusPresentation',
);
expect(unifiedAgentStatusPresentationSource).toContain(
@ -249,14 +262,14 @@ describe('monitored-system model guardrails', () => {
expect(unifiedAgentStatusPresentationSource).toContain(
'export function getUnifiedAgentLookupStatusPresentation',
);
expect(infrastructureOperationsStateSource).toContain('getInventorySubjectLabel');
expect(infrastructureOperationsStateSource).toContain('getMonitoringStoppedEmptyState');
expect(infrastructureOperationsStateSource).toContain('getRemovedUnifiedAgentItemLabel');
expect(infrastructureOperationsStateSource).toContain('getUnifiedAgentLastSeenLabel');
expect(infrastructureOperationsStateSource).not.toContain('const getInventorySubjectLabel =');
expect(infrastructureOperationsStateSource).not.toContain('const getRemovedItemLabel =');
expect(infrastructureOperationsStateSource).not.toContain('const lastSeenLabel = () => {');
expect(infrastructureOperationsStateSource).not.toContain(
expect(infrastructureOperationsSource).toContain('getInventorySubjectLabel');
expect(infrastructureOperationsSource).toContain('getMonitoringStoppedEmptyState');
expect(infrastructureOperationsSource).toContain('getRemovedUnifiedAgentItemLabel');
expect(infrastructureOperationsSource).toContain('getUnifiedAgentLastSeenLabel');
expect(infrastructureOperationsSource).not.toContain('const getInventorySubjectLabel =');
expect(infrastructureOperationsSource).not.toContain('const getRemovedItemLabel =');
expect(infrastructureOperationsSource).not.toContain('const lastSeenLabel = () => {');
expect(infrastructureOperationsSource).not.toContain(
'No monitoring-stopped items match the current filters.',
);
expect(unifiedAgentInventoryPresentationSource).toContain(
@ -289,15 +302,15 @@ describe('monitored-system model guardrails', () => {
expect(unifiedAgentInventoryPresentationSource).toContain(
'export function getUnifiedAgentClipboardCopySuccessMessage',
);
expect(infrastructureOperationsStateSource).not.toContain(
expect(infrastructureOperationsSource).not.toContain(
'No host identifiers are available to stop monitoring.',
);
expect(infrastructureOperationsStateSource).not.toContain(
expect(infrastructureOperationsSource).not.toContain(
'Failed to update agent configuration',
);
expect(infrastructureOperationsStateSource).not.toContain('Uninstall command copied');
expect(infrastructureOperationsStateSource).not.toContain('Upgrade command copied');
expect(infrastructureOperationsStateSource).not.toContain(
expect(infrastructureOperationsSource).not.toContain('Uninstall command copied');
expect(infrastructureOperationsSource).not.toContain('Upgrade command copied');
expect(infrastructureOperationsSource).not.toContain(
"notificationStore.error('Failed to copy')",
);
expect(relaySettingsPanelSource).toContain('getRelayConnectionPresentation');
@ -336,10 +349,10 @@ describe('monitored-system model guardrails', () => {
expect(relayOnboardingCardSource).not.toContain('Pair Your Mobile Device');
expect(relayOnboardingCardSource).not.toContain('Relay is currently disconnected.');
expect(infrastructureOperationsStateSource).toContain('STORAGE_KEYS.SETUP_HANDOFF');
expect(infrastructureOperationsStateSource).toContain(
expect(infrastructureInstallerSectionSource).toContain(
'Security configured. Save these first-run credentials now.',
);
expect(infrastructureOperationsStateSource).toContain(
expect(infrastructureInstallerSectionSource).toContain(
'Generate a scoped install token below before copying agent commands.',
);
expect(setupCompletionPanelSource).toContain('@/utils/relayPresentation');

View file

@ -4,10 +4,15 @@ import settingsShellSource from '../SettingsPageShell.tsx?raw';
import infrastructureWorkspaceSource from '../InfrastructureWorkspace.tsx?raw';
import infrastructureWorkspaceModelSource from '../infrastructureWorkspaceModel.ts?raw';
import infrastructureInstallPanelSource from '../InfrastructureInstallPanel.tsx?raw';
import infrastructureInstallerSectionSource from '../InfrastructureInstallerSection.tsx?raw';
import infrastructureOperationsControllerSource from '../InfrastructureOperationsController.tsx?raw';
import infrastructureOperationsModelSource from '../infrastructureOperationsModel.tsx?raw';
import infrastructureReportingPanelSource from '../InfrastructureReportingPanel.tsx?raw';
import infrastructureDirectConnectionsSummaryCardSource from '../InfrastructureDirectConnectionsSummaryCard.tsx?raw';
import infrastructureInventorySectionSource from '../InfrastructureInventorySection.tsx?raw';
import infrastructureActiveRowDetailsSource from '../InfrastructureActiveRowDetails.tsx?raw';
import infrastructureIgnoredRowDetailsSource from '../InfrastructureIgnoredRowDetails.tsx?raw';
import infrastructureStopMonitoringDialogSource from '../InfrastructureStopMonitoringDialog.tsx?raw';
import nodeModalModelSource from '../nodeModalModel.ts?raw';
import nodeModalSource from '../NodeModal.tsx?raw';
import infrastructureOperationsStateSource from '../useInfrastructureOperationsState.tsx?raw';
@ -85,8 +90,13 @@ const extractedModules = [
'../InfrastructureWorkspace.tsx',
'../infrastructureWorkspaceModel.ts',
'../InfrastructureInstallPanel.tsx',
'../InfrastructureInstallerSection.tsx',
'../InfrastructureReportingPanel.tsx',
'../InfrastructureDirectConnectionsSummaryCard.tsx',
'../InfrastructureInventorySection.tsx',
'../InfrastructureActiveRowDetails.tsx',
'../InfrastructureIgnoredRowDetails.tsx',
'../InfrastructureStopMonitoringDialog.tsx',
'../AIProviderConfigurationSection.tsx',
'../AISettingsDialogs.tsx',
'../aiSettingsModel.ts',
@ -374,17 +384,37 @@ describe('Settings architecture guardrails', () => {
expect(infrastructureWorkspaceSource).not.toContain('createEffect(() =>');
expect(infrastructureWorkspaceSource).toContain('InfrastructureInstallPanel');
expect(infrastructureWorkspaceSource).toContain('InfrastructureReportingPanel');
expect(infrastructureInstallPanelSource).toContain('useInfrastructureOperationsState');
expect(infrastructureReportingPanelSource).toContain('useInfrastructureOperationsState');
expect(infrastructureInstallPanelSource).toContain('InfrastructureOperationsStateProvider');
expect(infrastructureInstallPanelSource).toContain('InfrastructureInstallerSection');
expect(infrastructureReportingPanelSource).toContain('InfrastructureOperationsStateProvider');
expect(infrastructureReportingPanelSource).toContain('InfrastructureInventorySection');
expect(infrastructureReportingPanelSource).toContain('InfrastructureStopMonitoringDialog');
expect(infrastructureReportingPanelSource).toContain(
'./InfrastructureDirectConnectionsSummaryCard',
);
expect(infrastructureReportingPanelSource).not.toContain('Direct Proxmox connections');
expect(infrastructureReportingPanelSource).not.toContain('Manage direct connections');
expect(infrastructureOperationsControllerSource).toContain('useInfrastructureOperationsState');
expect(infrastructureOperationsControllerSource).toContain('InfrastructureOperationsStateProvider');
expect(infrastructureOperationsControllerSource).toContain('InfrastructureInstallerSection');
expect(infrastructureOperationsControllerSource).toContain('InfrastructureInventorySection');
expect(infrastructureOperationsControllerSource).toContain('InfrastructureStopMonitoringDialog');
expect(infrastructureOperationsStateSource).toContain(
'export const useInfrastructureOperationsState',
);
expect(infrastructureOperationsStateSource).toContain(
'export const InfrastructureOperationsStateProvider',
);
expect(infrastructureOperationsStateSource).toContain(
'export const useInfrastructureOperationsContext',
);
expect(infrastructureOperationsStateSource).not.toContain('const renderInstallerSection =');
expect(infrastructureOperationsStateSource).not.toContain('const renderInventorySection =');
expect(infrastructureOperationsStateSource).not.toContain('const renderStopMonitoringDialog =');
expect(infrastructureInstallerSectionSource).toContain('useInfrastructureOperationsContext');
expect(infrastructureInventorySectionSource).toContain('useInfrastructureOperationsContext');
expect(infrastructureStopMonitoringDialogSource).toContain('useInfrastructureOperationsContext');
expect(infrastructureActiveRowDetailsSource).toContain('useInfrastructureOperationsContext');
expect(infrastructureIgnoredRowDetailsSource).toContain('useInfrastructureOperationsContext');
expect(infrastructureWorkspaceModelSource).toContain(
'export const INFRASTRUCTURE_WORKSPACE_TABS',
);

View file

@ -1895,6 +1895,42 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
"frontend-modern/src/components/Settings/useProxmoxSettingsPanelState.ts"
)
def test_infrastructure_installer_section_change_requires_agent_lifecycle(self):
required = infer_impacted_subsystems(
["frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx"]
)
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"],
["frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx"],
)
self.assertEqual(
lifecycle["verification_requirements"],
[
{
"id": "unified-agent-settings-surface",
"label": "unified agent settings lifecycle proof",
"touched_runtime_files": [
"frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx"
],
"allow_same_subsystem_tests": False,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/api/__tests__/agentProfiles.test.ts",
"frontend-modern/src/api/__tests__/monitoring.test.ts",
"frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx",
"frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx",
],
}
],
)
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

@ -2062,6 +2062,42 @@ class SubsystemLookupTest(unittest.TestCase):
],
)
def test_lookup_paths_assigns_infrastructure_installer_section_to_agent_lifecycle(self) -> None:
result = lookup_paths(
["frontend-modern/src/components/Settings/InfrastructureInstallerSection.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.assertIsNone(file_entry["shared_ownership"])
self.assertEqual(len(file_entry["matches"]), 1)
match = file_entry["matches"][0]
self.assertEqual(match["subsystem"], "agent-lifecycle")
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"],
"unified-agent-settings-surface",
)
self.assertEqual(
match["verification_requirement"]["exact_files"],
[
"frontend-modern/src/api/__tests__/agentProfiles.test.ts",
"frontend-modern/src/api/__tests__/monitoring.test.ts",
"frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx",
"frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx",
],
)
def test_lookup_paths_reports_windows_installer_as_shared_boundary(self) -> None:
result = lookup_paths(["scripts/install.ps1"])
self.assertEqual(result["unowned_runtime_files"], [])