Re-home Agent Doctor from a settings modal to a routed page

Agent Doctor now lives at /settings/infrastructure/agent-doctor as a
full page in the settings shell instead of a max-w-lg dialog stacked
over the Infrastructure workspace, which was cramped on phones. The
per-agent card pile becomes one fleet table (agent, system, status,
reported and target versions, last seen) with per-row expansions
holding the diagnosis reasons, identity evidence, repair actions, and
the host-local update command. A lone scoped target auto-expands so
platform-page deep links land straight on the diagnosis.

Legacy ?agentDoctor=1 / ?agentUpdates=1 workspace links redirect onto
the route with their agents scope preserved, and closing the page is
now ordinary back navigation. The dialog component is renamed to
InfrastructureAgentDoctorPage; shared-template registry, the
agent-lifecycle subsystem inventory, and the frontend-primitives
settings deep-link clause follow the change.
This commit is contained in:
rcourtman
2026-07-22 10:26:06 +01:00
parent f1eac15833
commit c8b1e9d07b
22 changed files with 801 additions and 663 deletions
@@ -54,7 +54,7 @@ that binary, not separate customer-facing agent products.
22. `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx`
22a. `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx`
23. `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`
23a. `frontend-modern/src/components/Settings/InfrastructureAgentUpdatesDialog.tsx`
23a. `frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx`
23b. `frontend-modern/src/components/Settings/useAgentFleetDiagnostics.ts`
24. `frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx`
25. `frontend-modern/src/components/Settings/InfrastructureSourcePicker.tsx`
@@ -1268,7 +1268,7 @@ Agent`), with the plain-language source phrase available through accessible
become the primary top-level system wording when a provider/API platform or
reported host OS/appliance identity better explains what the operator is
looking at.
5. Keep settings deep-link route selection on the shared settings-navigation boundary. `frontend-modern/src/components/Settings/settingsNavigationModel.ts` and `frontend-modern/src/components/Settings/useSettingsNavigation.ts` must treat the canonical PBS and PMG Proxmox deep links as agent-selection authority even though those URLs resolve to the shared `infrastructure-operations` tab. Reloading or remounting on a PBS or PMG deep link must not silently fall back to the PVE selector state. Assistant OAuth callback compatibility queries such as `ai_oauth_error` and `ai_oauth_success` must route the bare settings root to Pulse Intelligence > Provider & Models while preserving the query long enough for `useAISettingsState` to consume and clear it, rather than normalizing the user back to Infrastructure and dropping the callback result.
5. Keep settings deep-link route selection on the shared settings-navigation boundary. `frontend-modern/src/components/Settings/settingsNavigationModel.ts` and `frontend-modern/src/components/Settings/useSettingsNavigation.ts` must treat the canonical PBS and PMG Proxmox deep links as agent-selection authority even though those URLs resolve to the shared `infrastructure-operations` tab. Reloading or remounting on a PBS or PMG deep link must not silently fall back to the PVE selector state. Assistant OAuth callback compatibility queries such as `ai_oauth_error` and `ai_oauth_success` must route the bare settings root to Pulse Intelligence > Provider & Models while preserving the query long enough for `useAISettingsState` to consume and clear it, rather than normalizing the user back to Infrastructure and dropping the callback result. The canonical Agent Doctor browser route is /settings/infrastructure/agent-doctor with optional agents scope query parameters; it is the only live routed subpath under the /settings/infrastructure workspace path (all other infrastructure subpaths stay retired compatibility paths), it resolves to the `infrastructure-systems` tab, and the settings navigation hook must canonicalize pre-route agentDoctor=1 and agentUpdates=1 workspace queries onto that route with their agents scope preserved instead of rendering Agent Doctor as a dialog stacked over the workspace.
6. Keep shared storage feature presenters on canonical platform truth. When reusable storage presenters under `frontend-modern/src/features/storageBackups/` classify canonical resources for the shared storage route, API-backed virtualization datastores such as VMware must stay inventory-only datastores instead of inheriting PBS-specific backup-repository or protected-target copy from older fallback branches.
Those reusable storage presenters must also keep primary issue copy separate
from contextual impact copy. Composite posture fields may include dependent
@@ -1123,8 +1123,8 @@
"frontend-modern/src/components/Settings/connectionsTableModel.ts",
"frontend-modern/src/components/Settings/DiscoverySettingsForm.tsx",
"frontend-modern/src/components/Settings/discoverySettingsModel.ts",
"frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx",
"frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts",
"frontend-modern/src/components/Settings/InfrastructureAgentUpdatesDialog.tsx",
"frontend-modern/src/components/Settings/InfrastructureDiscoverySettingsDialog.tsx",
"frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx",
"frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx",
@@ -1480,8 +1480,8 @@
"label": "Agent Doctor lifecycle and safe update-handoff proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx",
"frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts",
"frontend-modern/src/components/Settings/InfrastructureAgentUpdatesDialog.tsx",
"frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx",
"frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx",
"frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts",
@@ -956,7 +956,7 @@
{ "path": "src/components/Settings/CopyCommandBlock.tsx" },
{ "path": "src/components/Settings/DataHandlingPanel.tsx" },
{ "path": "src/components/Settings/GeneralSettingsPanel.tsx" },
{ "path": "src/components/Settings/InfrastructureAgentUpdatesDialog.tsx" },
{ "path": "src/components/Settings/InfrastructureAgentDoctorPage.tsx" },
{ "path": "src/components/Settings/InfrastructureDiscoverySettingsDialog.tsx" },
{ "path": "src/components/Settings/InfrastructureInstallerSection.tsx" },
{ "path": "src/components/Settings/InfrastructureSourceManager.tsx" },
@@ -16,6 +16,7 @@ import Shield from 'lucide-solid/icons/shield';
import Sparkles from 'lucide-solid/icons/sparkles';
import XCircle from 'lucide-solid/icons/x-circle';
import { Button, ButtonLink } from '@/components/shared/Button';
import { buildInfrastructureAgentDoctorPath } from './infrastructureWorkspaceModel';
import { EmptyState } from '@/components/shared/EmptyState';
import { StatusIndicatorBadge } from '@/components/shared/StatusIndicatorBadge';
import { getSemanticTonePresentation } from '@/utils/semanticTonePresentation';
@@ -386,11 +387,7 @@ export const DiagnosticsResultsPanel: Component<DiagnosticsResultsPanelProps> =
</div>
</Show>
<div class="mt-3 border-t border-border-subtle pt-3">
<ButtonLink
href="/settings/infrastructure?agentDoctor=1"
variant="outline"
size="sm"
>
<ButtonLink href={buildInfrastructureAgentDoctorPath()} variant="outline" size="sm">
Open Agent Doctor
</ButtonLink>
</div>
@@ -0,0 +1,491 @@
import { For, Show, createMemo, createSignal, type Component } from 'solid-js';
import { ChevronDown, ChevronLeft, ChevronRight, RefreshCw } from 'lucide-solid';
import { Button, CommandCopyButton } from '@/components/shared/Button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
import { copyToClipboard } from '@/utils/clipboard';
import { notificationStore } from '@/stores/notifications';
import {
getUnifiedAgentClipboardCopyErrorMessage,
getUnifiedAgentClipboardCopySuccessMessage,
} from '@/utils/unifiedAgentInventoryPresentation';
import {
summarizeInfrastructureAgentDoctorTargets,
type InfrastructureAgentDoctorStatus,
type InfrastructureAgentDoctorTarget,
} from './infrastructureAgentUpdateCommandsModel';
import { buildInfrastructureWorkspacePath } from './infrastructureWorkspaceModel';
import { useInfrastructureOperationsContext } from './useInfrastructureOperationsState';
interface InfrastructureAgentDoctorPageProps {
targets: readonly InfrastructureAgentDoctorTarget[];
diagnosticsLoading?: boolean;
diagnosticsError?: unknown;
onRetryDiagnostics?: () => void;
}
const STATUS_PRESENTATION: Record<
InfrastructureAgentDoctorStatus,
{ label: string; badgeClass: string }
> = {
healthy: {
label: 'Healthy',
badgeClass: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200',
},
waiting: {
label: 'Waiting for updater',
badgeClass: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
},
warning: {
label: 'Needs attention',
badgeClass: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
},
critical: {
label: 'Critical',
badgeClass: 'bg-rose-100 text-rose-800 dark:bg-rose-900 dark:text-rose-200',
},
removed: {
label: 'Removed',
badgeClass: 'bg-surface-alt text-muted',
},
unknown: {
label: 'Unknown',
badgeClass: 'bg-surface-alt text-base-content',
},
};
const formatLastSeen = (value?: number | string | null): string | undefined => {
if (!value) return undefined;
const timestamp = typeof value === 'number' ? value : Date.parse(value);
if (!Number.isFinite(timestamp)) return undefined;
return new Date(timestamp).toLocaleString();
};
export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorPageProps> = (
props,
) => {
const operations = useInfrastructureOperationsContext();
const summary = createMemo(() => summarizeInfrastructureAgentDoctorTargets(props.targets));
const anyTargetNeedsUpdate = createMemo(() => props.targets.some((target) => target.needsUpdate));
const summaryChips = createMemo(() => {
const counts = summary();
const order: { status: InfrastructureAgentDoctorStatus; count: number }[] = [
{ status: 'critical', count: counts.critical },
{ status: 'warning', count: counts.warning },
{ status: 'waiting', count: counts.waiting },
{ status: 'unknown', count: counts.unknown },
{ status: 'removed', count: counts.removed },
{ status: 'healthy', count: counts.healthy },
];
return order.filter((entry) => entry.count > 0);
});
const commandTargets = createMemo(() =>
props.targets.filter(
(target) =>
target.needsUpdate &&
Boolean(target.connection) &&
Boolean(target.commandPlatform) &&
!target.commandBlockedReason,
),
);
const tokenGatedTargetCount = createMemo(
() =>
commandTargets().filter(
(target) =>
target.connection &&
operations.getAgentConnectionUpgradeCommandRequiresToken(
target.connection,
target.commandPlatform ?? undefined,
),
).length,
);
const commandReadyForTarget = (target: InfrastructureAgentDoctorTarget) =>
Boolean(target.connection && target.commandPlatform && !target.commandBlockedReason) &&
(!operations.getAgentConnectionUpgradeCommandRequiresToken(
target.connection!,
target.commandPlatform!,
) ||
operations.commandsUnlocked());
const copyCommand = async (command: string) => {
const success = await copyToClipboard(command);
if (success) {
notificationStore.success(getUnifiedAgentClipboardCopySuccessMessage());
return;
}
notificationStore.error(getUnifiedAgentClipboardCopyErrorMessage());
};
// A lone target (the common case when a platform page deep-links one stale
// agent) starts expanded so its diagnosis and update command are immediately
// visible; larger fleets start collapsed and expand per row.
const [expansionOverrides, setExpansionOverrides] = createSignal<ReadonlyMap<string, boolean>>(
new Map(),
);
const isExpanded = (target: InfrastructureAgentDoctorTarget) =>
expansionOverrides().get(target.connectionId) ?? props.targets.length === 1;
const toggleExpanded = (target: InfrastructureAgentDoctorTarget) => {
setExpansionOverrides((previous) => {
const next = new Map(previous);
next.set(target.connectionId, !isExpanded(target));
return next;
});
};
return (
<div class="space-y-4">
<div class="space-y-1">
<a
href={buildInfrastructureWorkspacePath()}
class="inline-flex items-center gap-1 text-sm text-muted transition-colors hover:text-base-content"
>
<ChevronLeft class="h-4 w-4" aria-hidden="true" />
Infrastructure
</a>
<h2 class="text-base font-semibold text-base-content">Agent Doctor</h2>
<p class="text-sm text-muted">
Diagnose fleet connectivity, versions, identity, profiles, and removed-agent state.
</p>
</div>
<Show when={props.diagnosticsError}>
<section class="flex flex-col gap-3 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="font-semibold">Structured diagnostics are temporarily unavailable</div>
<p class="mt-1 text-xs leading-5 text-amber-800 dark:text-amber-200">
Showing the last known connection-ledger assessment. Profile and removed-agent details
may be incomplete.
</p>
</div>
<Show when={props.onRetryDiagnostics}>
<Button
type="button"
variant="outline"
size="sm"
class="gap-2 self-start"
onClick={() => props.onRetryDiagnostics?.()}
>
<RefreshCw class="h-3.5 w-3.5" aria-hidden="true" />
Retry
</Button>
</Show>
</section>
</Show>
<Show when={props.diagnosticsLoading && props.targets.length === 0}>
<div class="rounded-md border border-border bg-surface-alt px-4 py-3 text-sm text-muted">
Checking agent fleet health
</div>
</Show>
<Show
when={props.targets.length > 0}
fallback={
<Show when={!props.diagnosticsLoading}>
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-100">
No Pulse Agent connections are currently in scope.
</div>
</Show>
}
>
<section aria-label="Agent Doctor summary" class="flex flex-wrap items-center gap-2">
<For each={summaryChips()}>
{(chip) => (
<span
class={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium ${STATUS_PRESENTATION[chip.status].badgeClass}`}
>
<span class="font-semibold">{chip.count}</span>
{STATUS_PRESENTATION[chip.status].label}
</span>
)}
</For>
</section>
<Show when={anyTargetNeedsUpdate()}>
<div class="rounded-md border border-blue-200 bg-blue-50 px-4 py-3 text-xs leading-5 text-blue-900 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-100">
Update commands are host-local: copy one to the affected machine to update its Pulse
Agent from this server. They do not update the Pulse server runtime and Pulse does not
run them remotely.
</div>
</Show>
<Show
when={
tokenGatedTargetCount() > 0 &&
operations.requiresToken() &&
!operations.commandsUnlocked()
}
>
<section class="space-y-3 rounded-md border border-blue-200 bg-blue-50 px-4 py-3 dark:border-blue-800 dark:bg-blue-950/30">
<div class="space-y-1">
<h3 class="text-sm font-semibold text-blue-900 dark:text-blue-100">
Generate update token
</h3>
<p class="text-xs leading-5 text-blue-800 dark:text-blue-200">
{tokenGatedTargetCount() === 1
? 'One Windows repair needs a scoped install token before Pulse can show its command.'
: `${tokenGatedTargetCount()} Windows repairs need a scoped install token before Pulse can show their commands.`}
</p>
</div>
<div class="flex flex-col gap-2 sm:flex-row">
<input
type="text"
value={operations.tokenName()}
onInput={(event) => operations.setTokenName(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !operations.isGeneratingToken()) {
void operations.handleGenerateToken();
}
}}
placeholder="Token name (optional)"
class="min-h-10 flex-1 rounded-md border border-blue-200 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:border-blue-700 dark:bg-blue-950 dark:focus:ring-blue-900"
/>
<button
type="button"
onClick={() => void operations.handleGenerateToken()}
disabled={operations.isGeneratingToken()}
class="inline-flex min-h-10 items-center justify-center 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"
>
{operations.isGeneratingToken() ? 'Generating...' : 'Generate token'}
</button>
</div>
</section>
</Show>
<Show
when={
tokenGatedTargetCount() > 0 &&
!operations.requiresToken() &&
!operations.commandsUnlocked()
}
>
<section class="space-y-3 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100">
<p class="text-xs leading-5">
Tokens are optional on this Pulse instance. Confirm to generate Windows update
commands without embedding a token.
</p>
<button
type="button"
onClick={operations.acknowledgeNoToken}
disabled={operations.confirmedNoToken()}
class="inline-flex min-h-9 items-center justify-center rounded-md border border-amber-300 bg-surface px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-60"
>
{operations.confirmedNoToken() ? 'No token confirmed' : 'Confirm without token'}
</button>
</section>
</Show>
<div class="rounded-md border border-border bg-surface">
<Table class="w-full min-w-[760px] table-fixed text-sm">
<TableHeader class="bg-surface-alt/60">
<TableRow>
<TableHead class="w-[26%] py-1.5 pl-3 pr-3 text-left text-[11px] font-medium text-muted whitespace-nowrap">
Agent
</TableHead>
<TableHead class="w-[15%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap">
System
</TableHead>
<TableHead class="w-[15%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap">
Status
</TableHead>
<TableHead class="w-[12%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap">
Reported
</TableHead>
<TableHead class="w-[12%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap">
Target
</TableHead>
<TableHead class="w-[20%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap">
Last seen
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<For each={props.targets}>
{(target) => {
const status = () => STATUS_PRESENTATION[target.status];
const lastSeen = () => formatLastSeen(target.lastSeen);
const command = () =>
target.connection && target.commandPlatform
? operations.getAgentConnectionUpgradeCommand(
target.connection,
target.installFlags,
target.commandPlatform,
)
: '';
const otherRepairs = () =>
(target.diagnostic?.repairActions ?? []).filter(
(action) => action.code !== 'copy_upgrade_command',
);
const expanded = () => isExpanded(target);
return (
<>
<TableRow class="cursor-pointer" onClick={() => toggleExpanded(target)}>
<TableCell class="py-1.5 pl-3 pr-3">
<div class="flex items-center gap-1.5">
<button
type="button"
class="inline-flex items-center rounded p-0.5 text-muted transition-colors hover:bg-surface-alt hover:text-base-content"
aria-expanded={expanded()}
aria-label={`${expanded() ? 'Hide' : 'Show'} details for ${target.displayName}`}
onClick={(event) => {
event.stopPropagation();
toggleExpanded(target);
}}
>
<Show
when={expanded()}
fallback={<ChevronRight class="h-3.5 w-3.5" />}
>
<ChevronDown class="h-3.5 w-3.5" />
</Show>
</button>
<span class="truncate text-sm font-medium text-base-content">
{target.displayName}
</span>
</div>
</TableCell>
<TableCell class="px-3 py-1.5">
<span class="truncate text-xs text-muted">{target.contextLabel}</span>
</TableCell>
<TableCell class="px-3 py-1.5">
<span
class={`inline-flex w-fit items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${status().badgeClass}`}
>
{status().label}
</span>
</TableCell>
<TableCell class="px-3 py-1.5 text-xs text-base-content">
{target.currentVersion || '—'}
</TableCell>
<TableCell class="px-3 py-1.5 text-xs text-base-content">
{target.expectedVersion || '—'}
</TableCell>
<TableCell class="px-3 py-1.5 text-xs text-muted">
{lastSeen() || '—'}
</TableCell>
</TableRow>
<Show when={expanded()}>
<InlineDetailTableRow colspan={6}>
<div class="space-y-3 whitespace-normal">
<p class="text-xs text-muted">
{target.connectionId}
<Show when={target.updaterLabel}>
{' '}
· Updater: {target.updaterLabel}
</Show>
<Show when={target.profileLabel}>
{' '}
· Profile: {target.profileLabel}
<Show when={target.profileVersionLabel}>
{' '}
({target.profileVersionLabel})
</Show>
</Show>
</p>
<Show
when={target.reasons.length > 0}
fallback={
<p class="text-xs text-muted">
{target.status === 'healthy'
? 'No fleet-health issues detected.'
: 'Pulse has not received a structured diagnostic for this agent yet. This usually clears after its next report.'}
</p>
}
>
<ul class="space-y-2">
<For each={target.reasons}>
{(reason) => (
<li class="rounded-md border border-border-subtle bg-surface px-3 py-2">
<div class="text-xs font-medium text-base-content">
{reason.message}
</div>
<Show when={reason.evidence?.length}>
<div class="mt-1 text-[11px] leading-4 text-muted">
{(reason.evidence ?? []).join(' · ')}
</div>
</Show>
</li>
)}
</For>
</ul>
</Show>
<Show when={target.evidence.length > 0}>
<details class="text-xs text-muted">
<summary class="cursor-pointer font-medium text-base-content">
Identity evidence
</summary>
<ul class="mt-2 list-disc space-y-1 pl-5">
<For each={target.evidence}>{(item) => <li>{item}</li>}</For>
</ul>
</details>
</Show>
<For each={otherRepairs()}>
{(repair) => (
<div class="rounded-md border border-border bg-surface px-3 py-2 text-xs">
<div class="font-medium text-base-content">{repair.label}</div>
<div class="mt-0.5 text-muted">{repair.description}</div>
<Show when={repair.scope}>
<div class="mt-1 text-[11px] text-muted">
Required scope: {repair.scope}
</div>
</Show>
</div>
)}
</For>
<Show when={target.needsUpdate}>
<Show
when={!target.commandBlockedReason}
fallback={
<div class="rounded-md border border-border bg-surface px-3 py-3 text-xs text-muted">
{target.commandBlockedReason}
</div>
}
>
<Show
when={commandReadyForTarget(target)}
fallback={
<div class="rounded-md border border-border bg-surface px-3 py-3 text-xs text-muted">
Generate a token to unlock this host-local update command.
</div>
}
>
<div class="relative">
<CommandCopyButton
onClick={() => void copyCommand(command())}
title="Copy host-local agent update command"
label={`Copy update command for ${target.displayName}`}
/>
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
<code>{command()}</code>
</pre>
</div>
</Show>
</Show>
</Show>
</div>
</InlineDetailTableRow>
</Show>
</>
);
}}
</For>
</TableBody>
</Table>
</div>
</Show>
</div>
);
};
@@ -1,429 +0,0 @@
import { For, Show, createMemo, type Component } from 'solid-js';
import { RefreshCw, X } from 'lucide-solid';
import { Dialog } from '@/components/shared/Dialog';
import { Button, CommandCopyButton } from '@/components/shared/Button';
import { copyToClipboard } from '@/utils/clipboard';
import { notificationStore } from '@/stores/notifications';
import {
getUnifiedAgentClipboardCopyErrorMessage,
getUnifiedAgentClipboardCopySuccessMessage,
} from '@/utils/unifiedAgentInventoryPresentation';
import {
summarizeInfrastructureAgentDoctorTargets,
type InfrastructureAgentDoctorStatus,
type InfrastructureAgentDoctorTarget,
} from './infrastructureAgentUpdateCommandsModel';
import { useInfrastructureOperationsContext } from './useInfrastructureOperationsState';
interface InfrastructureAgentUpdatesDialogProps {
isOpen: boolean;
targets: readonly InfrastructureAgentDoctorTarget[];
diagnosticsLoading?: boolean;
diagnosticsError?: unknown;
onRetryDiagnostics?: () => void;
onClose: () => void;
}
const STATUS_PRESENTATION: Record<
InfrastructureAgentDoctorStatus,
{ label: string; badgeClass: string }
> = {
healthy: {
label: 'Healthy',
badgeClass: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200',
},
waiting: {
label: 'Waiting for updater',
badgeClass: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
},
warning: {
label: 'Needs attention',
badgeClass: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
},
critical: {
label: 'Critical',
badgeClass: 'bg-rose-100 text-rose-800 dark:bg-rose-900 dark:text-rose-200',
},
removed: {
label: 'Removed',
badgeClass: 'bg-surface-alt text-muted',
},
unknown: {
label: 'Unknown',
badgeClass: 'bg-surface-alt text-base-content',
},
};
const formatLastSeen = (value?: number | string | null): string | undefined => {
if (!value) return undefined;
const timestamp = typeof value === 'number' ? value : Date.parse(value);
if (!Number.isFinite(timestamp)) return undefined;
return new Date(timestamp).toLocaleString();
};
export const InfrastructureAgentUpdatesDialog: Component<InfrastructureAgentUpdatesDialogProps> = (
props,
) => {
const operations = useInfrastructureOperationsContext();
const summary = createMemo(() => summarizeInfrastructureAgentDoctorTargets(props.targets));
const anyTargetNeedsUpdate = createMemo(() => props.targets.some((target) => target.needsUpdate));
const summaryChips = createMemo(() => {
const counts = summary();
const order: { status: InfrastructureAgentDoctorStatus; count: number }[] = [
{ status: 'critical', count: counts.critical },
{ status: 'warning', count: counts.warning },
{ status: 'waiting', count: counts.waiting },
{ status: 'unknown', count: counts.unknown },
{ status: 'removed', count: counts.removed },
{ status: 'healthy', count: counts.healthy },
];
return order.filter((entry) => entry.count > 0);
});
const commandTargets = createMemo(() =>
props.targets.filter(
(target) =>
target.needsUpdate &&
Boolean(target.connection) &&
Boolean(target.commandPlatform) &&
!target.commandBlockedReason,
),
);
const tokenGatedTargetCount = createMemo(
() =>
commandTargets().filter(
(target) =>
target.connection &&
operations.getAgentConnectionUpgradeCommandRequiresToken(
target.connection,
target.commandPlatform ?? undefined,
),
).length,
);
const commandReadyForTarget = (target: InfrastructureAgentDoctorTarget) =>
Boolean(target.connection && target.commandPlatform && !target.commandBlockedReason) &&
(!operations.getAgentConnectionUpgradeCommandRequiresToken(
target.connection!,
target.commandPlatform!,
) ||
operations.commandsUnlocked());
const copyCommand = async (command: string) => {
const success = await copyToClipboard(command);
if (success) {
notificationStore.success(getUnifiedAgentClipboardCopySuccessMessage());
return;
}
notificationStore.error(getUnifiedAgentClipboardCopyErrorMessage());
};
return (
<Dialog isOpen={props.isOpen} onClose={props.onClose} ariaLabel="Agent Doctor">
<div class="flex h-full min-h-0 flex-col">
<div class="flex items-start justify-between gap-4 border-b border-border bg-surface-alt px-4 py-4 sm:px-6">
<div class="space-y-1">
<h2 class="text-base font-semibold text-base-content">Agent Doctor</h2>
<p class="text-sm text-muted">
Diagnose fleet connectivity, versions, identity, profiles, and removed-agent state.
</p>
</div>
<Button
type="button"
variant="outline"
size="iconMd"
onClick={props.onClose}
aria-label="Close Agent Doctor"
>
<X class="h-4 w-4" />
</Button>
</div>
<div class="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 sm:p-6">
<Show when={props.diagnosticsError}>
<section class="flex flex-col gap-3 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="font-semibold">Structured diagnostics are temporarily unavailable</div>
<p class="mt-1 text-xs leading-5 text-amber-800 dark:text-amber-200">
Showing the last known connection-ledger assessment. Profile and removed-agent
details may be incomplete.
</p>
</div>
<Show when={props.onRetryDiagnostics}>
<Button
type="button"
variant="outline"
size="sm"
class="gap-2 self-start"
onClick={() => props.onRetryDiagnostics?.()}
>
<RefreshCw class="h-3.5 w-3.5" aria-hidden="true" />
Retry
</Button>
</Show>
</section>
</Show>
<Show when={props.diagnosticsLoading && props.targets.length === 0}>
<div class="rounded-md border border-border bg-surface-alt px-4 py-3 text-sm text-muted">
Checking agent fleet health
</div>
</Show>
<Show
when={props.targets.length > 0}
fallback={
<Show when={!props.diagnosticsLoading}>
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-100">
No Pulse Agent connections are currently in scope.
</div>
</Show>
}
>
<section aria-label="Agent Doctor summary" class="flex flex-wrap items-center gap-2">
<For each={summaryChips()}>
{(chip) => (
<span
class={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium ${STATUS_PRESENTATION[chip.status].badgeClass}`}
>
<span class="font-semibold">{chip.count}</span>
{STATUS_PRESENTATION[chip.status].label}
</span>
)}
</For>
</section>
<Show when={anyTargetNeedsUpdate()}>
<div class="rounded-md border border-blue-200 bg-blue-50 px-4 py-3 text-xs leading-5 text-blue-900 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-100">
Update commands are host-local: copy one to the affected machine to update its Pulse
Agent from this server. They do not update the Pulse server runtime and Pulse does
not run them remotely.
</div>
</Show>
<Show
when={
tokenGatedTargetCount() > 0 &&
operations.requiresToken() &&
!operations.commandsUnlocked()
}
>
<section class="space-y-3 rounded-md border border-blue-200 bg-blue-50 px-4 py-3 dark:border-blue-800 dark:bg-blue-950/30">
<div class="space-y-1">
<h3 class="text-sm font-semibold text-blue-900 dark:text-blue-100">
Generate update token
</h3>
<p class="text-xs leading-5 text-blue-800 dark:text-blue-200">
{tokenGatedTargetCount() === 1
? 'One Windows repair needs a scoped install token before Pulse can show its command.'
: `${tokenGatedTargetCount()} Windows repairs need a scoped install token before Pulse can show their commands.`}
</p>
</div>
<div class="flex flex-col gap-2 sm:flex-row">
<input
type="text"
value={operations.tokenName()}
onInput={(event) => operations.setTokenName(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !operations.isGeneratingToken()) {
void operations.handleGenerateToken();
}
}}
placeholder="Token name (optional)"
class="min-h-10 flex-1 rounded-md border border-blue-200 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:border-blue-700 dark:bg-blue-950 dark:focus:ring-blue-900"
/>
<button
type="button"
onClick={() => void operations.handleGenerateToken()}
disabled={operations.isGeneratingToken()}
class="inline-flex min-h-10 items-center justify-center 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"
>
{operations.isGeneratingToken() ? 'Generating...' : 'Generate token'}
</button>
</div>
</section>
</Show>
<Show
when={
tokenGatedTargetCount() > 0 &&
!operations.requiresToken() &&
!operations.commandsUnlocked()
}
>
<section class="space-y-3 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100">
<p class="text-xs leading-5">
Tokens are optional on this Pulse instance. Confirm to generate Windows update
commands without embedding a token.
</p>
<button
type="button"
onClick={operations.acknowledgeNoToken}
disabled={operations.confirmedNoToken()}
class="inline-flex min-h-9 items-center justify-center rounded-md border border-amber-300 bg-surface px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-60"
>
{operations.confirmedNoToken() ? 'No token confirmed' : 'Confirm without token'}
</button>
</section>
</Show>
<div class="space-y-3">
<For each={props.targets}>
{(target) => {
const status = () => STATUS_PRESENTATION[target.status];
const lastSeen = () => formatLastSeen(target.lastSeen);
const command = () =>
target.connection && target.commandPlatform
? operations.getAgentConnectionUpgradeCommand(
target.connection,
target.installFlags,
target.commandPlatform,
)
: '';
const otherRepairs = () =>
(target.diagnostic?.repairActions ?? []).filter(
(action) => action.code !== 'copy_upgrade_command',
);
return (
<section class="space-y-3 rounded-md border border-border bg-surface px-4 py-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1">
<h3 class="text-sm font-semibold text-base-content">
{target.displayName}
</h3>
<p class="text-xs text-muted">
{target.contextLabel} · {target.connectionId}
<Show when={lastSeen()}> · Last seen {lastSeen()}</Show>
</p>
</div>
<span
class={`inline-flex w-fit items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${status().badgeClass}`}
>
{status().label}
</span>
</div>
<Show when={target.currentVersion || target.expectedVersion}>
<div class="grid gap-2 text-xs sm:grid-cols-2">
<Show when={target.currentVersion}>
<Detail label="Reported agent" value={target.currentVersion!} />
</Show>
<Show when={target.expectedVersion}>
<Detail label="Supported target" value={target.expectedVersion!} />
</Show>
</div>
</Show>
<Show when={target.updaterLabel}>
<Detail label="Agent updater" value={target.updaterLabel!} />
</Show>
<Show when={target.profileLabel}>
<div class="rounded-md border border-border-subtle bg-surface-alt px-3 py-2 text-xs text-base-content">
<span class="font-medium">Profile: {target.profileLabel}</span>
<Show when={target.profileVersionLabel}>
<span class="text-muted"> · {target.profileVersionLabel}</span>
</Show>
</div>
</Show>
<Show
when={target.reasons.length > 0}
fallback={
<p class="text-xs text-muted">
{target.status === 'healthy'
? 'No fleet-health issues detected.'
: 'Pulse has not received a structured diagnostic for this agent yet. This usually clears after its next report.'}
</p>
}
>
<ul class="space-y-2">
<For each={target.reasons}>
{(reason) => (
<li class="rounded-md border border-border-subtle bg-surface-alt px-3 py-2">
<div class="text-xs font-medium text-base-content">
{reason.message}
</div>
<Show when={reason.evidence?.length}>
<div class="mt-1 text-[11px] leading-4 text-muted">
{(reason.evidence ?? []).join(' · ')}
</div>
</Show>
</li>
)}
</For>
</ul>
</Show>
<Show when={target.evidence.length > 0}>
<details class="text-xs text-muted">
<summary class="cursor-pointer font-medium text-base-content">
Identity evidence
</summary>
<ul class="mt-2 list-disc space-y-1 pl-5">
<For each={target.evidence}>{(item) => <li>{item}</li>}</For>
</ul>
</details>
</Show>
<For each={otherRepairs()}>
{(repair) => (
<div class="rounded-md border border-border bg-surface-alt px-3 py-2 text-xs">
<div class="font-medium text-base-content">{repair.label}</div>
<div class="mt-0.5 text-muted">{repair.description}</div>
<Show when={repair.scope}>
<div class="mt-1 text-[11px] text-muted">
Required scope: {repair.scope}
</div>
</Show>
</div>
)}
</For>
<Show when={target.needsUpdate}>
<Show
when={!target.commandBlockedReason}
fallback={
<div class="rounded-md border border-border bg-surface-alt px-3 py-3 text-xs text-muted">
{target.commandBlockedReason}
</div>
}
>
<Show
when={commandReadyForTarget(target)}
fallback={
<div class="rounded-md border border-border bg-surface-alt px-3 py-3 text-xs text-muted">
Generate a token to unlock this host-local update command.
</div>
}
>
<div class="relative">
<CommandCopyButton
onClick={() => void copyCommand(command())}
title="Copy host-local agent update command"
label={`Copy update command for ${target.displayName}`}
/>
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
<code>{command()}</code>
</pre>
</div>
</Show>
</Show>
</Show>
</section>
);
}}
</For>
</div>
</Show>
</div>
</div>
</Dialog>
);
};
const Detail: Component<{ label: string; value: string }> = (props) => (
<div class="rounded-md border border-border-subtle bg-surface-alt px-3 py-2">
<div class="text-[11px] text-muted">{props.label}</div>
<div class="mt-0.5 font-medium text-base-content">{props.value}</div>
</div>
);
@@ -18,7 +18,7 @@ import type { TrueNASConnection } from '@/api/truenas';
import type { VMwareConnection } from '@/api/vmware';
import type { NodeConfig, NodeConfigWithStatus } from '@/types/nodes';
import { InfrastructureDiscoverySettingsDialog } from './InfrastructureDiscoverySettingsDialog';
import { InfrastructureAgentUpdatesDialog } from './InfrastructureAgentUpdatesDialog';
import { InfrastructureAgentDoctorPage } from './InfrastructureAgentDoctorPage';
import {
InfrastructureInstallerSection,
type InfrastructureInstallerFocus,
@@ -272,10 +272,6 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
navigateToWorkspace(Boolean(routeStep()));
};
const closeAgentDoctor = () => {
navigateToWorkspace(Boolean(showAgentDoctor()));
};
const closeEditFlow = () => {
const connection = editingConnection();
if (connection) {
@@ -901,198 +897,201 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
const isAgentDialog = () => activeAddType() === 'agent' || editingConnection()?.type === 'agent';
return (
<div class="space-y-6">
<InfrastructureSourceManager
rows={rows}
discoveredNodes={visibleDiscoveredNodes}
discoveryEnabled={props.discoveryEnabled()}
discoveryScanStatus={props.discoveryScanStatus}
readOnly={readOnly()}
onAddSource={
readOnly()
? undefined
: (type) => openAddFlow(type === 'agent' ? 'agent' : (type as ManagedAddTypeStep))
}
onAddSourceStep={readOnly() ? undefined : (step) => openAddFlow(step as ManagedAddTypeStep)}
onAddInfrastructure={readOnly() ? undefined : () => openAddFlow('pick')}
onRunDiscovery={
readOnly()
? undefined
: () => {
void props.triggerDiscoveryScan();
}
}
onOpenDiscoverySettings={readOnly() ? undefined : () => setShowDiscoverySettings(true)}
onOpenConnection={readOnly() ? undefined : (row) => setEditingRowSource(row)}
onOpenAgentDoctor={
readOnly()
? undefined
: (agentIds = []) =>
navigate(buildInfrastructureAgentDoctorPath(agentIds), { scroll: false })
}
onReviewDiscoveredSource={
readOnly() ? undefined : (server) => reviewDiscoveredSource(server)
}
/>
<InfrastructureDiscoverySettingsDialog
isOpen={showDiscoverySettings()}
onClose={() => setShowDiscoverySettings(false)}
discoveryEnabled={props.discoveryEnabled}
discoveryMode={props.discoveryMode}
discoverySubnetDraft={props.discoverySubnetDraft}
discoverySubnetError={props.discoverySubnetError}
savingDiscoverySettings={props.savingDiscoverySettings}
envOverrides={props.envOverrides}
handleDiscoveryEnabledChange={props.handleDiscoveryEnabledChange}
handleDiscoveryModeChange={props.handleDiscoveryModeChange}
setDiscoveryMode={props.setDiscoveryMode}
setDiscoverySubnetDraft={props.setDiscoverySubnetDraft}
setDiscoverySubnetError={props.setDiscoverySubnetError}
setLastCustomSubnet={props.setLastCustomSubnet}
commitDiscoverySubnet={props.commitDiscoverySubnet}
parseSubnetList={props.parseSubnetList}
normalizeSubnetList={props.normalizeSubnetList}
isValidCIDR={props.isValidCIDR}
currentDraftSubnetValue={props.currentDraftSubnetValue}
discoverySubnetInputRef={props.discoverySubnetInputRef}
/>
<Show when={showAgentDoctor()}>
<InfrastructureAgentUpdatesDialog
isOpen={true}
<Show
when={!showAgentDoctor()}
fallback={
<InfrastructureAgentDoctorPage
targets={agentDoctorTargets()}
diagnosticsLoading={agentDiagnostics.loading()}
diagnosticsError={agentDiagnostics.error()}
onRetryDiagnostics={() => void agentDiagnostics.reload()}
onClose={closeAgentDoctor}
/>
</Show>
}
>
<div class="space-y-6">
<InfrastructureSourceManager
rows={rows}
discoveredNodes={visibleDiscoveredNodes}
discoveryEnabled={props.discoveryEnabled()}
discoveryScanStatus={props.discoveryScanStatus}
readOnly={readOnly()}
onAddSource={
readOnly()
? undefined
: (type) => openAddFlow(type === 'agent' ? 'agent' : (type as ManagedAddTypeStep))
}
onAddSourceStep={
readOnly() ? undefined : (step) => openAddFlow(step as ManagedAddTypeStep)
}
onAddInfrastructure={readOnly() ? undefined : () => openAddFlow('pick')}
onRunDiscovery={
readOnly()
? undefined
: () => {
void props.triggerDiscoveryScan();
}
}
onOpenDiscoverySettings={readOnly() ? undefined : () => setShowDiscoverySettings(true)}
onOpenConnection={readOnly() ? undefined : (row) => setEditingRowSource(row)}
onOpenAgentDoctor={
readOnly()
? undefined
: (agentIds = []) =>
navigate(buildInfrastructureAgentDoctorPath(agentIds), { scroll: false })
}
onReviewDiscoveredSource={
readOnly() ? undefined : (server) => reviewDiscoveredSource(server)
}
/>
<Show when={routeStep() !== null}>
<Dialog
isOpen={true}
onClose={closeAddFlow}
ariaLabel={addDialogTitle()}
panelClass={isAgentDialog() ? 'max-w-6xl' : 'max-w-5xl'}
>
<div class="flex h-full min-h-0 flex-col">
<div class="flex items-start justify-between gap-4 border-b border-border bg-surface-alt px-4 py-4 sm:px-6">
<div class="space-y-1">
<h2 class="text-base font-semibold text-base-content">{addDialogTitle()}</h2>
<p class="text-sm text-muted">{addDialogDescription()}</p>
<InfrastructureDiscoverySettingsDialog
isOpen={showDiscoverySettings()}
onClose={() => setShowDiscoverySettings(false)}
discoveryEnabled={props.discoveryEnabled}
discoveryMode={props.discoveryMode}
discoverySubnetDraft={props.discoverySubnetDraft}
discoverySubnetError={props.discoverySubnetError}
savingDiscoverySettings={props.savingDiscoverySettings}
envOverrides={props.envOverrides}
handleDiscoveryEnabledChange={props.handleDiscoveryEnabledChange}
handleDiscoveryModeChange={props.handleDiscoveryModeChange}
setDiscoveryMode={props.setDiscoveryMode}
setDiscoverySubnetDraft={props.setDiscoverySubnetDraft}
setDiscoverySubnetError={props.setDiscoverySubnetError}
setLastCustomSubnet={props.setLastCustomSubnet}
commitDiscoverySubnet={props.commitDiscoverySubnet}
parseSubnetList={props.parseSubnetList}
normalizeSubnetList={props.normalizeSubnetList}
isValidCIDR={props.isValidCIDR}
currentDraftSubnetValue={props.currentDraftSubnetValue}
discoverySubnetInputRef={props.discoverySubnetInputRef}
/>
<Show when={routeStep() !== null}>
<Dialog
isOpen={true}
onClose={closeAddFlow}
ariaLabel={addDialogTitle()}
panelClass={isAgentDialog() ? 'max-w-6xl' : 'max-w-5xl'}
>
<div class="flex h-full min-h-0 flex-col">
<div class="flex items-start justify-between gap-4 border-b border-border bg-surface-alt px-4 py-4 sm:px-6">
<div class="space-y-1">
<h2 class="text-base font-semibold text-base-content">{addDialogTitle()}</h2>
<p class="text-sm text-muted">{addDialogDescription()}</p>
</div>
<Button
type="button"
variant="outline"
size="iconMd"
onClick={closeAddFlow}
aria-label="Close add infrastructure dialog"
>
<X class="h-4 w-4" />
</Button>
</div>
<Button
type="button"
variant="outline"
size="iconMd"
onClick={closeAddFlow}
aria-label="Close add infrastructure dialog"
<div class="min-h-0 flex-1 overflow-y-auto">
<Switch>
<Match when={routeStep() === 'pick'}>
<InfrastructureSourcePicker
onSelectStep={(step) => openAddFlow(step as ManagedAddTypeStep)}
onDetectApiPlatform={() => openAddFlow('detect')}
/>
</Match>
<Match when={routeStep() === 'detect'}>
<ConnectionEditor
mode="add"
onBackToCatalog={() => openAddFlow('pick')}
onSelectAgentRoute={() => openAddFlow('linux-host')}
onSelectCandidate={openAddFlowFromProbe}
onClose={closeAddFlow}
onSaved={handleAddSaved}
renderCredentialSlot={({ type, candidate, onCancel, onSaved }) =>
renderConnectionSlot({ mode: 'add', type, candidate, onCancel, onSaved })
}
/>
</Match>
<Match when={Boolean(activeAddType())}>
<ConnectionEditor
mode="add"
initialType={activeAddType() ?? undefined}
initialCandidate={selectedProbeCandidate()}
showSlotHeader={false}
onBackToCatalog={() => openAddFlow('pick')}
onClose={closeAddFlow}
onSaved={handleAddSaved}
renderCredentialSlot={({ type, candidate, onCancel, onSaved }) =>
renderConnectionSlot({ mode: 'add', type, candidate, onCancel, onSaved })
}
/>
</Match>
</Switch>
</div>
</div>
</Dialog>
</Show>
<Show when={editingConnection()}>
{(connectionAccessor) => {
const connection = connectionAccessor();
return (
<Dialog
isOpen={true}
onClose={closeEditFlow}
ariaLabel={editDialogTitle()}
panelClass={connection.type === 'agent' ? 'max-w-5xl' : 'max-w-5xl'}
>
<X class="h-4 w-4" />
</Button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
<Switch>
<Match when={routeStep() === 'pick'}>
<InfrastructureSourcePicker
onSelectStep={(step) => openAddFlow(step as ManagedAddTypeStep)}
onDetectApiPlatform={() => openAddFlow('detect')}
/>
</Match>
<Match when={routeStep() === 'detect'}>
<ConnectionEditor
mode="add"
onBackToCatalog={() => openAddFlow('pick')}
onSelectAgentRoute={() => openAddFlow('linux-host')}
onSelectCandidate={openAddFlowFromProbe}
onClose={closeAddFlow}
onSaved={handleAddSaved}
renderCredentialSlot={({ type, candidate, onCancel, onSaved }) =>
renderConnectionSlot({ mode: 'add', type, candidate, onCancel, onSaved })
}
/>
</Match>
<Match when={Boolean(activeAddType())}>
<ConnectionEditor
mode="add"
initialType={activeAddType() ?? undefined}
initialCandidate={selectedProbeCandidate()}
showSlotHeader={false}
onBackToCatalog={() => openAddFlow('pick')}
onClose={closeAddFlow}
onSaved={handleAddSaved}
renderCredentialSlot={({ type, candidate, onCancel, onSaved }) =>
renderConnectionSlot({ mode: 'add', type, candidate, onCancel, onSaved })
}
/>
</Match>
</Switch>
</div>
</div>
</Dialog>
</Show>
<Show when={editingConnection()}>
{(connectionAccessor) => {
const connection = connectionAccessor();
return (
<Dialog
isOpen={true}
onClose={closeEditFlow}
ariaLabel={editDialogTitle()}
panelClass={connection.type === 'agent' ? 'max-w-5xl' : 'max-w-5xl'}
>
<div class="flex h-full min-h-0 flex-col">
<div class="flex items-start justify-between gap-4 border-b border-border bg-surface-alt px-4 py-4 sm:px-6">
<div class="space-y-1">
<h2 class="text-base font-semibold text-base-content">{editDialogTitle()}</h2>
<p class="text-sm text-muted">{editDialogDescription()}</p>
<div class="flex h-full min-h-0 flex-col">
<div class="flex items-start justify-between gap-4 border-b border-border bg-surface-alt px-4 py-4 sm:px-6">
<div class="space-y-1">
<h2 class="text-base font-semibold text-base-content">{editDialogTitle()}</h2>
<p class="text-sm text-muted">{editDialogDescription()}</p>
</div>
<Button
type="button"
variant="outline"
size="iconMd"
onClick={closeEditFlow}
aria-label="Close edit infrastructure dialog"
>
<X class="h-4 w-4" />
</Button>
</div>
<Button
type="button"
variant="outline"
size="iconMd"
onClick={closeEditFlow}
aria-label="Close edit infrastructure dialog"
>
<X class="h-4 w-4" />
</Button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
<Show
when={connection.type === 'agent'}
fallback={
<div class="space-y-4">
<ConnectionEditor
mode="edit"
initialType={connection.type}
showSlotHeader={false}
onClose={closeEditFlow}
onSaved={handleEditSaved}
renderCredentialSlot={({ type, onCancel, onSaved }) =>
renderConnectionSlot({ mode: 'edit', type, onCancel, onSaved })
}
/>
<Show when={attachedAgentConnections().length > 0}>
{renderAttachedAgentAugmentations(attachedAgentConnections())}
</Show>
</div>
}
>
{renderAgentConnectionDetails(connection)}
</Show>
<div class="min-h-0 flex-1 overflow-y-auto">
<Show
when={connection.type === 'agent'}
fallback={
<div class="space-y-4">
<ConnectionEditor
mode="edit"
initialType={connection.type}
showSlotHeader={false}
onClose={closeEditFlow}
onSaved={handleEditSaved}
renderCredentialSlot={({ type, onCancel, onSaved }) =>
renderConnectionSlot({ mode: 'edit', type, onCancel, onSaved })
}
/>
<Show when={attachedAgentConnections().length > 0}>
{renderAttachedAgentAugmentations(attachedAgentConnections())}
</Show>
</div>
}
>
{renderAgentConnectionDetails(connection)}
</Show>
</div>
</div>
</div>
</Dialog>
);
}}
</Show>
</div>
</Dialog>
);
}}
</Show>
</div>
</Show>
);
};
@@ -240,7 +240,7 @@ describe('DiagnosticsResultsPanel', () => {
expect(screen.getByText('Agent-backed Docker / Podman monitoring')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Open Agent Doctor' })).toHaveAttribute(
'href',
'/settings/infrastructure?agentDoctor=1',
'/settings/infrastructure/agent-doctor',
);
expect(screen.queryByText('Container Runtime Agents')).not.toBeInTheDocument();
});
@@ -423,8 +423,7 @@ describe('InfrastructureWorkspace', () => {
renderWorkspace();
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
expect(screen.getByText('Agent Doctor')).toBeInTheDocument();
await waitFor(() => expect(screen.getByText('Agent Doctor')).toBeInTheDocument());
expect(screen.getByText('zeus')).toBeInTheDocument();
expect(screen.queryByText('other')).not.toBeInTheDocument();
expect(screen.getByText('5.1.34')).toBeInTheDocument();
@@ -433,11 +432,26 @@ describe('InfrastructureWorkspace', () => {
screen.getByText(/upgrade agent:agent-zeus --enable-proxmox --proxmox-type pve/),
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Close Agent Doctor' }));
expect(navigateSpy).toHaveBeenLastCalledWith('/settings/infrastructure', {
replace: true,
scroll: false,
});
// The page replaces the workspace content and links back to Infrastructure.
expect(screen.queryByText('Connected systems')).not.toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Infrastructure' })).toHaveAttribute(
'href',
'/settings/infrastructure',
);
});
it('renders the Agent Doctor page on its routed path', async () => {
routeState.pathname = '/settings/infrastructure/agent-doctor';
routeState.search = '';
renderWorkspace();
await waitFor(() => expect(screen.getByText('Agent Doctor')).toBeInTheDocument());
expect(screen.queryByText('Connected systems')).not.toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Infrastructure' })).toHaveAttribute(
'href',
'/settings/infrastructure',
);
});
it('does not invent scoped update commands when the backend publishes no agent target', async () => {
@@ -488,12 +502,11 @@ describe('InfrastructureWorkspace', () => {
renderWorkspace();
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('zeus')).toBeInTheDocument();
expect(within(dialog).getByText('Reported agent')).toBeInTheDocument();
// With no published target there is no "Supported target" cell to guess at.
expect(within(dialog).queryByText('Supported target')).not.toBeInTheDocument();
expect(within(dialog).queryByText(/upgrade agent:agent-zeus/)).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByText('Agent Doctor')).toBeInTheDocument());
expect(screen.getByText('zeus')).toBeInTheDocument();
expect(screen.getByText('5.1.34')).toBeInTheDocument();
// With no published target there is no target version or command to guess at.
expect(screen.queryByText(/upgrade agent:agent-zeus/)).not.toBeInTheDocument();
});
it('keeps source groups in the catalog order instead of count order', async () => {
@@ -29,11 +29,18 @@ describe('infrastructureWorkspaceModel', () => {
});
it('builds the canonical Agent Doctor route and accepts legacy update deep links', () => {
expect(buildInfrastructureAgentDoctorPath()).toBe('/settings/infrastructure?agentDoctor=1');
expect(buildInfrastructureAgentUpdatesPath()).toBe('/settings/infrastructure?agentDoctor=1');
expect(buildInfrastructureAgentDoctorPath()).toBe('/settings/infrastructure/agent-doctor');
expect(buildInfrastructureAgentUpdatesPath()).toBe('/settings/infrastructure/agent-doctor');
expect(buildInfrastructureAgentUpdatesPath(['agent:agent-delly', 'agent-pi'])).toBe(
'/settings/infrastructure?agentDoctor=1&agents=agent%3Aagent-delly&agents=agent%3Aagent-pi',
'/settings/infrastructure/agent-doctor?agents=agent%3Aagent-delly&agents=agent%3Aagent-pi',
);
expect(deriveAgentUpdatesFromLocation('/settings/infrastructure/agent-doctor', '')).toBe(true);
expect(
deriveAgentUpdateScopeFromLocation(
'/settings/infrastructure/agent-doctor',
'?agents=agent%3Aagent-pi&agents=agent-delly',
),
).toEqual(['agent:agent-delly', 'agent:agent-pi']);
expect(deriveAgentUpdatesFromLocation('/settings/infrastructure', '?agentDoctor=1')).toBe(true);
expect(deriveAgentUpdatesFromLocation('/settings/infrastructure', '?agentUpdates=1')).toBe(
true,
@@ -307,6 +307,20 @@ describe('settings architecture guardrails', () => {
);
});
it('keeps Agent Doctor route-backed under the infrastructure workspace', () => {
expect(settingsNavigationModelSource).toContain(
'normalizedPath !== INFRASTRUCTURE_AGENT_DOCTOR_PATH',
);
expect(settingsNavigationModelSource).toContain(
'canonicalPath === INFRASTRUCTURE_AGENT_DOCTOR_PATH',
);
expect(settingsNavigationModelSource).toContain('INFRASTRUCTURE_AGENT_DOCTOR_PATH,');
expect(settingsNavigationHookSource).toContain('isLegacyAgentDoctorLocation(path, search)');
expect(settingsNavigationHookSource).toContain(
'buildInfrastructureAgentDoctorPath(deriveAgentDoctorScopeFromLocation(path, search))',
);
});
it('keeps Pulse server updates separate from Agent Doctor lifecycle triage', () => {
const updatesNavBlock = settingsNavCatalogSource.match(
/id: 'system-updates',[\s\S]*?id: 'system-recovery',/,
@@ -13,9 +13,13 @@ export type InfrastructureAddStep =
export type InfrastructurePanelStep = 'pick' | InfrastructureAddStep;
const INFRASTRUCTURE_BASE_PATH = '/settings/infrastructure';
// Agent Doctor is a routed page, not a dialog: platform pages deep-link into
// it and its content (per-agent triage cards, update commands) is
// destination-shaped, so it owns a subroute of the infrastructure workspace.
export const INFRASTRUCTURE_AGENT_DOCTOR_PATH = `${INFRASTRUCTURE_BASE_PATH}/agent-doctor`;
export const INFRASTRUCTURE_ADD_QUERY_PARAM = 'add';
// Legacy deep links from before Agent Doctor had its own route remain valid.
export const INFRASTRUCTURE_AGENT_DOCTOR_QUERY_PARAM = 'agentDoctor';
// Legacy deep links from platform update notices and bookmarks remain valid.
export const INFRASTRUCTURE_AGENT_UPDATES_QUERY_PARAM = 'agentUpdates';
export const INFRASTRUCTURE_AGENT_UPDATE_IDS_QUERY_PARAM = 'agents';
@@ -55,14 +59,14 @@ export function buildInfrastructureAgentDoctorPath(
agentIds: readonly (string | null | undefined)[] = [],
): string {
const params = new URLSearchParams();
params.set(INFRASTRUCTURE_AGENT_DOCTOR_QUERY_PARAM, '1');
const normalizedAgentIds = Array.from(
new Set(agentIds.map(normalizeAgentUpdateConnectionID).filter(Boolean) as string[]),
).sort((left, right) => left.localeCompare(right));
for (const agentId of normalizedAgentIds) {
params.append(INFRASTRUCTURE_AGENT_UPDATE_IDS_QUERY_PARAM, agentId);
}
return `${INFRASTRUCTURE_BASE_PATH}?${params.toString()}`;
const query = params.toString();
return query ? `${INFRASTRUCTURE_AGENT_DOCTOR_PATH}?${query}` : INFRASTRUCTURE_AGENT_DOCTOR_PATH;
}
/** @deprecated Use buildInfrastructureAgentDoctorPath. */
@@ -90,7 +94,7 @@ export function deriveAddStepFromLocation(
return deriveAddStepFromSearch(search);
}
export function deriveAgentDoctorFromLocation(pathname: string, search: string): boolean {
export function isLegacyAgentDoctorLocation(pathname: string, search: string): boolean {
if (pathname !== INFRASTRUCTURE_BASE_PATH && pathname !== `${INFRASTRUCTURE_BASE_PATH}/`) {
return false;
}
@@ -102,6 +106,16 @@ export function deriveAgentDoctorFromLocation(pathname: string, search: string):
);
}
export function deriveAgentDoctorFromLocation(pathname: string, search: string): boolean {
if (
pathname === INFRASTRUCTURE_AGENT_DOCTOR_PATH ||
pathname === `${INFRASTRUCTURE_AGENT_DOCTOR_PATH}/`
) {
return true;
}
return isLegacyAgentDoctorLocation(pathname, search);
}
export function deriveAgentDoctorScopeFromLocation(pathname: string, search: string): string[] {
if (!deriveAgentDoctorFromLocation(pathname, search)) {
return [];
@@ -5,6 +5,7 @@ import type { PlatformType } from '@/types/resource';
import {
deriveAddStepFromSearch,
INFRASTRUCTURE_ADD_QUERY_PARAM,
INFRASTRUCTURE_AGENT_DOCTOR_PATH,
} from './infrastructureWorkspaceModel';
import {
AVAILABILITY_ADD_QUERY_PARAM,
@@ -176,7 +177,10 @@ export function isRetiredSettingsCompatibilityPath(path: string): boolean {
normalizedPath.startsWith(`${RETIRED_SETTINGS_INTEGRATIONS_API_PREFIX}/`) ||
normalizedPath === RETIRED_SETTINGS_SYSTEM_PRO_PREFIX ||
normalizedPath.startsWith(`${RETIRED_SETTINGS_SYSTEM_PRO_PREFIX}/`) ||
normalizedPath.startsWith(`${INFRASTRUCTURE_SYSTEMS_PREFIX}/`)
// Infrastructure subpaths are retired v5-era deep links, except Agent
// Doctor, which is a live routed page under the infrastructure workspace.
(normalizedPath.startsWith(`${INFRASTRUCTURE_SYSTEMS_PREFIX}/`) &&
normalizedPath !== INFRASTRUCTURE_AGENT_DOCTOR_PATH)
);
}
@@ -223,7 +227,10 @@ export function deriveTabFromPath(path: string): SettingsTab {
const canonicalPath = resolveCanonicalSettingsPath(path) ?? normalizeSettingsPath(path);
if (canonicalPath === '/settings') return DEFAULT_SETTINGS_TAB;
if (canonicalPath === INFRASTRUCTURE_SYSTEMS_PREFIX) {
if (
canonicalPath === INFRASTRUCTURE_SYSTEMS_PREFIX ||
canonicalPath === INFRASTRUCTURE_AGENT_DOCTOR_PATH
) {
return 'infrastructure-systems';
}
if (canonicalPath.startsWith(MONITORING_AVAILABILITY_PREFIX)) {
@@ -433,6 +440,7 @@ export function settingsTabPath(tab: SettingsTab): string {
const ROUTEABLE_SETTINGS_PATHS = new Set<string>([
settingsTabPath('infrastructure-systems'),
INFRASTRUCTURE_AGENT_DOCTOR_PATH,
settingsTabPath('monitoring-availability'),
settingsTabPath('system-general'),
settingsTabPath('system-network'),
@@ -4,7 +4,13 @@ import {
sessionPresentationPolicyResolved,
} from '@/stores/sessionPresentationPolicy';
import { resolveCanonicalSelfHostedBillingHref } from '@/utils/pricingHandoff';
import { buildInfrastructureWorkspacePath } from './infrastructureWorkspaceModel';
import {
buildInfrastructureAgentDoctorPath,
buildInfrastructureWorkspacePath,
deriveAddStepFromLocation,
deriveAgentDoctorScopeFromLocation,
isLegacyAgentDoctorLocation,
} from './infrastructureWorkspaceModel';
import {
EXTERNAL_AGENT_SETUP_PATH,
SETTINGS_API_ACCESS_PATH,
@@ -117,6 +123,24 @@ export function useSettingsNavigation({ navigate, location }: UseSettingsNavigat
return;
}
// Pre-route Agent Doctor links (?agentDoctor=1 / ?agentUpdates=1 on the
// workspace path) canonicalize onto the routed page, preserving scope.
// An add-flow query wins if both are somehow present, matching the
// workspace's own precedence.
if (
isLegacyAgentDoctorLocation(path, search) &&
deriveAddStepFromLocation(path, search) === null
) {
navigate(
buildInfrastructureAgentDoctorPath(deriveAgentDoctorScopeFromLocation(path, search)),
{
replace: true,
scroll: false,
},
);
return;
}
const canonicalPath = resolveCanonicalSettingsPath(path);
if (canonicalPath && canonicalPath !== path) {
navigate(canonicalPath, {
@@ -142,7 +142,7 @@ import availabilityTargetSlotSource from '@/components/Settings/ConnectionEditor
import trueNASCredentialSlotSource from '@/components/Settings/ConnectionEditor/CredentialSlots/TrueNASCredentialSlot.tsx?raw';
import vmwareCredentialSlotSource from '@/components/Settings/ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx?raw';
import copyCommandBlockSource from '@/components/Settings/CopyCommandBlock.tsx?raw';
import infrastructureAgentUpdatesDialogSource from '@/components/Settings/InfrastructureAgentUpdatesDialog.tsx?raw';
import infrastructureAgentDoctorPageSource from '@/components/Settings/InfrastructureAgentDoctorPage.tsx?raw';
import infrastructureDiscoverySettingsDialogSource from '@/components/Settings/InfrastructureDiscoverySettingsDialog.tsx?raw';
import selfHostedCommercialRecoverySectionSource from '@/components/Settings/SelfHostedCommercialRecoverySection.tsx?raw';
import suggestProfileModalSource from '@/components/Settings/SuggestProfileModal.tsx?raw';
@@ -3124,7 +3124,7 @@ describe('shared primitive guardrails', () => {
'src/components/Settings/CopyCommandBlock.tsx',
'src/components/Settings/DataHandlingPanel.tsx',
'src/components/Settings/GeneralSettingsPanel.tsx',
'src/components/Settings/InfrastructureAgentUpdatesDialog.tsx',
'src/components/Settings/InfrastructureAgentDoctorPage.tsx',
'src/components/Settings/InfrastructureDiscoverySettingsDialog.tsx',
'src/components/Settings/InfrastructureInstallerSection.tsx',
'src/components/Settings/InfrastructureSourceManager.tsx',
@@ -4293,11 +4293,11 @@ describe('shared primitive guardrails', () => {
);
expect(infrastructureInstallerSectionSource).not.toContain('absolute right-2 top-2');
expect(infrastructureInstallerSectionSource).not.toContain('bg-surface-hover p-2');
expect(infrastructureAgentUpdatesDialogSource).toContain('@/components/shared/Button');
expect(infrastructureAgentUpdatesDialogSource).toContain('CommandCopyButton');
expect(infrastructureAgentUpdatesDialogSource).not.toContain('absolute right-2 top-2');
expect(infrastructureAgentUpdatesDialogSource).not.toContain('bg-surface-hover p-2');
expect(infrastructureAgentUpdatesDialogSource).not.toContain(
expect(infrastructureAgentDoctorPageSource).toContain('@/components/shared/Button');
expect(infrastructureAgentDoctorPageSource).toContain('CommandCopyButton');
expect(infrastructureAgentDoctorPageSource).not.toContain('absolute right-2 top-2');
expect(infrastructureAgentDoctorPageSource).not.toContain('bg-surface-hover p-2');
expect(infrastructureAgentDoctorPageSource).not.toContain(
'h-9 w-9 items-center justify-center rounded-md border border-border text-base-content transition-colors hover:bg-surface-hover',
);
expect(infrastructureDiscoverySettingsDialogSource).toContain('@/components/shared/Button');
@@ -350,7 +350,7 @@ describe('DockerPageSurface', () => {
expect(screen.getByRole('link', { name: 'Open agent upgrade commands' })).toHaveAttribute(
'href',
'/settings/infrastructure?agentDoctor=1&agents=agent%3Aagent-docker-old',
'/settings/infrastructure/agent-doctor?agents=agent%3Aagent-docker-old',
);
});
@@ -236,7 +236,7 @@ describe('KubernetesPageSurface contract', () => {
expect(notice).toHaveTextContent('Kubernetes nodes, workloads, services, storage');
expect(screen.getByRole('link', { name: 'Open agent upgrade commands' })).toHaveAttribute(
'href',
'/settings/infrastructure?agentDoctor=1&agents=agent%3Aagent-k8s-node-1',
'/settings/infrastructure/agent-doctor?agents=agent%3Aagent-k8s-node-1',
);
});
@@ -280,7 +280,7 @@ describe('KubernetesPageSurface contract', () => {
expect(notice).toHaveTextContent('Kubernetes nodes, workloads, services, storage');
expect(screen.getByRole('link', { name: 'Open agent upgrade commands' })).toHaveAttribute(
'href',
'/settings/infrastructure?agentDoctor=1&agents=agent%3Aagent-k8s-cluster',
'/settings/infrastructure/agent-doctor?agents=agent%3Aagent-k8s-cluster',
);
});
@@ -159,7 +159,7 @@ describe('ProxmoxPageSurface contract', () => {
);
expect(screen.getByRole('link', { name: 'Open agent upgrade commands' })).toHaveAttribute(
'href',
'/settings/infrastructure?agentDoctor=1&agents=agent%3Aagent-delly',
'/settings/infrastructure/agent-doctor?agents=agent%3Aagent-delly',
);
});
@@ -225,7 +225,7 @@ describe('StandalonePageSurface', () => {
);
expect(screen.getByRole('link', { name: 'Open agent upgrade commands' })).toHaveAttribute(
'href',
'/settings/infrastructure?agentDoctor=1&agents=agent%3Aagent-tower',
'/settings/infrastructure/agent-doctor?agents=agent%3Aagent-tower',
);
});
@@ -158,7 +158,7 @@ describe('TrueNASPageSurface contract', () => {
);
expect(screen.getByRole('link', { name: 'Open agent upgrade commands' })).toHaveAttribute(
'href',
'/settings/infrastructure?agentDoctor=1&agents=agent%3Aagent-truenas-scale',
'/settings/infrastructure/agent-doctor?agents=agent%3Aagent-truenas-scale',
);
});
});
@@ -159,7 +159,7 @@ describe('VmwarePageSurface contract', () => {
expect(notice).toHaveTextContent('latest in-guest telemetry and command support on this VM');
expect(screen.getByRole('link', { name: 'Open agent upgrade commands' })).toHaveAttribute(
'href',
'/settings/infrastructure?agentDoctor=1&agents=agent%3Aagent-app-01',
'/settings/infrastructure/agent-doctor?agents=agent%3Aagent-app-01',
);
});