From c8b1e9d07be6c111d08c6cdbe4a75092a150d857 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 22 Jul 2026 10:26:06 +0100 Subject: [PATCH] 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. --- .../v6/internal/subsystems/agent-lifecycle.md | 2 +- .../subsystems/frontend-primitives.md | 2 +- .../v6/internal/subsystems/registry.json | 4 +- .../scripts/shared-template-registry.json | 2 +- .../Settings/DiagnosticsResultsPanel.tsx | 7 +- .../InfrastructureAgentDoctorPage.tsx | 491 ++++++++++++++++++ .../InfrastructureAgentUpdatesDialog.tsx | 429 --------------- .../Settings/InfrastructureWorkspace.tsx | 371 +++++++------ .../DiagnosticsResultsPanel.test.tsx | 2 +- .../InfrastructureWorkspace.test.tsx | 39 +- .../infrastructureWorkspaceModel.test.ts | 13 +- .../__tests__/settingsArchitecture.test.ts | 14 + .../Settings/infrastructureWorkspaceModel.ts | 22 +- .../Settings/settingsNavigationModel.ts | 12 +- .../Settings/useSettingsNavigation.ts | 26 +- .../SharedPrimitives.guardrails.test.ts | 14 +- .../__tests__/DockerPageSurface.test.tsx | 2 +- .../KubernetesPageSurface.contract.test.tsx | 4 +- .../ProxmoxPageSurface.contract.test.tsx | 2 +- .../__tests__/StandalonePageSurface.test.tsx | 2 +- .../TrueNASPageSurface.contract.test.tsx | 2 +- .../VmwarePageSurface.contract.test.tsx | 2 +- 22 files changed, 801 insertions(+), 663 deletions(-) create mode 100644 frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx delete mode 100644 frontend-modern/src/components/Settings/InfrastructureAgentUpdatesDialog.tsx diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index df597576a..d71c476d9 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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` diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 0bdf15841..8824c095d 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 7fee29909..89e44e629 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -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", diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index f291996ec..f911c599a 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -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" }, diff --git a/frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx b/frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx index 22c451ba1..d76417f48 100644 --- a/frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx +++ b/frontend-modern/src/components/Settings/DiagnosticsResultsPanel.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 =
- + Open Agent Doctor
diff --git a/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx b/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx new file mode 100644 index 000000000..f500041fc --- /dev/null +++ b/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx @@ -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 = ( + 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>( + 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 ( +
+
+ + +

Agent Doctor

+

+ Diagnose fleet connectivity, versions, identity, profiles, and removed-agent state. +

+
+ + +
+
+
Structured diagnostics are temporarily unavailable
+

+ Showing the last known connection-ledger assessment. Profile and removed-agent details + may be incomplete. +

+
+ + + +
+
+ + +
+ Checking agent fleet health… +
+
+ + 0} + fallback={ + +
+ No Pulse Agent connections are currently in scope. +
+
+ } + > +
+ + {(chip) => ( + + {chip.count} + {STATUS_PRESENTATION[chip.status].label} + + )} + +
+ + +
+ 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. +
+
+ + 0 && + operations.requiresToken() && + !operations.commandsUnlocked() + } + > +
+
+

+ Generate update token +

+

+ {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.`} +

+
+
+ 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" + /> + +
+
+
+ + 0 && + !operations.requiresToken() && + !operations.commandsUnlocked() + } + > +
+

+ Tokens are optional on this Pulse instance. Confirm to generate Windows update + commands without embedding a token. +

