mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
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:
@@ -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();
|
||||
});
|
||||
|
||||
+26
-13
@@ -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 () => {
|
||||
|
||||
+10
-3
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+2
-2
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user