+ +
+
+ +
+ + + + + Agent + + + System + + + Status + + + Reported + + + Target + + + Last seen + + + + + + {(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 ( + <> + toggleExpanded(target)}> + +
+ + + {target.displayName} + +
+
+ + {target.contextLabel} + + + + {status().label} + + + + {target.currentVersion || '—'} + + + {target.expectedVersion || '—'} + + + {lastSeen() || '—'} + +
+ + + +
+

+ {target.connectionId} + + {' '} + · Updater: {target.updaterLabel} + + + {' '} + · Profile: {target.profileLabel} + + {' '} + ({target.profileVersionLabel}) + + +

+ + 0} + fallback={ +

+ {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.'} +

+ } + > +
    + + {(reason) => ( +
  • +
    + {reason.message} +
    + +
    + {(reason.evidence ?? []).join(' · ')} +
    +
    +
  • + )} +
    +
+
+ + 0}> +
+ + Identity evidence + +
    + {(item) =>
  • {item}
  • }
    +
+
+
+ + + {(repair) => ( +
+
{repair.label}
+
{repair.description}
+ +
+ Required scope: {repair.scope} +
+
+
+ )} +
+ + + + {target.commandBlockedReason} +
+ } + > + + Generate a token to unlock this host-local update command. + + } + > +
+ void copyCommand(command())} + title="Copy host-local agent update command" + label={`Copy update command for ${target.displayName}`} + /> +
+                                      {command()}
+                                    
+
+
+
+ + + + + + ); + }} +
+
+
+
+
+
+ ); +}; diff --git a/frontend-modern/src/components/Settings/InfrastructureAgentUpdatesDialog.tsx b/frontend-modern/src/components/Settings/InfrastructureAgentUpdatesDialog.tsx deleted file mode 100644 index 36b13acab..000000000 --- a/frontend-modern/src/components/Settings/InfrastructureAgentUpdatesDialog.tsx +++ /dev/null @@ -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 = ( - 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 ( - -
-
-
-

Agent Doctor

-

- Diagnose fleet connectivity, versions, identity, profiles, and removed-agent state. -

-
- -
- -
- -
-
-
Structured diagnostics are temporarily unavailable
-

- Showing the last known connection-ledger assessment. Profile and removed-agent - details may be incomplete. -

-
- - - -
-
- - -
- Checking agent fleet health… -
-
- - 0} - fallback={ - -
- No Pulse Agent connections are currently in scope. -
-
- } - > -
- - {(chip) => ( - - {chip.count} - {STATUS_PRESENTATION[chip.status].label} - - )} - -
- - -
- 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. -
-
- - 0 && - operations.requiresToken() && - !operations.commandsUnlocked() - } - > -
-
-

- Generate update token -

-

- {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.`} -

-
-
- 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" - /> - -
-
-
- - 0 && - !operations.requiresToken() && - !operations.commandsUnlocked() - } - > -
-

- Tokens are optional on this Pulse instance. Confirm to generate Windows update - commands without embedding a token. -

- -
-
- -
- - {(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 ( -
-
-
-

- {target.displayName} -

-

- {target.contextLabel} · {target.connectionId} - · Last seen {lastSeen()} -

-
- - {status().label} - -
- - -
- - - - - - -
-
- - - - - - -
- Profile: {target.profileLabel} - - · {target.profileVersionLabel} - -
-
- - 0} - fallback={ -

- {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.'} -

- } - > -
    - - {(reason) => ( -
  • -
    - {reason.message} -
    - -
    - {(reason.evidence ?? []).join(' · ')} -
    -
    -
  • - )} -
    -
-
- - 0}> -
- - Identity evidence - -
    - {(item) =>
  • {item}
  • }
    -
-
-
- - - {(repair) => ( -
-
{repair.label}
-
{repair.description}
- -
- Required scope: {repair.scope} -
-
-
- )} -
- - - - {target.commandBlockedReason} -
- } - > - - Generate a token to unlock this host-local update command. -
- } - > -
- void copyCommand(command())} - title="Copy host-local agent update command" - label={`Copy update command for ${target.displayName}`} - /> -
-                                {command()}
-                              
-
- - - - - ); - }} - -
- - - -
- ); -}; - -const Detail: Component<{ label: string; value: string }> = (props) => ( -
-
{props.label}
-
{props.value}
-
-); diff --git a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx index 12831fd34..b844ad0aa 100644 --- a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx @@ -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 = navigateToWorkspace(Boolean(routeStep())); }; - const closeAgentDoctor = () => { - navigateToWorkspace(Boolean(showAgentDoctor())); - }; - const closeEditFlow = () => { const connection = editingConnection(); if (connection) { @@ -901,198 +897,201 @@ const InfrastructureWorkspaceContent: Component = const isAgentDialog = () => activeAddType() === 'agent' || editingConnection()?.type === 'agent'; return ( -
- 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) - } - /> - - 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} - /> - - - void agentDiagnostics.reload()} - onClose={closeAgentDoctor} /> - + } + > +
+ 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) + } + /> - - -
-
-
-

{addDialogTitle()}

-

{addDialogDescription()}

+ 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} + /> + + + +
+
+
+

{addDialogTitle()}

+

{addDialogDescription()}

+
+
-
+
+
+
+ + + {(connectionAccessor) => { + const connection = connectionAccessor(); + return ( + - - -
- -
- - - openAddFlow(step as ManagedAddTypeStep)} - onDetectApiPlatform={() => openAddFlow('detect')} - /> - - - - openAddFlow('pick')} - onSelectAgentRoute={() => openAddFlow('linux-host')} - onSelectCandidate={openAddFlowFromProbe} - onClose={closeAddFlow} - onSaved={handleAddSaved} - renderCredentialSlot={({ type, candidate, onCancel, onSaved }) => - renderConnectionSlot({ mode: 'add', type, candidate, onCancel, onSaved }) - } - /> - - - - openAddFlow('pick')} - onClose={closeAddFlow} - onSaved={handleAddSaved} - renderCredentialSlot={({ type, candidate, onCancel, onSaved }) => - renderConnectionSlot({ mode: 'add', type, candidate, onCancel, onSaved }) - } - /> - - -
-
- - - - - {(connectionAccessor) => { - const connection = connectionAccessor(); - return ( - -
-
-
-

{editDialogTitle()}

-

{editDialogDescription()}

+
+
+
+

{editDialogTitle()}

+

{editDialogDescription()}

+
+
- -
-
- - - renderConnectionSlot({ mode: 'edit', type, onCancel, onSaved }) - } - /> - 0}> - {renderAttachedAgentAugmentations(attachedAgentConnections())} - -
- } - > - {renderAgentConnectionDetails(connection)} - +
+ + + renderConnectionSlot({ mode: 'edit', type, onCancel, onSaved }) + } + /> + 0}> + {renderAttachedAgentAugmentations(attachedAgentConnections())} + +
+ } + > + {renderAgentConnectionDetails(connection)} + +
-
-
- ); - }} -
- + + ); + }} + + + ); }; diff --git a/frontend-modern/src/components/Settings/__tests__/DiagnosticsResultsPanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/DiagnosticsResultsPanel.test.tsx index a7a7a021e..6d17a3635 100644 --- a/frontend-modern/src/components/Settings/__tests__/DiagnosticsResultsPanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/DiagnosticsResultsPanel.test.tsx @@ -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(); }); diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx index 7cd74c7fd..650960624 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx @@ -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 () => { diff --git a/frontend-modern/src/components/Settings/__tests__/infrastructureWorkspaceModel.test.ts b/frontend-modern/src/components/Settings/__tests__/infrastructureWorkspaceModel.test.ts index 97c766411..39d3f8457 100644 --- a/frontend-modern/src/components/Settings/__tests__/infrastructureWorkspaceModel.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/infrastructureWorkspaceModel.test.ts @@ -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, diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index ff9ab6c13..cf7f943d5 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -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',/, diff --git a/frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts b/frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts index ce08239c4..9fdc10c41 100644 --- a/frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts +++ b/frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts @@ -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 []; diff --git a/frontend-modern/src/components/Settings/settingsNavigationModel.ts b/frontend-modern/src/components/Settings/settingsNavigationModel.ts index b1f3d536c..ae09162cc 100644 --- a/frontend-modern/src/components/Settings/settingsNavigationModel.ts +++ b/frontend-modern/src/components/Settings/settingsNavigationModel.ts @@ -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([ settingsTabPath('infrastructure-systems'), + INFRASTRUCTURE_AGENT_DOCTOR_PATH, settingsTabPath('monitoring-availability'), settingsTabPath('system-general'), settingsTabPath('system-network'), diff --git a/frontend-modern/src/components/Settings/useSettingsNavigation.ts b/frontend-modern/src/components/Settings/useSettingsNavigation.ts index 7b2d28b30..7ac3bb6e3 100644 --- a/frontend-modern/src/components/Settings/useSettingsNavigation.ts +++ b/frontend-modern/src/components/Settings/useSettingsNavigation.ts @@ -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, { diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index ed996c38c..20730a741 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -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'); diff --git a/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx index a4e736339..a18230a30 100644 --- a/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx +++ b/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx @@ -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', ); }); diff --git a/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx b/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx index 65fac99ca..6940c0eaa 100644 --- a/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx +++ b/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx @@ -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', ); }); diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx index fd9566b5f..ff063d674 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx @@ -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', ); }); diff --git a/frontend-modern/src/features/standalone/__tests__/StandalonePageSurface.test.tsx b/frontend-modern/src/features/standalone/__tests__/StandalonePageSurface.test.tsx index 034842163..3d0ecec91 100644 --- a/frontend-modern/src/features/standalone/__tests__/StandalonePageSurface.test.tsx +++ b/frontend-modern/src/features/standalone/__tests__/StandalonePageSurface.test.tsx @@ -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', ); }); diff --git a/frontend-modern/src/features/truenas/__tests__/TrueNASPageSurface.contract.test.tsx b/frontend-modern/src/features/truenas/__tests__/TrueNASPageSurface.contract.test.tsx index 5e5a969cc..d9d88d99c 100644 --- a/frontend-modern/src/features/truenas/__tests__/TrueNASPageSurface.contract.test.tsx +++ b/frontend-modern/src/features/truenas/__tests__/TrueNASPageSurface.contract.test.tsx @@ -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', ); }); }); diff --git a/frontend-modern/src/features/vmware/__tests__/VmwarePageSurface.contract.test.tsx b/frontend-modern/src/features/vmware/__tests__/VmwarePageSurface.contract.test.tsx index 1cf56adf5..847f08cbb 100644 --- a/frontend-modern/src/features/vmware/__tests__/VmwarePageSurface.contract.test.tsx +++ b/frontend-modern/src/features/vmware/__tests__/VmwarePageSurface.contract.test.tsx @@ -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', ); });