mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 18:44:53 +00:00
Split infrastructure operations render owners
This commit is contained in:
parent
0986f46fab
commit
c6f627dc2f
17 changed files with 1999 additions and 1691 deletions
|
|
@ -59,6 +59,11 @@ management, and fleet control surfaces.
|
|||
35. `frontend-modern/src/utils/agentProfilesPresentation.ts`
|
||||
36. `frontend-modern/src/utils/agentInstallCommand.ts`
|
||||
37. `frontend-modern/src/api/nodes.ts`
|
||||
38. `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`
|
||||
39. `frontend-modern/src/components/Settings/InfrastructureInventorySection.tsx`
|
||||
40. `frontend-modern/src/components/Settings/InfrastructureActiveRowDetails.tsx`
|
||||
41. `frontend-modern/src/components/Settings/InfrastructureIgnoredRowDetails.tsx`
|
||||
42. `frontend-modern/src/components/Settings/InfrastructureStopMonitoringDialog.tsx`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
|
|
@ -85,7 +90,7 @@ management, and fleet control surfaces.
|
|||
3. Add or change runtime-side Unified Agent startup, first-report assembly, and enroll/runtime continuity through `internal/hostagent/`.
|
||||
4. Keep legacy Unified Agent compatibility names explicitly secondary when touching shared `internal/api/` runtime helpers: the legacy host-route family and `host-agent:*` scope names may remain as ingress or migration aliases, but they must not retake primary ownership in router state, live runtime scope checks, handler commentary, or operator-facing guidance.
|
||||
5. Add or change installer flags, persisted service arguments, or upgrade-safe re-entry behavior through `scripts/install.sh` and `scripts/install.ps1`.
|
||||
6. Add or change profile management, the extracted agent profiles runtime owner, the pure unified-agent inventory/install model, the direct Proxmox workspace shell, route model, reporting summary owner, local panel-state owner, and section owners, shared frontend install-command assembly, Proxmox setup/install API transport, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`, `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx`, `frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx`, `frontend-modern/src/components/Settings/InfrastructureDirectConnectionsSummaryCard.tsx`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`, `frontend-modern/src/components/Settings/ProxmoxSettingsPanel.tsx`, `frontend-modern/src/components/Settings/ProxmoxConfiguredNodesTable.tsx`, `frontend-modern/src/components/Settings/ProxmoxDirectConnectionsCard.tsx`, `frontend-modern/src/components/Settings/ProxmoxDiscoveryResultsCard.tsx`, `frontend-modern/src/components/Settings/ProxmoxDeleteNodeDialog.tsx`, `frontend-modern/src/components/Settings/ProxmoxNodeModalStack.tsx`, `frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx`, `frontend-modern/src/components/Settings/SettingsSectionNav.tsx`, `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`, `frontend-modern/src/components/Settings/useProxmoxSettingsPanelState.ts`, `frontend-modern/src/components/Settings/NodeModal.tsx`, `frontend-modern/src/components/Settings/nodeModalModel.ts`, `frontend-modern/src/components/Settings/useNodeModalState.ts`, `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`, `frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx`, and `frontend-modern/src/utils/agentInstallCommand.ts`.
|
||||
6. Add or change profile management, the extracted agent profiles runtime owner, the pure unified-agent inventory/install model, the direct Proxmox workspace shell, route model, reporting summary owner, shared install/inventory/dialog section owners, local panel-state owner, shared frontend install-command assembly, Proxmox setup/install API transport, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`, `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`, `frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInventorySection.tsx`, `frontend-modern/src/components/Settings/InfrastructureActiveRowDetails.tsx`, `frontend-modern/src/components/Settings/InfrastructureIgnoredRowDetails.tsx`, `frontend-modern/src/components/Settings/InfrastructureStopMonitoringDialog.tsx`, `frontend-modern/src/components/Settings/InfrastructureDirectConnectionsSummaryCard.tsx`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`, `frontend-modern/src/components/Settings/ProxmoxSettingsPanel.tsx`, `frontend-modern/src/components/Settings/ProxmoxConfiguredNodesTable.tsx`, `frontend-modern/src/components/Settings/ProxmoxDirectConnectionsCard.tsx`, `frontend-modern/src/components/Settings/ProxmoxDiscoveryResultsCard.tsx`, `frontend-modern/src/components/Settings/ProxmoxDeleteNodeDialog.tsx`, `frontend-modern/src/components/Settings/ProxmoxNodeModalStack.tsx`, `frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx`, `frontend-modern/src/components/Settings/SettingsSectionNav.tsx`, `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`, `frontend-modern/src/components/Settings/useProxmoxSettingsPanelState.ts`, `frontend-modern/src/components/Settings/NodeModal.tsx`, `frontend-modern/src/components/Settings/nodeModalModel.ts`, `frontend-modern/src/components/Settings/useNodeModalState.ts`, `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`, `frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx`, and `frontend-modern/src/utils/agentInstallCommand.ts`.
|
||||
|
||||
## Forbidden Paths
|
||||
|
||||
|
|
@ -155,13 +160,17 @@ contracts instead of leaving those guarantees implied by generic API ownership.
|
|||
The direct Proxmox fallback workspace now also lives explicitly inside this
|
||||
lifecycle boundary: `InfrastructureWorkspace.tsx`,
|
||||
`infrastructureWorkspaceModel.ts`,
|
||||
`InfrastructureInstallPanel.tsx`, `InfrastructureReportingPanel.tsx`,
|
||||
`InfrastructureInstallPanel.tsx`, `InfrastructureInstallerSection.tsx`,
|
||||
`InfrastructureReportingPanel.tsx`, `InfrastructureInventorySection.tsx`,
|
||||
`InfrastructureActiveRowDetails.tsx`,
|
||||
`InfrastructureIgnoredRowDetails.tsx`,
|
||||
`InfrastructureStopMonitoringDialog.tsx`,
|
||||
`InfrastructureDirectConnectionsSummaryCard.tsx`,
|
||||
`ProxmoxSettingsPanel.tsx`, `ProxmoxConfiguredNodesTable.tsx`,
|
||||
`ProxmoxDirectConnectionsCard.tsx`, `ProxmoxDiscoveryResultsCard.tsx`,
|
||||
`ProxmoxDeleteNodeDialog.tsx`, `ProxmoxNodeModalStack.tsx`,
|
||||
`ConfiguredNodeTables.tsx`, `SettingsSectionNav.tsx`,
|
||||
`useInfrastructureSettingsState.ts`, and
|
||||
`useInfrastructureOperationsState.ts`, `useInfrastructureSettingsState.ts`, and
|
||||
`useProxmoxSettingsPanelState.ts` own the fallback install/direct/reporting
|
||||
operator flow instead of leaving those panels ungoverned beside the canonical
|
||||
unified-agent install path.
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ Own canonical runtime payload shapes between backend and frontend.
|
|||
and the shared `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` and `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx` cards' infrastructure resource-link default, so the Patrol page, resource drawer, and problem-resource dashboard panels inherit the canonical resource-filter path construction instead of rebuilding infrastructure URLs inline
|
||||
8. Route frontend API-client parsed error propagation, API-error-status fallback handling, allowed-status handling, custom status-specific error handling, command-trigger success envelope handling, shared response parsing pipelines, missing-resource lookup handling, metadata CRUD routing, stream event consumption, response status, collection normalization, scalar payload coercion, and structured error normalization through canonical shared helpers under `frontend-modern/src/api/`
|
||||
9. Add or change API token scope, assignment, and revocation presentation through `frontend-modern/src/components/Settings/APITokenManager.tsx`, `frontend-modern/src/components/Settings/apiTokenManagerModel.ts`, and `frontend-modern/src/components/Settings/useAPITokenManagerState.ts`
|
||||
10. Add or change infrastructure operations token generation, lookup, assignment, the pure unified-agent inventory/install model, and reporting/install presentation through `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, and `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`
|
||||
10. Add or change infrastructure operations token generation, lookup, assignment, the pure unified-agent inventory/install model, the shared infrastructure-operations state provider/context, and reporting/install presentation through `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, and `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`
|
||||
11. Keep `internal/api/session_store.go` on a fail-closed auth-persistence boundary: persisted OIDC refresh tokens may only round-trip through encrypted-at-rest session payloads, and any missing-crypto or invalid-ciphertext path must drop the token instead of preserving plaintext-at-rest session state.
|
||||
12. Keep tenant AI handler wiring on canonical provider ownership: `internal/api/ai_handlers.go` may wire tenant `ReadState` and tenant-scoped unified-resource providers into AI services, but it must not revive tenant snapshot-provider bridges once Patrol can initialize and verify from those canonical providers directly.
|
||||
|
||||
|
|
@ -277,6 +277,11 @@ The same shared-boundary rule now applies to `frontend-modern/src/api/agentProfi
|
|||
`internal/api/config_setup_handlers.go`, and `internal/api/unified_agent.go`:
|
||||
agent install/register/profile control changes must preserve canonical API
|
||||
payload behavior instead of drifting into subsystem-local transport rules.
|
||||
That same shared boundary now assumes `InfrastructureOperationsController.tsx`
|
||||
is only the shell, while `useInfrastructureOperationsState.tsx` owns the API-backed
|
||||
state/provider contract and the extracted installer, inventory, and stop-monitoring
|
||||
section owners stay render-side consumers of that canonical state instead of
|
||||
embedding their own transport calls or ad hoc API parsing.
|
||||
That shared `frontend-modern/src/api/agentProfiles.ts` boundary must also stay
|
||||
under explicit proof routing on both sides instead of remaining a generic
|
||||
frontend-client match on the API-contract side: assignment, delete, unassign,
|
||||
|
|
|
|||
|
|
@ -369,11 +369,16 @@
|
|||
"frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx",
|
||||
"frontend-modern/src/components/Settings/AgentProfilesPanel.tsx",
|
||||
"frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx",
|
||||
"frontend-modern/src/components/Settings/InfrastructureActiveRowDetails.tsx",
|
||||
"frontend-modern/src/components/Settings/InfrastructureDirectConnectionsSummaryCard.tsx",
|
||||
"frontend-modern/src/components/Settings/InfrastructureIgnoredRowDetails.tsx",
|
||||
"frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx",
|
||||
"frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx",
|
||||
"frontend-modern/src/components/Settings/InfrastructureInventorySection.tsx",
|
||||
"frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx",
|
||||
"frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx",
|
||||
"frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx",
|
||||
"frontend-modern/src/components/Settings/InfrastructureStopMonitoringDialog.tsx",
|
||||
"frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx",
|
||||
"frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts",
|
||||
"frontend-modern/src/components/Settings/NodeModal.tsx",
|
||||
|
|
@ -589,8 +594,13 @@
|
|||
"label": "unified agent settings lifecycle proof",
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
"frontend-modern/src/components/Settings/InfrastructureActiveRowDetails.tsx",
|
||||
"frontend-modern/src/components/Settings/InfrastructureIgnoredRowDetails.tsx",
|
||||
"frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx",
|
||||
"frontend-modern/src/components/Settings/InfrastructureInventorySection.tsx",
|
||||
"frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx",
|
||||
"frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx",
|
||||
"frontend-modern/src/components/Settings/InfrastructureStopMonitoringDialog.tsx",
|
||||
"frontend-modern/src/components/Settings/UnifiedAgents.tsx",
|
||||
"frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,374 @@
|
|||
import type { Accessor, Component } from 'solid-js';
|
||||
import { For, Show } from 'solid-js';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import {
|
||||
getAgentCapabilityBadgeClass,
|
||||
getAgentCapabilityLabel,
|
||||
} from '@/utils/agentCapabilityPresentation';
|
||||
import {
|
||||
getUnifiedAgentClipboardCopyErrorMessage,
|
||||
getUnifiedAgentClipboardCopySuccessMessage,
|
||||
} from '@/utils/unifiedAgentInventoryPresentation';
|
||||
import {
|
||||
createSurfaceScopedRow,
|
||||
getRowSurfaceBreakdown,
|
||||
type UnifiedAgentRow,
|
||||
} from './infrastructureOperationsModel';
|
||||
import { useInfrastructureOperationsContext } from './useInfrastructureOperationsState';
|
||||
|
||||
export interface InfrastructureActiveRowDetailsProps {
|
||||
rowAccessor: Accessor<UnifiedAgentRow>;
|
||||
}
|
||||
|
||||
export const InfrastructureActiveRowDetails: Component<InfrastructureActiveRowDetailsProps> = (
|
||||
props,
|
||||
) => {
|
||||
const state = useInfrastructureOperationsContext();
|
||||
const row = () => props.rowAccessor();
|
||||
const isKubernetes = () =>
|
||||
row().capabilities.includes('kubernetes') && !row().capabilities.includes('agent');
|
||||
const resolvedAgentId = () => row().agentId || '';
|
||||
const assignment = () =>
|
||||
resolvedAgentId() ? state.assignmentByAgent().get(resolvedAgentId()) : undefined;
|
||||
const isScopeUpdating = () =>
|
||||
resolvedAgentId() ? Boolean(state.pendingScopeUpdates()[resolvedAgentId()]) : false;
|
||||
const agentName = () => row().displayName || row().hostname || row().name;
|
||||
const surfaces = () => getRowSurfaceBreakdown(row());
|
||||
|
||||
return (
|
||||
<div id={`agent-details-${row().rowKey}`} class="flex h-full flex-col overflow-y-auto">
|
||||
<div class="border-b border-border bg-surface-alt px-4 py-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 space-y-3">
|
||||
<div>
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted">
|
||||
Selected reporting item
|
||||
</div>
|
||||
<div class="mt-2 text-lg font-semibold text-base-content">{row().name}</div>
|
||||
<Show when={row().displayName && row().hostname && row().displayName !== row().hostname}>
|
||||
<div class="mt-1 text-xs text-muted">{row().hostname}</div>
|
||||
</Show>
|
||||
<div class="mt-2 text-sm text-base-content">
|
||||
Use surface controls to stop specific reporting without removing the machine.
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<For each={row().capabilities}>
|
||||
{(cap) => (
|
||||
<span
|
||||
class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${getAgentCapabilityBadgeClass(cap)}`}
|
||||
>
|
||||
{getAgentCapabilityLabel(cap)}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
<Show when={row().isOutdatedBinary}>
|
||||
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-900 dark:text-amber-200">
|
||||
Outdated
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={row().linkedNodeId}>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-300">
|
||||
Linked
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.setExpandedRowKey(null)}
|
||||
class="rounded-md p-1 hover:bg-surface-hover hover:text-base-content"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-4 text-sm text-muted">
|
||||
<div class="rounded-lg border border-border bg-surface-alt px-4 py-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-muted">Machine overview</div>
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-1">
|
||||
<div class="space-y-2 text-xs text-muted">
|
||||
<div>
|
||||
Item ID: <span class="font-mono text-base-content">{row().id}</span>
|
||||
</div>
|
||||
<Show when={row().agentActionId && row().agentActionId !== row().id}>
|
||||
<div>
|
||||
Agent ID: <span class="font-mono text-base-content">{row().agentActionId}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={row().dockerActionId && row().dockerActionId !== row().id}>
|
||||
<div>
|
||||
Container Agent ID:{' '}
|
||||
<span class="font-mono text-base-content">{row().dockerActionId}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={row().kubernetesActionId && row().kubernetesActionId !== row().id}>
|
||||
<div>
|
||||
Cluster ID:{' '}
|
||||
<span class="font-mono text-base-content">{row().kubernetesActionId}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={row().agentId && row().agentId !== row().id}>
|
||||
<div>
|
||||
Reporting agent ID:{' '}
|
||||
<span class="font-mono text-base-content">{row().agentId}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={row().linkedNodeId}>
|
||||
<div>
|
||||
Linked node ID:{' '}
|
||||
<span class="font-mono text-base-content">{row().linkedNodeId}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="space-y-2 text-xs text-muted">
|
||||
<Show when={row().lastSeen}>
|
||||
<div>
|
||||
Last seen {formatRelativeTime(row().lastSeen!)} ({formatAbsoluteTime(row().lastSeen!)})
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={row().scope.category !== 'na'}>
|
||||
<div>
|
||||
<div class="mb-1">Scope profile</div>
|
||||
<Show
|
||||
when={resolvedAgentId()}
|
||||
fallback={
|
||||
<span class="text-base-content" title={row().scope.detail}>
|
||||
{row().scope.label}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={isKubernetes()}
|
||||
fallback={
|
||||
<Show
|
||||
when={state.profiles().length > 0}
|
||||
fallback={
|
||||
<span class="text-base-content" title={row().scope.detail}>
|
||||
{row().scope.label}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
value={assignment()?.profile_id || ''}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.currentTarget.value;
|
||||
const currentValue = assignment()?.profile_id || '';
|
||||
if (nextValue === currentValue) {
|
||||
return;
|
||||
}
|
||||
void state.updateScopeAssignment(
|
||||
resolvedAgentId(),
|
||||
nextValue || null,
|
||||
agentName(),
|
||||
);
|
||||
}}
|
||||
disabled={isScopeUpdating()}
|
||||
class="min-h-10 sm:min-h-9 rounded-md border border-border bg-surface px-2.5 py-1.5 text-sm text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-60 dark:focus:border-blue-400 dark:focus:ring-blue-800"
|
||||
>
|
||||
<option value="">Default (Auto-detect)</option>
|
||||
<Show
|
||||
when={
|
||||
assignment()?.profile_id &&
|
||||
!state.profileById().has(assignment()!.profile_id)
|
||||
}
|
||||
>
|
||||
<option value={assignment()!.profile_id}>
|
||||
{state.getProfileOptionLabel(assignment()!.profile_id)}
|
||||
</option>
|
||||
</Show>
|
||||
<For each={state.profiles()}>
|
||||
{(profile) => (
|
||||
<option value={profile.id}>
|
||||
{state.getProfileOptionLabel(profile.id)}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
<Show when={isScopeUpdating()}>
|
||||
<span class="text-[10px] text-muted">Updating…</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<span class="text-base-content" title={row().scope.detail}>
|
||||
{row().scope.label}
|
||||
</span>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
row().kubernetesInfo &&
|
||||
(row().kubernetesInfo?.server ||
|
||||
row().kubernetesInfo?.context ||
|
||||
row().kubernetesInfo?.tokenName)
|
||||
}
|
||||
>
|
||||
<div class="space-y-1 pt-1">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted">
|
||||
Kubernetes connection
|
||||
</div>
|
||||
<Show when={row().kubernetesInfo?.server}>
|
||||
<div>
|
||||
Server: <span class="text-base-content">{row().kubernetesInfo?.server}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={row().kubernetesInfo?.context}>
|
||||
<div>
|
||||
Context: <span class="text-base-content">{row().kubernetesInfo?.context}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={row().kubernetesInfo?.tokenName}>
|
||||
<div>
|
||||
Token: <span class="text-base-content">{row().kubernetesInfo?.tokenName}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={assignment()}>
|
||||
<div class="mt-3 border-t border-border pt-3">
|
||||
<div class="text-[11px] text-amber-600 dark:text-amber-400">
|
||||
Restart required to apply scope changes.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void state.handleResetScope(resolvedAgentId(), agentName() || resolvedAgentId())}
|
||||
class="mt-2 text-left text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={surfaces().length > 0}>
|
||||
<div class="rounded-lg border border-border bg-surface-alt px-4 py-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-muted">Surface controls</div>
|
||||
<div class="text-xs text-muted">
|
||||
Stop a specific surface. Other surfaces keep reporting.
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 overflow-hidden rounded-md border border-border">
|
||||
<div class="grid grid-cols-[minmax(0,1.1fr)_minmax(0,1.35fr)_minmax(0,0.9fr)_auto] gap-0 border-b border-border bg-surface px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted">
|
||||
<div>Surface</div>
|
||||
<div>What Pulse receives</div>
|
||||
<div>ID</div>
|
||||
<div class="text-right">Control</div>
|
||||
</div>
|
||||
<For each={surfaces()}>
|
||||
{(surface) => (
|
||||
<div class="grid grid-cols-[minmax(0,1.1fr)_minmax(0,1.35fr)_minmax(0,0.9fr)_auto] gap-0 border-b border-border bg-surface-alt px-3 py-2 text-xs last:border-b-0">
|
||||
<div class="pr-3 font-medium text-base-content">{surface.label}</div>
|
||||
<div class="pr-3 text-muted">{surface.detail}</div>
|
||||
<div class="text-muted">
|
||||
<Show
|
||||
when={surface.idLabel && surface.idValue}
|
||||
fallback={<span class="text-muted">Not separately addressed</span>}
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<div class="text-[11px]">{surface.idLabel}</div>
|
||||
<div class="font-mono text-base-content">{surface.idValue}</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="pl-3 text-right">
|
||||
<Show
|
||||
when={
|
||||
surface.key === 'docker' ||
|
||||
surface.key === 'agent' ||
|
||||
surface.key === 'kubernetes'
|
||||
}
|
||||
fallback={<span class="text-[11px] text-muted">Managed with host telemetry</span>}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-row-action
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
state.openStopMonitoringDialog(
|
||||
createSurfaceScopedRow(
|
||||
row(),
|
||||
surface.key as 'agent' | 'docker' | 'kubernetes',
|
||||
),
|
||||
);
|
||||
}}
|
||||
class="inline-flex min-h-9 items-center rounded-md px-2.5 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 hover:text-red-900 dark:text-red-400 dark:hover:bg-red-900 dark:hover:text-red-300"
|
||||
>
|
||||
Stop this surface
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="rounded-lg border border-border bg-surface-alt px-4 py-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-muted">Machine actions</div>
|
||||
<div class="mt-2 text-xs text-muted">Machine-level utilities.</div>
|
||||
<div class="mt-4 flex flex-col gap-2">
|
||||
<Show when={!isKubernetes()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const success = await copyToClipboard(
|
||||
state.getPlatformUninstallCommand(row().upgradePlatform, row()),
|
||||
);
|
||||
if (success) {
|
||||
notificationStore.success(getUnifiedAgentClipboardCopySuccessMessage());
|
||||
} else {
|
||||
notificationStore.error(getUnifiedAgentClipboardCopyErrorMessage());
|
||||
}
|
||||
}}
|
||||
class="rounded-md border border-border px-3 py-2 text-left text-xs text-slate-600 hover:bg-surface hover:text-base-content"
|
||||
>
|
||||
Copy uninstall command
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={row().isOutdatedBinary}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const success = await copyToClipboard(state.getUpgradeCommand(row()));
|
||||
if (success) {
|
||||
notificationStore.success(getUnifiedAgentClipboardCopySuccessMessage());
|
||||
} else {
|
||||
notificationStore.error(getUnifiedAgentClipboardCopyErrorMessage());
|
||||
}
|
||||
}}
|
||||
class="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-left text-xs text-amber-700 hover:bg-amber-100 hover:text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300 dark:hover:bg-amber-900/60 dark:hover:text-amber-200"
|
||||
>
|
||||
Copy upgrade command
|
||||
</button>
|
||||
</Show>
|
||||
<div class="rounded-md border border-blue-200 bg-blue-50 px-3 py-3 text-xs text-blue-900 dark:border-blue-800 dark:bg-blue-950/40 dark:text-blue-100">
|
||||
Use surface controls above to stop reporting without uninstalling.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfrastructureActiveRowDetails;
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import type { Accessor, Component } from 'solid-js';
|
||||
import {
|
||||
getReconnectActionLabel,
|
||||
getCapabilitySurfaceLabel,
|
||||
type UnifiedAgentRow,
|
||||
} from './infrastructureOperationsModel';
|
||||
import { useInfrastructureOperationsContext } from './useInfrastructureOperationsState';
|
||||
|
||||
export interface InfrastructureIgnoredRowDetailsProps {
|
||||
rowAccessor: Accessor<UnifiedAgentRow>;
|
||||
}
|
||||
|
||||
export const InfrastructureIgnoredRowDetails: Component<InfrastructureIgnoredRowDetailsProps> = (
|
||||
props,
|
||||
) => {
|
||||
const state = useInfrastructureOperationsContext();
|
||||
const row = () => props.rowAccessor();
|
||||
const pendingAction = () => state.getPendingInventoryAction(row().rowKey);
|
||||
const isAllowingReconnect = () => pendingAction() === 'allow-reconnect';
|
||||
const reconnectLabel = () => getReconnectActionLabel(row());
|
||||
const blockedId = () =>
|
||||
row().dockerActionId || row().kubernetesActionId || row().agentActionId || row().id;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`ignored-details-${row().rowKey}`}
|
||||
class="flex h-full flex-col overflow-y-auto bg-amber-50/70 dark:bg-amber-950/30"
|
||||
>
|
||||
<div class="border-b border-amber-200 bg-amber-100/80 px-4 py-4 dark:border-amber-800 dark:bg-amber-900/30">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-900 dark:text-amber-100">
|
||||
Selected ignored item
|
||||
</div>
|
||||
<div class="mt-2 text-lg font-semibold text-base-content">{row().name}</div>
|
||||
<div class="mt-2 text-xs font-medium uppercase tracking-wide text-amber-800 dark:text-amber-200">
|
||||
Ignored by Pulse
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-amber-950 dark:text-amber-100">
|
||||
Pulse is blocking reports from this surface.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.setSelectedIgnoredRowKey(null)}
|
||||
class="rounded-md p-1 hover:bg-amber-200/70 hover:text-base-content dark:hover:bg-amber-800/50"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-4 text-sm text-muted">
|
||||
<div class="rounded-lg border border-amber-200/80 bg-white/70 px-4 py-4 dark:border-amber-800/80 dark:bg-amber-950/20">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-muted">Ignored surface</div>
|
||||
<div class="mt-3 overflow-hidden rounded-md border border-amber-200/80 dark:border-amber-800/80">
|
||||
<div class="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1.5fr)_minmax(0,1fr)_auto] gap-0 border-b border-amber-200/80 bg-white/80 px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-amber-900 dark:border-amber-800/80 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
<div>Ignored surface</div>
|
||||
<div>What Pulse is ignoring</div>
|
||||
<div>ID</div>
|
||||
<div class="text-right">Recovery</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1.5fr)_minmax(0,1fr)_auto] gap-0 bg-transparent px-3 py-2 text-xs">
|
||||
<div class="pr-3 font-medium text-base-content">
|
||||
{row().capabilities.map(getCapabilitySurfaceLabel).join(', ')}
|
||||
</div>
|
||||
<div class="pr-3 text-muted">
|
||||
Pulse will ignore new reports for this surface until reconnect is allowed.
|
||||
</div>
|
||||
<div class="pr-3 text-muted">
|
||||
<span class="font-mono text-base-content">{blockedId()}</span>
|
||||
</div>
|
||||
<div class="text-right text-[11px] text-muted">Ready to return</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-amber-200/80 bg-white/70 px-4 py-4 dark:border-amber-800/80 dark:bg-amber-950/20">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-muted">Recovery action</div>
|
||||
<div class="mt-2 text-xs text-muted">Allow this blocked ID to report again.</div>
|
||||
<div class="mt-4 flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() =>
|
||||
row().capabilities.includes('docker')
|
||||
? void state.handleAllowDockerReconnect(row())
|
||||
: row().capabilities.includes('kubernetes')
|
||||
? void state.handleAllowKubernetesReconnect(row())
|
||||
: void state.handleAllowHostReconnect(row())
|
||||
}
|
||||
disabled={Boolean(pendingAction())}
|
||||
class="inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md bg-white px-3 py-2 text-sm font-medium text-blue-600 shadow-sm ring-1 ring-border hover:bg-blue-50 hover:text-blue-900 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-slate-900 dark:text-blue-400 dark:ring-slate-700 dark:hover:bg-blue-900 dark:hover:text-blue-300"
|
||||
>
|
||||
{isAllowingReconnect() ? 'Allowing reconnect…' : reconnectLabel()}
|
||||
</button>
|
||||
<div class="text-xs text-muted">
|
||||
This only changes Pulse. It does not reinstall software.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfrastructureIgnoredRowDetails;
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import { useInfrastructureOperationsState } from './useInfrastructureOperationsState';
|
||||
import { InfrastructureInstallerSection } from './InfrastructureInstallerSection';
|
||||
import { InfrastructureOperationsStateProvider } from './useInfrastructureOperationsState';
|
||||
|
||||
export const InfrastructureInstallPanel: Component = () => {
|
||||
const state = useInfrastructureOperationsState({ embedded: true });
|
||||
|
||||
return state.renderInstallerSection();
|
||||
return (
|
||||
<InfrastructureOperationsStateProvider embedded>
|
||||
<InfrastructureInstallerSection />
|
||||
</InfrastructureOperationsStateProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfrastructureInstallPanel;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,664 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import { For, Show } from 'solid-js';
|
||||
import Server from 'lucide-solid/icons/server';
|
||||
import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon';
|
||||
import SettingsPanel from '@/components/shared/SettingsPanel';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { getUnifiedAgentLookupStatusPresentation } from '@/utils/unifiedAgentStatusPresentation';
|
||||
import {
|
||||
getUnifiedAgentClipboardCopyErrorMessage,
|
||||
getUnifiedAgentClipboardCopySuccessMessage,
|
||||
} from '@/utils/unifiedAgentInventoryPresentation';
|
||||
import { trackAgentInstallCommandCopied } from '@/utils/upgradeMetrics';
|
||||
import {
|
||||
INSTALL_PROFILE_OPTIONS,
|
||||
normalizeTelemetryPart,
|
||||
type InstallProfile,
|
||||
UNIFIED_AGENT_TELEMETRY_SURFACE,
|
||||
} from './infrastructureOperationsModel';
|
||||
import { useInfrastructureOperationsContext } from './useInfrastructureOperationsState';
|
||||
|
||||
export const InfrastructureInstallerSection: Component = () => {
|
||||
const state = useInfrastructureOperationsContext();
|
||||
|
||||
return (
|
||||
<SettingsPanel
|
||||
title={state.isEmbedded() ? 'Install on a host' : 'Infrastructure'}
|
||||
description={
|
||||
state.isEmbedded()
|
||||
? 'Use the unified agent as the default path for hosts, Docker, Kubernetes, and agent-managed Proxmox.'
|
||||
: 'Primary setup hub for unified agents across systems, Docker, Kubernetes, Proxmox, and related infrastructure.'
|
||||
}
|
||||
icon={<Server class="h-5 w-5" strokeWidth={2} />}
|
||||
bodyClass="space-y-5"
|
||||
>
|
||||
<Show when={state.setupHandoff()}>
|
||||
{(handoff) => (
|
||||
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm text-emerald-950 dark:border-emerald-700 dark:bg-emerald-900 dark:text-emerald-50">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="space-y-2">
|
||||
<p class="font-semibold">Security configured. Save these first-run credentials now.</p>
|
||||
<p class="text-xs text-emerald-800 dark:text-emerald-200">
|
||||
This is the canonical handoff from first-run setup into Infrastructure Install.
|
||||
Generate a scoped install token below before copying agent commands.
|
||||
</p>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-md border border-emerald-200 bg-white px-3 py-2 dark:border-emerald-800 dark:bg-emerald-950">
|
||||
<div class="text-[11px] font-medium uppercase tracking-wide text-emerald-700 dark:text-emerald-300">
|
||||
Username
|
||||
</div>
|
||||
<div class="mt-1 font-mono text-sm text-base-content">{handoff().username}</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-emerald-200 bg-white px-3 py-2 dark:border-emerald-800 dark:bg-emerald-950">
|
||||
<div class="text-[11px] font-medium uppercase tracking-wide text-emerald-700 dark:text-emerald-300">
|
||||
Password
|
||||
</div>
|
||||
<div class="mt-1 break-all font-mono text-sm text-base-content">
|
||||
{handoff().password}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-emerald-200 bg-white px-3 py-2 dark:border-emerald-800 dark:bg-emerald-950">
|
||||
<div class="text-[11px] font-medium uppercase tracking-wide text-emerald-700 dark:text-emerald-300">
|
||||
Admin API Token
|
||||
</div>
|
||||
<div class="mt-1 break-all font-mono text-sm text-base-content">
|
||||
{handoff().apiToken}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 lg:w-64 lg:flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void state.copySetupHandoffField(handoff().password, 'Copied first-run password.')
|
||||
}
|
||||
class="inline-flex items-center justify-center rounded-md border border-emerald-300 bg-white px-3 py-2 text-sm font-medium text-emerald-900 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-100 dark:hover:bg-emerald-800"
|
||||
>
|
||||
Copy password
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void state.copySetupHandoffField(
|
||||
handoff().apiToken,
|
||||
'Copied first-run admin API token.',
|
||||
)
|
||||
}
|
||||
class="inline-flex items-center justify-center rounded-md border border-emerald-300 bg-white px-3 py-2 text-sm font-medium text-emerald-900 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-100 dark:hover:bg-emerald-800"
|
||||
>
|
||||
Copy admin token
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={state.downloadSetupHandoff}
|
||||
class="inline-flex items-center justify-center rounded-md border border-emerald-300 bg-white px-3 py-2 text-sm font-medium text-emerald-900 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-100 dark:hover:bg-emerald-800"
|
||||
>
|
||||
Download credentials
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={state.clearSetupHandoff}
|
||||
class="inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-medium text-emerald-900 hover:bg-emerald-100 dark:text-emerald-100 dark:hover:bg-emerald-800"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<div class="rounded-md border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-900 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-100">
|
||||
<p class="font-semibold">Unified Agent is the default monitoring gateway.</p>
|
||||
<p class="mt-1 text-xs text-blue-800 dark:text-blue-200">
|
||||
Install it on each system you want Pulse to monitor. The installer auto-detects
|
||||
available platforms on that machine and enables the right integrations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Show when={!state.isEmbedded()}>
|
||||
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-700 dark:bg-emerald-900 dark:text-emerald-100">
|
||||
<div class="flex items-start gap-3">
|
||||
<ProxmoxIcon class="mt-0.5 h-5 w-5 shrink-0 text-amber-500" />
|
||||
<div class="flex-1">
|
||||
<p class="text-sm">
|
||||
Proxmox nodes can be added here with the unified agent for extra capabilities like
|
||||
temperature monitoring and Pulse Patrol automation (auto-creates the required token
|
||||
and links the node).
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={state.openDirectProxmoxSetup}
|
||||
class="mt-2 inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-2 py-1.5 text-sm font-medium text-emerald-800 underline hover:bg-emerald-100 hover:text-emerald-900 dark:text-emerald-200 dark:hover:bg-emerald-900 dark:hover:text-emerald-100"
|
||||
>
|
||||
Need direct setup instead? Open Proxmox →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-semibold text-base-content">
|
||||
<span class="mr-1.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white">
|
||||
1
|
||||
</span>
|
||||
Generate API token
|
||||
</p>
|
||||
<p class="ml-6 text-sm text-muted">
|
||||
{state.requiresToken()
|
||||
? 'Create a fresh token scoped for Agent, Docker, and Kubernetes monitoring.'
|
||||
: 'Tokens are optional on this Pulse instance. Generate one if you want copied commands to preserve explicit credentialed transport.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={state.tokenName()}
|
||||
onInput={(event) => state.setTokenName(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !state.isGeneratingToken()) {
|
||||
void state.handleGenerateToken();
|
||||
}
|
||||
}}
|
||||
placeholder="Token name (optional)"
|
||||
class="flex-1 rounded-md border border-border bg-surface px-3 py-2 text-sm text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:focus:border-blue-400 dark:focus:ring-blue-900"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void state.handleGenerateToken()}
|
||||
disabled={state.isGeneratingToken()}
|
||||
class="inline-flex items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{state.isGeneratingToken()
|
||||
? 'Generating…'
|
||||
: state.hasToken()
|
||||
? 'Generate another'
|
||||
: 'Generate token'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={state.latestRecord()}>
|
||||
<div class="flex items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-4 py-2 text-xs text-blue-800 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span>
|
||||
Token <strong>{state.latestRecord()?.name}</strong> created. Commands below now
|
||||
include this credential.
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={!state.requiresToken()}>
|
||||
<div class="space-y-3">
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-900 dark:text-amber-200">
|
||||
Tokens are optional on this Pulse instance. Confirm to generate commands without
|
||||
embedding a token.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={state.acknowledgeNoToken}
|
||||
disabled={state.confirmedNoToken()}
|
||||
class={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors ${
|
||||
state.confirmedNoToken()
|
||||
? 'cursor-default bg-green-600 text-white'
|
||||
: 'border border-border bg-surface text-base-content hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{state.confirmedNoToken() ? 'No token confirmed' : 'Confirm without token'}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={state.requiresToken() && !state.commandsUnlocked()}>
|
||||
<div class="pointer-events-none select-none space-y-3 opacity-60">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-base-content">
|
||||
<span class="mr-1.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-slate-400 text-xs font-bold text-white">
|
||||
2
|
||||
</span>
|
||||
Installation commands
|
||||
</h4>
|
||||
<p class="ml-6 mt-0.5 text-xs text-muted">
|
||||
Generate a token above to unlock installation commands.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-border bg-surface-hover px-4 py-6 text-center">
|
||||
<svg
|
||||
class="mx-auto mb-2 h-8 w-8 text-muted"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-sm text-muted">Click "Generate token" above to see installation commands</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={state.commandsUnlocked()}>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-base-content">
|
||||
<Show when={state.requiresToken()}>
|
||||
<span class="mr-1.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-green-600 text-xs font-bold text-white">
|
||||
2
|
||||
</span>
|
||||
</Show>
|
||||
Installation commands
|
||||
</h4>
|
||||
<p class={`mt-0.5 text-xs text-muted ${state.requiresToken() ? 'ml-6' : ''}`}>
|
||||
The installer auto-detects Docker, Kubernetes, and Proxmox on the target
|
||||
machine.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-border bg-surface-hover px-4 py-3">
|
||||
<label class="mb-1.5 block text-xs font-medium text-base-content">
|
||||
Connection URL (Agent → Pulse)
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={state.customAgentUrl()}
|
||||
onInput={(event) => state.setCustomAgentUrl(event.currentTarget.value)}
|
||||
placeholder={state.agentUrl()}
|
||||
class="flex-1 rounded-md border bg-surface px-3 py-1.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:border-blue-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-muted">
|
||||
Override the address agents use to connect to this server (e.g., use IP address{' '}
|
||||
<code>http://192.0.2.50:7655</code> if DNS fails).
|
||||
<Show when={!state.customAgentUrl()}>
|
||||
<span class="ml-1 opacity-75">Currently using auto-detected: {state.agentUrl()}</span>
|
||||
</Show>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-border bg-surface-hover px-4 py-3">
|
||||
<label
|
||||
for="custom-ca-certificate-path"
|
||||
class="mb-1.5 block text-xs font-medium text-base-content"
|
||||
>
|
||||
Custom CA certificate path (optional)
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="custom-ca-certificate-path"
|
||||
type="text"
|
||||
value={state.customCaPath()}
|
||||
onInput={(event) => state.setCustomCaPath(event.currentTarget.value)}
|
||||
placeholder={
|
||||
state.selectedAgentUrl().startsWith('http://')
|
||||
? 'Not needed for plain HTTP'
|
||||
: 'Examples: /etc/pulse/ca.pem or C:\\Pulse\\ca.cer'
|
||||
}
|
||||
class="flex-1 rounded-md border bg-surface px-3 py-1.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:border-blue-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-muted">
|
||||
Preserves custom trust for copied install, upgrade, and uninstall commands.
|
||||
Shell commands pass <code>--cacert</code> to both the download and the installer.
|
||||
Windows commands set <code>PULSE_CACERT</code> and use a transport-aware
|
||||
PowerShell bootstrap for the initial script fetch.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Show when={state.insecureMode()}>
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-900 dark:text-amber-200">
|
||||
<span class="font-medium">TLS verification disabled</span> — skip cert checks for
|
||||
self-signed setups. Not recommended for production.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<label
|
||||
class="inline-flex cursor-pointer items-center gap-2 text-sm text-base-content"
|
||||
title="Skip TLS certificate verification (for self-signed certificates)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.insecureMode()}
|
||||
onChange={(event) => state.setInsecureMode(event.currentTarget.checked)}
|
||||
class="rounded text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
Skip TLS certificate verification (self-signed certs; not recommended)
|
||||
</label>
|
||||
|
||||
<label
|
||||
class="inline-flex cursor-pointer items-center gap-2 text-sm text-base-content"
|
||||
title="Allow Pulse Patrol to execute diagnostic and fix commands on this agent (auto-fix requires Pulse Pro)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.enableCommands()}
|
||||
onChange={(event) => state.setEnableCommands(event.currentTarget.checked)}
|
||||
class="rounded text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
Enable Pulse command execution (for Patrol auto-fix)
|
||||
</label>
|
||||
|
||||
<Show when={state.enableCommands()}>
|
||||
<div class="rounded-md border border-blue-200 bg-blue-50 px-4 py-2 text-sm text-blue-800 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-200">
|
||||
<span class="font-medium">Pulse commands enabled</span> — The agent will accept
|
||||
diagnostic and fix commands from Pulse Patrol features.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm text-emerald-900 dark:border-emerald-700 dark:bg-emerald-900 dark:text-emerald-100">
|
||||
<span class="font-medium">Config signing (optional)</span> — Require signed remote
|
||||
config payloads with <code>PULSE_AGENT_CONFIG_SIGNATURE_REQUIRED=true</code>.
|
||||
Provide keys via <code>PULSE_AGENT_CONFIG_SIGNING_KEY</code> (Pulse) and{' '}
|
||||
<code>PULSE_AGENT_CONFIG_PUBLIC_KEYS</code> (agents).
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-border bg-surface-hover px-4 py-3">
|
||||
<label for="install-profile-select" class="mb-1.5 block text-xs font-medium text-base-content">
|
||||
Target profile (optional)
|
||||
</label>
|
||||
<select
|
||||
id="install-profile-select"
|
||||
value={state.installProfile()}
|
||||
onChange={(event) =>
|
||||
state.handleInstallProfileChange(event.currentTarget.value as InstallProfile)
|
||||
}
|
||||
class="w-full rounded-md border bg-surface px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:border-blue-400 dark:focus:ring-blue-800"
|
||||
>
|
||||
<For each={INSTALL_PROFILE_OPTIONS}>
|
||||
{(option) => <option value={option.value}>{option.label}</option>}
|
||||
</For>
|
||||
</select>
|
||||
<p class="mt-1.5 text-xs text-muted">{state.getSelectedInstallProfile().description}</p>
|
||||
<Show when={state.getInstallProfileFlags().length > 0}>
|
||||
<p class="mt-1.5 text-xs text-muted">
|
||||
Adds flags to shell-based install commands:{' '}
|
||||
<code>{state.getInstallProfileFlags().join(' ')}</code>
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<For each={state.commandSections()}>
|
||||
{(section) => (
|
||||
<div class="space-y-3 rounded-md border border-border p-4">
|
||||
<div class="space-y-1">
|
||||
<h5 class="text-sm font-semibold text-base-content">{section.title}</h5>
|
||||
<p class="text-xs text-muted">{section.description}</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<For each={section.snippets}>
|
||||
{(snippet) => {
|
||||
const copyCommand = () => snippet.command;
|
||||
const commandTelemetryCapability = () => {
|
||||
const label = normalizeTelemetryPart(snippet.label) || 'install';
|
||||
return `${section.platform}:${state.installProfile()}:${label}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-2">
|
||||
<h6 class="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
{snippet.label}
|
||||
</h6>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const success = await copyToClipboard(copyCommand());
|
||||
if (success) {
|
||||
trackAgentInstallCommandCopied(
|
||||
UNIFIED_AGENT_TELEMETRY_SURFACE,
|
||||
commandTelemetryCapability(),
|
||||
);
|
||||
notificationStore.success(
|
||||
getUnifiedAgentClipboardCopySuccessMessage(),
|
||||
);
|
||||
} else {
|
||||
notificationStore.error(
|
||||
getUnifiedAgentClipboardCopyErrorMessage(),
|
||||
);
|
||||
}
|
||||
}}
|
||||
class="absolute right-2 top-2 inline-flex min-h-10 min-w-10 items-center justify-center rounded-md bg-surface-hover p-2 transition-colors hover:text-slate-200 sm:min-h-9 sm:min-w-9"
|
||||
title="Copy command"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
|
||||
<code>{copyCommand()}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<Show when={snippet.note}>
|
||||
<p class="text-xs text-muted">{snippet.note}</p>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 rounded-md border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-900 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-100">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h5 class="text-sm font-semibold">Check installation status</h5>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void state.handleLookup()}
|
||||
disabled={state.lookupLoading()}
|
||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{state.lookupLoading() ? 'Checking…' : 'Check status'}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-blue-800 dark:text-blue-200">
|
||||
Enter the hostname (or agent ID) from the machine you just installed. Pulse returns
|
||||
the latest status instantly.
|
||||
</p>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={state.lookupValue()}
|
||||
onInput={(event) => {
|
||||
state.setLookupValue(event.currentTarget.value);
|
||||
state.clearLookupState();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void state.handleLookup();
|
||||
}
|
||||
}}
|
||||
placeholder="Hostname or agent ID"
|
||||
class="flex-1 rounded-md border border-blue-200 bg-surface px-3 py-2 text-sm text-blue-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-100 dark:focus:border-blue-300 dark:focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
<Show when={state.lookupError()}>
|
||||
<p class="text-xs font-medium text-red-600 dark:text-red-300">{state.lookupError()}</p>
|
||||
</Show>
|
||||
<Show when={state.lookupResult()}>
|
||||
{(result) => {
|
||||
const agent = () => result().agent!;
|
||||
const lookupStatusPresentation = () =>
|
||||
getUnifiedAgentLookupStatusPresentation(agent().connected);
|
||||
|
||||
return (
|
||||
<div class="space-y-1 rounded-md border border-blue-200 bg-surface px-3 py-2 text-xs text-blue-900 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-100">
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="text-sm font-semibold">{agent().displayName || agent().hostname}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold ${lookupStatusPresentation().badgeClass}`}
|
||||
>
|
||||
{lookupStatusPresentation().label}
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium text-blue-700 dark:bg-blue-900 dark:text-blue-200">
|
||||
{agent().status || 'unknown'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
Last seen {formatRelativeTime(agent().lastSeen)} (
|
||||
{formatAbsoluteTime(agent().lastSeen)})
|
||||
</div>
|
||||
<Show when={agent().agentVersion}>
|
||||
<div class="text-xs text-blue-700 dark:text-blue-200">
|
||||
Agent version {agent().agentVersion}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<details class="rounded-md border border-border bg-surface-hover px-4 py-3 text-sm">
|
||||
<summary class="cursor-pointer text-sm font-medium text-base-content">
|
||||
Troubleshooting
|
||||
</summary>
|
||||
<div class="mt-3 space-y-4">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide text-muted">Auto-detection not working?</p>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
If Docker, Kubernetes, or Proxmox isn't detected automatically, add these flags
|
||||
to the install command:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-xs text-muted">
|
||||
<li>
|
||||
<code class="rounded bg-surface-hover px-1">--enable-docker</code> — Force
|
||||
enable Docker/Podman monitoring
|
||||
</li>
|
||||
<li>
|
||||
<code class="rounded bg-surface-hover px-1">--enable-kubernetes</code> — Force
|
||||
enable Kubernetes monitoring
|
||||
</li>
|
||||
<li>
|
||||
<code class="rounded bg-surface-hover px-1">--enable-proxmox</code> — Force
|
||||
enable Proxmox integration (creates API token)
|
||||
</li>
|
||||
<li>
|
||||
<code class="rounded bg-surface-hover px-1">--proxmox-type pve|pbs</code> —
|
||||
Set Proxmox node mode explicitly
|
||||
</li>
|
||||
<li>
|
||||
<code class="rounded bg-surface-hover px-1">--disable-docker</code> — Skip
|
||||
Docker even if detected
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="mt-4 border-t border-border pt-4">
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-semibold text-base-content">Uninstall agent</h4>
|
||||
<p class="text-xs text-muted">
|
||||
Run the appropriate command on your machine to remove the Pulse agent:
|
||||
</p>
|
||||
<div class="space-y-1">
|
||||
<span class="text-xs font-medium text-muted">Linux / macOS / FreeBSD</span>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const success = await copyToClipboard(state.getUninstallCommand());
|
||||
if (success) {
|
||||
notificationStore.success(getUnifiedAgentClipboardCopySuccessMessage());
|
||||
} else {
|
||||
notificationStore.error(getUnifiedAgentClipboardCopyErrorMessage());
|
||||
}
|
||||
}}
|
||||
class="absolute right-2 top-2 inline-flex min-h-10 min-w-10 items-center justify-center rounded-md bg-surface-hover p-2 text-slate-400 transition-colors hover:bg-slate-700 hover:text-slate-200 sm:min-h-9 sm:min-w-9"
|
||||
title="Copy command"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<pre class="overflow-x-auto rounded-md bg-slate-950 p-3 pr-12 font-mono text-xs text-red-400">
|
||||
<code>{state.getUninstallCommand()}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs italic text-muted">
|
||||
If the agent can't reach this server, run directly on the machine:{' '}
|
||||
<code class="rounded bg-surface-hover px-1 not-italic">
|
||||
sudo bash /var/lib/pulse-agent/install.sh --uninstall
|
||||
</code>{' '}
|
||||
(TrueNAS:{' '}
|
||||
<code class="rounded bg-surface-hover px-1 not-italic">
|
||||
/data/pulse-agent/install.sh
|
||||
</code>
|
||||
, Unraid:{' '}
|
||||
<code class="rounded bg-surface-hover px-1 not-italic">
|
||||
/boot/config/plugins/pulse-agent/install.sh
|
||||
</code>
|
||||
)
|
||||
</p>
|
||||
<div class="space-y-1">
|
||||
<span class="text-xs font-medium text-muted">Windows (PowerShell as Administrator)</span>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const success = await copyToClipboard(state.getWindowsUninstallCommand());
|
||||
if (success) {
|
||||
notificationStore.success(getUnifiedAgentClipboardCopySuccessMessage());
|
||||
} else {
|
||||
notificationStore.error(getUnifiedAgentClipboardCopyErrorMessage());
|
||||
}
|
||||
}}
|
||||
class="absolute right-2 top-2 inline-flex min-h-10 min-w-10 items-center justify-center rounded-md bg-surface-hover p-2 text-slate-400 transition-colors hover:bg-slate-700 hover:text-slate-200 sm:min-h-9 sm:min-w-9"
|
||||
title="Copy command"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<pre class="overflow-x-auto rounded-md bg-slate-950 p-3 pr-12 font-mono text-xs text-red-400">
|
||||
<code>{state.getWindowsUninstallCommand()}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsPanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfrastructureInstallerSection;
|
||||
|
|
@ -0,0 +1,374 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import { For, Show } from 'solid-js';
|
||||
import Users from 'lucide-solid/icons/users';
|
||||
import { Dialog } from '@/components/shared/Dialog';
|
||||
import { PulseDataGrid } from '@/components/shared/PulseDataGrid';
|
||||
import { SearchField } from '@/components/shared/SearchField';
|
||||
import SettingsPanel from '@/components/shared/SettingsPanel';
|
||||
import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format';
|
||||
import { getMonitoringStoppedEmptyState, getRemovedUnifiedAgentItemLabel } from '@/utils/unifiedAgentInventoryPresentation';
|
||||
import type { AgentCapability } from '@/utils/agentCapabilityPresentation';
|
||||
import type { ScopeCategory } from './infrastructureOperationsModel';
|
||||
import { getCapabilitySurfaceLabel } from './infrastructureOperationsModel';
|
||||
import { InfrastructureActiveRowDetails } from './InfrastructureActiveRowDetails';
|
||||
import { InfrastructureIgnoredRowDetails } from './InfrastructureIgnoredRowDetails';
|
||||
import { useInfrastructureOperationsContext } from './useInfrastructureOperationsState';
|
||||
|
||||
export const InfrastructureInventorySection: Component = () => {
|
||||
const state = useInfrastructureOperationsContext();
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-md border border-border bg-surface-alt px-4 py-3 text-sm">
|
||||
<p class="text-base-content">{state.inventoryStatusSummaryText()}</p>
|
||||
</div>
|
||||
|
||||
<SettingsPanel
|
||||
title="Reporting now"
|
||||
description="Hosts and runtimes currently checking in to Pulse."
|
||||
icon={<Users class="h-5 w-5" strokeWidth={2} />}
|
||||
bodyClass="space-y-4"
|
||||
>
|
||||
<div class="rounded-md border border-border bg-surface-alt px-4 py-3">
|
||||
<p class="text-sm font-medium text-base-content">{state.reportingCoverageSummaryText()}</p>
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
This workspace does not list every asset Pulse has discovered. It focuses on systems
|
||||
and runtimes that are actively checking in right now.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-4 dark:border-emerald-800 dark:bg-emerald-950/40">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-300">
|
||||
Active reporting
|
||||
</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-emerald-900 dark:text-emerald-100">
|
||||
{state.filteredActiveRows().length}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-emerald-900 dark:text-emerald-200">
|
||||
Item{state.filteredActiveRows().length === 1 ? '' : 's'} actively checking in to Pulse.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Show when={state.hasLinkedAgents()}>
|
||||
<div class="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 dark:border-blue-800 dark:bg-blue-900">
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 shrink-0 text-blue-500 dark:text-blue-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-xs text-blue-700 dark:text-blue-300">
|
||||
<span class="font-medium">{state.linkedAgents().length}</span> agent
|
||||
{state.linkedAgents().length > 1 ? 's are' : ' is'} linked to Proxmox node
|
||||
{state.linkedAgents().length > 1 ? 's' : ''} and flagged with a{' '}
|
||||
<span class="font-medium text-blue-700 dark:text-blue-300">Linked</span> badge.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={state.hasOutdatedAgents()}>
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-700 dark:bg-amber-900">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 shrink-0 text-amber-500 dark:text-amber-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div class="flex-1 space-y-1">
|
||||
<p class="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
{state.outdatedAgents().length} outdated agent
|
||||
{state.outdatedAgents().length > 1 ? ' binaries' : ' binary'} detected
|
||||
</p>
|
||||
<p class="text-sm text-amber-700 dark:text-amber-300">
|
||||
Older standalone agent binaries are deprecated. Expand a row to copy the upgrade
|
||||
command.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="min-w-[220px] space-y-1">
|
||||
<label for="agent-filter-search" class="text-xs font-medium text-muted">
|
||||
Search reporting items
|
||||
</label>
|
||||
<SearchField
|
||||
placeholder="Search name, hostname, or ID"
|
||||
value={state.filterSearch()}
|
||||
onChange={state.setFilterSearch}
|
||||
class="w-full"
|
||||
inputClass="min-h-10 sm:min-h-9 px-3 py-2 sm:py-1.5 shadow-sm focus:ring-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="pb-2 text-xs font-medium uppercase tracking-wide text-muted">
|
||||
Refine results
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label for="agent-filter-capability" class="text-xs font-medium text-muted">
|
||||
Capability
|
||||
</label>
|
||||
<select
|
||||
id="agent-filter-capability"
|
||||
value={state.filterCapability()}
|
||||
onChange={(event) =>
|
||||
state.setFilterCapability(event.currentTarget.value as 'all' | AgentCapability)
|
||||
}
|
||||
class="min-h-10 sm:min-h-9 rounded-md border border-border bg-surface px-2.5 py-2 text-sm text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:border-blue-400 dark:focus:ring-blue-800 sm:py-1.5"
|
||||
>
|
||||
<option value="all">All capabilities</option>
|
||||
<option value="agent">Agent</option>
|
||||
<option value="docker">Docker</option>
|
||||
<option value="kubernetes">Kubernetes</option>
|
||||
<option value="proxmox">Proxmox</option>
|
||||
<option value="pbs">PBS</option>
|
||||
<option value="pmg">PMG</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label for="agent-filter-scope" class="text-xs font-medium text-muted">
|
||||
Scope
|
||||
</label>
|
||||
<select
|
||||
id="agent-filter-scope"
|
||||
value={state.filterScope()}
|
||||
onChange={(event) =>
|
||||
state.setFilterScope(
|
||||
event.currentTarget.value as 'all' | Exclude<ScopeCategory, 'na'>,
|
||||
)
|
||||
}
|
||||
class="min-h-10 sm:min-h-9 rounded-md border border-border bg-surface px-2.5 py-2 text-sm text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:border-blue-400 dark:focus:ring-blue-800 sm:py-1.5"
|
||||
>
|
||||
<option value="all">All scopes</option>
|
||||
<option value="default">Default</option>
|
||||
<option value="profile">Profile assigned</option>
|
||||
<option value="ai-managed">Patrol-managed</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={state.resetFilters}
|
||||
disabled={!state.hasFilters()}
|
||||
class={`min-h-10 sm:min-h-9 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||
state.hasFilters()
|
||||
? 'text-base-content hover:bg-surface-alt'
|
||||
: 'cursor-not-allowed text-slate-400'
|
||||
}`}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 text-xs text-muted">
|
||||
<span>
|
||||
Showing {state.filteredActiveRows().length} of {state.activeRows().length} active
|
||||
records.
|
||||
</span>
|
||||
<Show when={state.filteredMonitoringStoppedRows().length > 0}>
|
||||
<span>{state.filteredMonitoringStoppedRows().length} item(s) are currently ignored by Pulse.</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-border bg-surface-hover px-4 py-3 text-sm text-muted">
|
||||
Stop monitoring removes an item from active reporting and moves it into the Ignored by
|
||||
Pulse list. The remote system keeps running; Pulse simply ignores new reports until you
|
||||
allow reconnect.
|
||||
</div>
|
||||
|
||||
<Show when={state.inventoryActionNotice()}>
|
||||
{(notice) => (
|
||||
<div
|
||||
class={`rounded-md border px-4 py-3 text-sm ${
|
||||
notice().tone === 'success'
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-700 dark:bg-emerald-900 dark:text-emerald-100'
|
||||
: 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-100'
|
||||
}`}
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="font-semibold">{notice().title}</p>
|
||||
<p class="text-xs opacity-90">{notice().detail}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={notice().showRecoveryQueueLink}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={state.scrollToRecoveryQueue}
|
||||
class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-2.5 py-1.5 text-xs font-medium underline"
|
||||
>
|
||||
View ignored items
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.setInventoryActionNotice(null)}
|
||||
class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-2.5 py-1.5 text-xs font-medium underline"
|
||||
aria-label="Dismiss inventory action message"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="overflow-hidden rounded-xl border border-border bg-surface shadow-sm">
|
||||
<div class="border-b border-border bg-surface-alt px-4 py-3">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted">
|
||||
Browse reporting items
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-base-content">
|
||||
Select a reporting item to open its details drawer.
|
||||
</div>
|
||||
</div>
|
||||
<PulseDataGrid
|
||||
data={state.filteredActiveRows()}
|
||||
emptyState={
|
||||
state.hasFilters()
|
||||
? 'No reporting items match the current filters.'
|
||||
: 'Nothing is actively reporting to Pulse yet.'
|
||||
}
|
||||
desktopMinWidth="960px"
|
||||
columns={state.reportingColumns}
|
||||
keyExtractor={(row) => row.rowKey}
|
||||
onRowClick={(row) => state.toggleAgentDetails(row.rowKey)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
isOpen={Boolean(state.selectedActiveRow())}
|
||||
onClose={() => state.setExpandedRowKey(null)}
|
||||
layout="drawer-right"
|
||||
panelClass="max-w-[760px]"
|
||||
ariaLabel="Reporting item details"
|
||||
>
|
||||
<Show when={state.selectedActiveRow()}>
|
||||
{(rowAccessor) => <InfrastructureActiveRowDetails rowAccessor={rowAccessor} />}
|
||||
</Show>
|
||||
</Dialog>
|
||||
</div>
|
||||
</SettingsPanel>
|
||||
|
||||
<Show when={state.showMonitoringStoppedSection()}>
|
||||
<div ref={state.setRecoveryQueueSectionRef}>
|
||||
<SettingsPanel
|
||||
title="Ignored by Pulse"
|
||||
description="Items you explicitly told Pulse to ignore stay out of live reporting until reconnect is allowed."
|
||||
icon={<Users class="h-5 w-5" strokeWidth={2} />}
|
||||
bodyClass="space-y-4"
|
||||
>
|
||||
<Show
|
||||
when={state.filteredMonitoringStoppedRows().length > 0}
|
||||
fallback={
|
||||
<div class="rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted">
|
||||
{getMonitoringStoppedEmptyState(state.hasFilters())}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="grid gap-4">
|
||||
<div class="overflow-hidden rounded-lg border border-amber-200 bg-amber-50/70 dark:border-amber-800 dark:bg-amber-950/30">
|
||||
<div class="border-b border-amber-200 px-4 py-3 dark:border-amber-800">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-900 dark:text-amber-100">
|
||||
Browse ignored items
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-amber-950 dark:text-amber-100">
|
||||
Select an ignored item to open its recovery drawer.
|
||||
</div>
|
||||
</div>
|
||||
<div class="divide-y divide-amber-200/80 dark:divide-amber-800/80">
|
||||
<For each={state.filteredMonitoringStoppedRows()}>
|
||||
{(row) => {
|
||||
const pendingAction = () => state.getPendingInventoryAction(row.rowKey);
|
||||
const isSelected = () => state.selectedIgnoredRowKey() === row.rowKey;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.setSelectedIgnoredRowKey(row.rowKey)}
|
||||
class={`flex w-full flex-col gap-2 px-4 py-3 text-left transition-colors ${
|
||||
isSelected()
|
||||
? 'bg-amber-100/80 ring-1 ring-inset ring-amber-300 dark:bg-amber-900/40 dark:ring-amber-700'
|
||||
: 'hover:bg-amber-100/50 dark:hover:bg-amber-900/20'
|
||||
}`}
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h4 class="truncate text-sm font-semibold text-base-content">
|
||||
{row.name}
|
||||
</h4>
|
||||
<span class="inline-flex items-center rounded-full bg-white/80 px-2 py-0.5 text-[11px] font-medium uppercase tracking-wide text-amber-800 dark:bg-amber-900/60 dark:text-amber-200">
|
||||
{getRemovedUnifiedAgentItemLabel(row)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-muted">
|
||||
{row.capabilities.map(getCapabilitySurfaceLabel).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[11px] text-muted">
|
||||
{pendingAction() === 'allow-reconnect'
|
||||
? 'Reconnect in progress'
|
||||
: 'Select to review'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted">
|
||||
<Show
|
||||
when={
|
||||
row.displayName && row.hostname && row.displayName !== row.hostname
|
||||
}
|
||||
>
|
||||
<span>Hostname: {row.hostname}</span>
|
||||
</Show>
|
||||
<span>
|
||||
Stopped{' '}
|
||||
{row.removedAt
|
||||
? `${formatRelativeTime(row.removedAt)} (${formatAbsoluteTime(row.removedAt)})`
|
||||
: 'at an unknown time'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
isOpen={Boolean(state.selectedIgnoredRow())}
|
||||
onClose={() => state.setSelectedIgnoredRowKey(null)}
|
||||
layout="drawer-right"
|
||||
panelClass="max-w-[720px]"
|
||||
ariaLabel="Ignored item details"
|
||||
>
|
||||
<Show when={state.selectedIgnoredRow()}>
|
||||
{(rowAccessor) => <InfrastructureIgnoredRowDetails rowAccessor={rowAccessor} />}
|
||||
</Show>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Show>
|
||||
</SettingsPanel>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfrastructureInventorySection;
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import { Show } from 'solid-js';
|
||||
import { InfrastructureInstallerSection } from './InfrastructureInstallerSection';
|
||||
import { InfrastructureInventorySection } from './InfrastructureInventorySection';
|
||||
import { InfrastructureStopMonitoringDialog } from './InfrastructureStopMonitoringDialog';
|
||||
import {
|
||||
InfrastructureOperationsStateProvider,
|
||||
type InfrastructureOperationsStateOptions,
|
||||
useInfrastructureOperationsState,
|
||||
} from './useInfrastructureOperationsState';
|
||||
|
||||
export interface InfrastructureOperationsControllerProps extends InfrastructureOperationsStateOptions {
|
||||
|
|
@ -13,14 +16,20 @@ export interface InfrastructureOperationsControllerProps extends InfrastructureO
|
|||
export const InfrastructureOperationsController: Component<
|
||||
InfrastructureOperationsControllerProps
|
||||
> = (props) => {
|
||||
const state = useInfrastructureOperationsState({ embedded: props.embedded });
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<Show when={props.showInventory ?? true}>{state.renderStopMonitoringDialog()}</Show>
|
||||
<Show when={props.showInstaller ?? true}>{state.renderInstallerSection()}</Show>
|
||||
<Show when={props.showInventory ?? true}>{state.renderInventorySection()}</Show>
|
||||
</div>
|
||||
<InfrastructureOperationsStateProvider embedded={props.embedded}>
|
||||
<div class="space-y-6">
|
||||
<Show when={props.showInventory ?? true}>
|
||||
<InfrastructureStopMonitoringDialog />
|
||||
</Show>
|
||||
<Show when={props.showInstaller ?? true}>
|
||||
<InfrastructureInstallerSection />
|
||||
</Show>
|
||||
<Show when={props.showInventory ?? true}>
|
||||
<InfrastructureInventorySection />
|
||||
</Show>
|
||||
</div>
|
||||
</InfrastructureOperationsStateProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import { AgentProfilesPanel } from './AgentProfilesPanel';
|
||||
import { InfrastructureDirectConnectionsSummaryCard } from './InfrastructureDirectConnectionsSummaryCard';
|
||||
import { InfrastructureInventorySection } from './InfrastructureInventorySection';
|
||||
import { InfrastructureStopMonitoringDialog } from './InfrastructureStopMonitoringDialog';
|
||||
import type { ProxmoxSettingsPanelProps } from './ProxmoxSettingsPanel';
|
||||
import { useInfrastructureOperationsState } from './useInfrastructureOperationsState';
|
||||
import { InfrastructureOperationsStateProvider } from './useInfrastructureOperationsState';
|
||||
|
||||
interface InfrastructureReportingPanelProps extends ProxmoxSettingsPanelProps {
|
||||
onManageDirectConnections: () => void;
|
||||
|
|
@ -11,12 +13,12 @@ interface InfrastructureReportingPanelProps extends ProxmoxSettingsPanelProps {
|
|||
export const InfrastructureReportingPanel: Component<InfrastructureReportingPanelProps> = (
|
||||
props,
|
||||
) => {
|
||||
const state = useInfrastructureOperationsState();
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{state.renderStopMonitoringDialog()}
|
||||
{state.renderInventorySection()}
|
||||
<InfrastructureOperationsStateProvider>
|
||||
<InfrastructureStopMonitoringDialog />
|
||||
<InfrastructureInventorySection />
|
||||
</InfrastructureOperationsStateProvider>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
|
||||
<InfrastructureDirectConnectionsSummaryCard
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import { For, Show } from 'solid-js';
|
||||
import { Dialog } from '@/components/shared/Dialog';
|
||||
import { getStopMonitoringSurfaces } from './infrastructureOperationsModel';
|
||||
import { useInfrastructureOperationsContext } from './useInfrastructureOperationsState';
|
||||
|
||||
export const InfrastructureStopMonitoringDialog: Component = () => {
|
||||
const state = useInfrastructureOperationsContext();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={Boolean(state.stopMonitoringDialog())}
|
||||
onClose={() => {
|
||||
if (!state.stopMonitoringDialog()) return;
|
||||
const row = state.stopMonitoringDialog()!.row;
|
||||
if (state.getPendingInventoryAction(row.rowKey)) return;
|
||||
state.setStopMonitoringDialog(null);
|
||||
}}
|
||||
panelClass="max-w-lg"
|
||||
closeOnBackdrop={
|
||||
!state.stopMonitoringDialog() ||
|
||||
!state.getPendingInventoryAction(state.stopMonitoringDialog()!.row.rowKey)
|
||||
}
|
||||
ariaLabel="Confirm stop monitoring"
|
||||
>
|
||||
<Show when={state.stopMonitoringDialog()}>
|
||||
{(dialog) => {
|
||||
const row = () => dialog().row;
|
||||
const pending = () => state.getPendingInventoryAction(row().rowKey) === 'stop-monitoring';
|
||||
const isKubernetes = () =>
|
||||
row().capabilities.includes('kubernetes') && !row().capabilities.includes('agent');
|
||||
const affectedSurfaces = () => getStopMonitoringSurfaces(row());
|
||||
|
||||
return (
|
||||
<div class="flex max-h-[90vh] flex-col">
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-base-content">Stop monitoring?</h2>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
Pulse will remove{' '}
|
||||
<span class="font-medium text-base-content">{dialog().subject}</span> from
|
||||
active reporting.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-4 px-6 py-4">
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-900 dark:text-amber-100">
|
||||
<p class="font-medium">{dialog().scopeLabel} will stop in Pulse.</p>
|
||||
<p class="mt-1 text-xs opacity-90">
|
||||
The remote system keeps running. Pulse will ignore future reports and move this
|
||||
item into Ignored by Pulse until you allow reconnect.
|
||||
</p>
|
||||
</div>
|
||||
<Show when={affectedSurfaces().length > 0}>
|
||||
<div class="rounded-md border border-border bg-surface-hover px-4 py-3 text-sm text-muted">
|
||||
<p class="font-medium text-base-content">
|
||||
Pulse will stop these reporting surfaces
|
||||
</p>
|
||||
<div class="mt-3 grid gap-2">
|
||||
<For each={affectedSurfaces()}>
|
||||
{(surface) => (
|
||||
<div class="rounded-md border border-border bg-surface px-3 py-2">
|
||||
<div class="text-sm font-medium text-base-content">{surface.label}</div>
|
||||
<div class="mt-1 text-xs text-muted">{surface.detail}</div>
|
||||
<Show when={surface.idLabel && surface.idValue}>
|
||||
<div class="mt-2 text-[11px] text-muted">
|
||||
{surface.idLabel}:{' '}
|
||||
<span class="font-mono text-base-content">{surface.idValue}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="rounded-md border border-border bg-surface-hover px-4 py-3 text-sm text-muted">
|
||||
<p class="font-medium text-base-content">What stays unchanged</p>
|
||||
<p class="mt-1 text-xs">
|
||||
{isKubernetes()
|
||||
? 'The cluster itself is not uninstalled or shut down.'
|
||||
: 'The host, containers, and installed agent binaries are not uninstalled or shut down.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col-reverse gap-2 border-t border-border px-6 py-4 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.setStopMonitoringDialog(null)}
|
||||
disabled={pending()}
|
||||
class="inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md border border-border px-4 py-2 text-sm font-medium text-base-content hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
isKubernetes()
|
||||
? void state.handleRemoveKubernetesCluster(row())
|
||||
: void state.handleRemoveAgent(row())
|
||||
}
|
||||
disabled={pending()}
|
||||
class="inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{pending() ? 'Stopping…' : 'Confirm stop monitoring'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfrastructureStopMonitoringDialog;
|
||||
|
|
@ -5,11 +5,14 @@ import { createStore } from 'solid-js/store';
|
|||
import { Router, Route } from '@solidjs/router';
|
||||
import { UnifiedAgents } from '../UnifiedAgents';
|
||||
import infrastructureInstallPanelSource from '../InfrastructureInstallPanel.tsx?raw';
|
||||
import infrastructureInstallerSectionSource from '../InfrastructureInstallerSection.tsx?raw';
|
||||
import infrastructureOperationsControllerSource from '../InfrastructureOperationsController.tsx?raw';
|
||||
import infrastructureOperationsModelSource from '../infrastructureOperationsModel.tsx?raw';
|
||||
import infrastructureReportingPanelSource from '../InfrastructureReportingPanel.tsx?raw';
|
||||
import infrastructureDirectConnectionsSummaryCardSource from '../InfrastructureDirectConnectionsSummaryCard.tsx?raw';
|
||||
import infrastructureInventorySectionSource from '../InfrastructureInventorySection.tsx?raw';
|
||||
import infrastructureOperationsStateSource from '../useInfrastructureOperationsState.tsx?raw';
|
||||
import infrastructureStopMonitoringDialogSource from '../InfrastructureStopMonitoringDialog.tsx?raw';
|
||||
import type {
|
||||
Agent,
|
||||
ConnectedInfrastructureItem,
|
||||
|
|
@ -50,9 +53,15 @@ let securityStatusResponse = { requiresAuth: true, apiTokenConfigured: false };
|
|||
|
||||
describe('UnifiedAgents ownership guardrails', () => {
|
||||
it('routes controller and workspace panels through the shared infrastructure operations state owner', () => {
|
||||
expect(infrastructureOperationsControllerSource).toContain('useInfrastructureOperationsState');
|
||||
expect(infrastructureInstallPanelSource).toContain('useInfrastructureOperationsState');
|
||||
expect(infrastructureReportingPanelSource).toContain('useInfrastructureOperationsState');
|
||||
expect(infrastructureOperationsControllerSource).toContain('InfrastructureOperationsStateProvider');
|
||||
expect(infrastructureOperationsControllerSource).toContain('InfrastructureInstallerSection');
|
||||
expect(infrastructureOperationsControllerSource).toContain('InfrastructureInventorySection');
|
||||
expect(infrastructureOperationsControllerSource).toContain('InfrastructureStopMonitoringDialog');
|
||||
expect(infrastructureInstallPanelSource).toContain('InfrastructureOperationsStateProvider');
|
||||
expect(infrastructureInstallPanelSource).toContain('InfrastructureInstallerSection');
|
||||
expect(infrastructureReportingPanelSource).toContain('InfrastructureOperationsStateProvider');
|
||||
expect(infrastructureReportingPanelSource).toContain('InfrastructureInventorySection');
|
||||
expect(infrastructureReportingPanelSource).toContain('InfrastructureStopMonitoringDialog');
|
||||
expect(infrastructureReportingPanelSource).toContain(
|
||||
'./InfrastructureDirectConnectionsSummaryCard',
|
||||
);
|
||||
|
|
@ -61,9 +70,18 @@ describe('UnifiedAgents ownership guardrails', () => {
|
|||
'Direct Proxmox connections',
|
||||
);
|
||||
expect(infrastructureOperationsStateSource).toContain("./infrastructureOperationsModel");
|
||||
expect(infrastructureOperationsStateSource).toContain('renderInstallerSection');
|
||||
expect(infrastructureOperationsStateSource).toContain('renderInventorySection');
|
||||
expect(infrastructureOperationsStateSource).toContain('renderStopMonitoringDialog');
|
||||
expect(infrastructureOperationsStateSource).toContain(
|
||||
'export const InfrastructureOperationsStateProvider',
|
||||
);
|
||||
expect(infrastructureOperationsStateSource).toContain(
|
||||
'export const useInfrastructureOperationsContext',
|
||||
);
|
||||
expect(infrastructureOperationsStateSource).not.toContain('renderInstallerSection');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('renderInventorySection');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('renderStopMonitoringDialog');
|
||||
expect(infrastructureInstallerSectionSource).toContain('useInfrastructureOperationsContext');
|
||||
expect(infrastructureInventorySectionSource).toContain('useInfrastructureOperationsContext');
|
||||
expect(infrastructureStopMonitoringDialogSource).toContain('useInfrastructureOperationsContext');
|
||||
expect(infrastructureOperationsModelSource).toContain('export const getRowReportingSummary');
|
||||
expect(infrastructureOperationsModelSource).toContain(
|
||||
'export const getPowerShellInstallProfileEnvFromFlags',
|
||||
|
|
|
|||
|
|
@ -4,7 +4,12 @@ import agentProfilesPanelStateSource from '../useAgentProfilesPanelState.ts?raw'
|
|||
import apiTokenManagerSource from '../APITokenManager.tsx?raw';
|
||||
import apiTokenManagerModelSource from '../apiTokenManagerModel.ts?raw';
|
||||
import apiTokenManagerStateSource from '../useAPITokenManagerState.ts?raw';
|
||||
import infrastructureActiveRowDetailsSource from '../InfrastructureActiveRowDetails.tsx?raw';
|
||||
import infrastructureIgnoredRowDetailsSource from '../InfrastructureIgnoredRowDetails.tsx?raw';
|
||||
import infrastructureInstallerSectionSource from '../InfrastructureInstallerSection.tsx?raw';
|
||||
import infrastructureInventorySectionSource from '../InfrastructureInventorySection.tsx?raw';
|
||||
import infrastructureOperationsStateSource from '../useInfrastructureOperationsState.tsx?raw';
|
||||
import infrastructureStopMonitoringDialogSource from '../InfrastructureStopMonitoringDialog.tsx?raw';
|
||||
import agentLedgerPanelSource from '../MonitoredSystemLedgerPanel.tsx?raw';
|
||||
import alertsPageSource from '@/pages/Alerts.tsx?raw';
|
||||
import setupCompletionPanelSource from '@/components/SetupWizard/SetupCompletionPanel.tsx?raw';
|
||||
|
|
@ -97,6 +102,14 @@ const aiSettingsSource = [
|
|||
aiSettingsModelSource,
|
||||
aiSettingsStateSource,
|
||||
].join('\n');
|
||||
const infrastructureOperationsSource = [
|
||||
infrastructureOperationsStateSource,
|
||||
infrastructureInstallerSectionSource,
|
||||
infrastructureInventorySectionSource,
|
||||
infrastructureActiveRowDetailsSource,
|
||||
infrastructureIgnoredRowDetailsSource,
|
||||
infrastructureStopMonitoringDialogSource,
|
||||
].join('\n');
|
||||
import dashboardGuestPresentationSource from '@/utils/dashboardGuestPresentation.ts?raw';
|
||||
import containerUpdatesSource from '@/stores/containerUpdates.ts?raw';
|
||||
import websocketStoreSource from '@/stores/websocket.ts?raw';
|
||||
|
|
@ -218,15 +231,15 @@ describe('monitored-system model guardrails', () => {
|
|||
it('keeps UnifiedAgents free of v5 merge-workaround patterns', () => {
|
||||
expect(infrastructureOperationsStateSource).not.toContain('previousHostTypes');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('const allHosts = createMemo(');
|
||||
expect(infrastructureOperationsStateSource).toContain('@/utils/unifiedAgentStatusPresentation');
|
||||
expect(infrastructureOperationsStateSource).not.toContain(
|
||||
expect(infrastructureOperationsSource).toContain('@/utils/unifiedAgentStatusPresentation');
|
||||
expect(infrastructureOperationsSource).not.toContain(
|
||||
'const MONITORING_STOPPED_STATUS_LABEL =',
|
||||
);
|
||||
expect(infrastructureOperationsStateSource).not.toContain('const ALLOW_RECONNECT_LABEL =');
|
||||
expect(infrastructureOperationsSource).not.toContain('const ALLOW_RECONNECT_LABEL =');
|
||||
expect(infrastructureOperationsStateSource).toContain('withPrivilegeEscalation');
|
||||
expect(infrastructureOperationsStateSource).toContain('@/utils/agentCapabilityPresentation');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('const getCapabilityLabel =');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('const getCapabilityBadgeClass =');
|
||||
expect(infrastructureOperationsSource).toContain('@/utils/agentCapabilityPresentation');
|
||||
expect(infrastructureOperationsSource).not.toContain('const getCapabilityLabel =');
|
||||
expect(infrastructureOperationsSource).not.toContain('const getCapabilityBadgeClass =');
|
||||
expect(agentCapabilityPresentationSource).toContain("export type AgentCapability = 'agent'");
|
||||
expect(agentCapabilityPresentationSource).toContain('export function getAgentCapabilityLabel');
|
||||
expect(agentCapabilityPresentationSource).toContain(
|
||||
|
|
@ -237,10 +250,10 @@ describe('monitored-system model guardrails', () => {
|
|||
expect(agentProfilesPanelStateSource).toContain('isConnectedHealthStatus');
|
||||
expect(agentProfilesPanelSource).not.toContain('const connectedFromStatus =');
|
||||
expect(statusUtilsSource).toContain('export function isConnectedHealthStatus');
|
||||
expect(infrastructureOperationsStateSource).toContain('@/utils/unifiedAgentStatusPresentation');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('const statusBadgeClass =');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('const statusBadgeClasses =');
|
||||
expect(infrastructureOperationsStateSource).toContain(
|
||||
expect(infrastructureOperationsSource).toContain('@/utils/unifiedAgentStatusPresentation');
|
||||
expect(infrastructureOperationsSource).not.toContain('const statusBadgeClass =');
|
||||
expect(infrastructureOperationsSource).not.toContain('const statusBadgeClasses =');
|
||||
expect(infrastructureOperationsSource).toContain(
|
||||
'getUnifiedAgentLookupStatusPresentation',
|
||||
);
|
||||
expect(unifiedAgentStatusPresentationSource).toContain(
|
||||
|
|
@ -249,14 +262,14 @@ describe('monitored-system model guardrails', () => {
|
|||
expect(unifiedAgentStatusPresentationSource).toContain(
|
||||
'export function getUnifiedAgentLookupStatusPresentation',
|
||||
);
|
||||
expect(infrastructureOperationsStateSource).toContain('getInventorySubjectLabel');
|
||||
expect(infrastructureOperationsStateSource).toContain('getMonitoringStoppedEmptyState');
|
||||
expect(infrastructureOperationsStateSource).toContain('getRemovedUnifiedAgentItemLabel');
|
||||
expect(infrastructureOperationsStateSource).toContain('getUnifiedAgentLastSeenLabel');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('const getInventorySubjectLabel =');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('const getRemovedItemLabel =');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('const lastSeenLabel = () => {');
|
||||
expect(infrastructureOperationsStateSource).not.toContain(
|
||||
expect(infrastructureOperationsSource).toContain('getInventorySubjectLabel');
|
||||
expect(infrastructureOperationsSource).toContain('getMonitoringStoppedEmptyState');
|
||||
expect(infrastructureOperationsSource).toContain('getRemovedUnifiedAgentItemLabel');
|
||||
expect(infrastructureOperationsSource).toContain('getUnifiedAgentLastSeenLabel');
|
||||
expect(infrastructureOperationsSource).not.toContain('const getInventorySubjectLabel =');
|
||||
expect(infrastructureOperationsSource).not.toContain('const getRemovedItemLabel =');
|
||||
expect(infrastructureOperationsSource).not.toContain('const lastSeenLabel = () => {');
|
||||
expect(infrastructureOperationsSource).not.toContain(
|
||||
'No monitoring-stopped items match the current filters.',
|
||||
);
|
||||
expect(unifiedAgentInventoryPresentationSource).toContain(
|
||||
|
|
@ -289,15 +302,15 @@ describe('monitored-system model guardrails', () => {
|
|||
expect(unifiedAgentInventoryPresentationSource).toContain(
|
||||
'export function getUnifiedAgentClipboardCopySuccessMessage',
|
||||
);
|
||||
expect(infrastructureOperationsStateSource).not.toContain(
|
||||
expect(infrastructureOperationsSource).not.toContain(
|
||||
'No host identifiers are available to stop monitoring.',
|
||||
);
|
||||
expect(infrastructureOperationsStateSource).not.toContain(
|
||||
expect(infrastructureOperationsSource).not.toContain(
|
||||
'Failed to update agent configuration',
|
||||
);
|
||||
expect(infrastructureOperationsStateSource).not.toContain('Uninstall command copied');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('Upgrade command copied');
|
||||
expect(infrastructureOperationsStateSource).not.toContain(
|
||||
expect(infrastructureOperationsSource).not.toContain('Uninstall command copied');
|
||||
expect(infrastructureOperationsSource).not.toContain('Upgrade command copied');
|
||||
expect(infrastructureOperationsSource).not.toContain(
|
||||
"notificationStore.error('Failed to copy')",
|
||||
);
|
||||
expect(relaySettingsPanelSource).toContain('getRelayConnectionPresentation');
|
||||
|
|
@ -336,10 +349,10 @@ describe('monitored-system model guardrails', () => {
|
|||
expect(relayOnboardingCardSource).not.toContain('Pair Your Mobile Device');
|
||||
expect(relayOnboardingCardSource).not.toContain('Relay is currently disconnected.');
|
||||
expect(infrastructureOperationsStateSource).toContain('STORAGE_KEYS.SETUP_HANDOFF');
|
||||
expect(infrastructureOperationsStateSource).toContain(
|
||||
expect(infrastructureInstallerSectionSource).toContain(
|
||||
'Security configured. Save these first-run credentials now.',
|
||||
);
|
||||
expect(infrastructureOperationsStateSource).toContain(
|
||||
expect(infrastructureInstallerSectionSource).toContain(
|
||||
'Generate a scoped install token below before copying agent commands.',
|
||||
);
|
||||
expect(setupCompletionPanelSource).toContain('@/utils/relayPresentation');
|
||||
|
|
|
|||
|
|
@ -4,10 +4,15 @@ import settingsShellSource from '../SettingsPageShell.tsx?raw';
|
|||
import infrastructureWorkspaceSource from '../InfrastructureWorkspace.tsx?raw';
|
||||
import infrastructureWorkspaceModelSource from '../infrastructureWorkspaceModel.ts?raw';
|
||||
import infrastructureInstallPanelSource from '../InfrastructureInstallPanel.tsx?raw';
|
||||
import infrastructureInstallerSectionSource from '../InfrastructureInstallerSection.tsx?raw';
|
||||
import infrastructureOperationsControllerSource from '../InfrastructureOperationsController.tsx?raw';
|
||||
import infrastructureOperationsModelSource from '../infrastructureOperationsModel.tsx?raw';
|
||||
import infrastructureReportingPanelSource from '../InfrastructureReportingPanel.tsx?raw';
|
||||
import infrastructureDirectConnectionsSummaryCardSource from '../InfrastructureDirectConnectionsSummaryCard.tsx?raw';
|
||||
import infrastructureInventorySectionSource from '../InfrastructureInventorySection.tsx?raw';
|
||||
import infrastructureActiveRowDetailsSource from '../InfrastructureActiveRowDetails.tsx?raw';
|
||||
import infrastructureIgnoredRowDetailsSource from '../InfrastructureIgnoredRowDetails.tsx?raw';
|
||||
import infrastructureStopMonitoringDialogSource from '../InfrastructureStopMonitoringDialog.tsx?raw';
|
||||
import nodeModalModelSource from '../nodeModalModel.ts?raw';
|
||||
import nodeModalSource from '../NodeModal.tsx?raw';
|
||||
import infrastructureOperationsStateSource from '../useInfrastructureOperationsState.tsx?raw';
|
||||
|
|
@ -85,8 +90,13 @@ const extractedModules = [
|
|||
'../InfrastructureWorkspace.tsx',
|
||||
'../infrastructureWorkspaceModel.ts',
|
||||
'../InfrastructureInstallPanel.tsx',
|
||||
'../InfrastructureInstallerSection.tsx',
|
||||
'../InfrastructureReportingPanel.tsx',
|
||||
'../InfrastructureDirectConnectionsSummaryCard.tsx',
|
||||
'../InfrastructureInventorySection.tsx',
|
||||
'../InfrastructureActiveRowDetails.tsx',
|
||||
'../InfrastructureIgnoredRowDetails.tsx',
|
||||
'../InfrastructureStopMonitoringDialog.tsx',
|
||||
'../AIProviderConfigurationSection.tsx',
|
||||
'../AISettingsDialogs.tsx',
|
||||
'../aiSettingsModel.ts',
|
||||
|
|
@ -374,17 +384,37 @@ describe('Settings architecture guardrails', () => {
|
|||
expect(infrastructureWorkspaceSource).not.toContain('createEffect(() =>');
|
||||
expect(infrastructureWorkspaceSource).toContain('InfrastructureInstallPanel');
|
||||
expect(infrastructureWorkspaceSource).toContain('InfrastructureReportingPanel');
|
||||
expect(infrastructureInstallPanelSource).toContain('useInfrastructureOperationsState');
|
||||
expect(infrastructureReportingPanelSource).toContain('useInfrastructureOperationsState');
|
||||
expect(infrastructureInstallPanelSource).toContain('InfrastructureOperationsStateProvider');
|
||||
expect(infrastructureInstallPanelSource).toContain('InfrastructureInstallerSection');
|
||||
expect(infrastructureReportingPanelSource).toContain('InfrastructureOperationsStateProvider');
|
||||
expect(infrastructureReportingPanelSource).toContain('InfrastructureInventorySection');
|
||||
expect(infrastructureReportingPanelSource).toContain('InfrastructureStopMonitoringDialog');
|
||||
expect(infrastructureReportingPanelSource).toContain(
|
||||
'./InfrastructureDirectConnectionsSummaryCard',
|
||||
);
|
||||
expect(infrastructureReportingPanelSource).not.toContain('Direct Proxmox connections');
|
||||
expect(infrastructureReportingPanelSource).not.toContain('Manage direct connections');
|
||||
expect(infrastructureOperationsControllerSource).toContain('useInfrastructureOperationsState');
|
||||
expect(infrastructureOperationsControllerSource).toContain('InfrastructureOperationsStateProvider');
|
||||
expect(infrastructureOperationsControllerSource).toContain('InfrastructureInstallerSection');
|
||||
expect(infrastructureOperationsControllerSource).toContain('InfrastructureInventorySection');
|
||||
expect(infrastructureOperationsControllerSource).toContain('InfrastructureStopMonitoringDialog');
|
||||
expect(infrastructureOperationsStateSource).toContain(
|
||||
'export const useInfrastructureOperationsState',
|
||||
);
|
||||
expect(infrastructureOperationsStateSource).toContain(
|
||||
'export const InfrastructureOperationsStateProvider',
|
||||
);
|
||||
expect(infrastructureOperationsStateSource).toContain(
|
||||
'export const useInfrastructureOperationsContext',
|
||||
);
|
||||
expect(infrastructureOperationsStateSource).not.toContain('const renderInstallerSection =');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('const renderInventorySection =');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('const renderStopMonitoringDialog =');
|
||||
expect(infrastructureInstallerSectionSource).toContain('useInfrastructureOperationsContext');
|
||||
expect(infrastructureInventorySectionSource).toContain('useInfrastructureOperationsContext');
|
||||
expect(infrastructureStopMonitoringDialogSource).toContain('useInfrastructureOperationsContext');
|
||||
expect(infrastructureActiveRowDetailsSource).toContain('useInfrastructureOperationsContext');
|
||||
expect(infrastructureIgnoredRowDetailsSource).toContain('useInfrastructureOperationsContext');
|
||||
expect(infrastructureWorkspaceModelSource).toContain(
|
||||
'export const INFRASTRUCTURE_WORKSPACE_TABS',
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1895,6 +1895,42 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
|
|||
"frontend-modern/src/components/Settings/useProxmoxSettingsPanelState.ts"
|
||||
)
|
||||
|
||||
def test_infrastructure_installer_section_change_requires_agent_lifecycle(self):
|
||||
required = infer_impacted_subsystems(
|
||||
["frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx"]
|
||||
)
|
||||
self.assertEqual(set(required), {"agent-lifecycle"})
|
||||
|
||||
lifecycle = required["agent-lifecycle"]
|
||||
self.assertEqual(
|
||||
lifecycle["contract"],
|
||||
"docs/release-control/v6/internal/subsystems/agent-lifecycle.md",
|
||||
)
|
||||
self.assertEqual(
|
||||
lifecycle["touched_runtime_files"],
|
||||
["frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx"],
|
||||
)
|
||||
self.assertEqual(
|
||||
lifecycle["verification_requirements"],
|
||||
[
|
||||
{
|
||||
"id": "unified-agent-settings-surface",
|
||||
"label": "unified agent settings lifecycle proof",
|
||||
"touched_runtime_files": [
|
||||
"frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx"
|
||||
],
|
||||
"allow_same_subsystem_tests": False,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"frontend-modern/src/api/__tests__/agentProfiles.test.ts",
|
||||
"frontend-modern/src/api/__tests__/monitoring.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx",
|
||||
"frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx",
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
def test_agent_install_backend_change_requires_lifecycle_and_api_contracts(self):
|
||||
required = infer_impacted_subsystems(["internal/api/agent_install_command_shared.go"])
|
||||
self.assertEqual(set(required), {"agent-lifecycle", "api-contracts"})
|
||||
|
|
|
|||
|
|
@ -2062,6 +2062,42 @@ class SubsystemLookupTest(unittest.TestCase):
|
|||
],
|
||||
)
|
||||
|
||||
def test_lookup_paths_assigns_infrastructure_installer_section_to_agent_lifecycle(self) -> None:
|
||||
result = lookup_paths(
|
||||
["frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx"]
|
||||
)
|
||||
self.assertEqual(result["unowned_runtime_files"], [])
|
||||
self.assertEqual(
|
||||
{item["subsystem"] for item in result["impacted_subsystems"]},
|
||||
{"agent-lifecycle"},
|
||||
)
|
||||
|
||||
file_entry = result["files"][0]
|
||||
self.assertEqual(file_entry["classification"], "runtime")
|
||||
self.assertIsNone(file_entry["shared_ownership"])
|
||||
self.assertEqual(len(file_entry["matches"]), 1)
|
||||
|
||||
match = file_entry["matches"][0]
|
||||
self.assertEqual(match["subsystem"], "agent-lifecycle")
|
||||
self.assertEqual(
|
||||
match["contract"],
|
||||
"docs/release-control/v6/internal/subsystems/agent-lifecycle.md",
|
||||
)
|
||||
self.assertEqual(match["lane_context"]["lane_id"], "L16")
|
||||
self.assertEqual(
|
||||
match["verification_requirement"]["id"],
|
||||
"unified-agent-settings-surface",
|
||||
)
|
||||
self.assertEqual(
|
||||
match["verification_requirement"]["exact_files"],
|
||||
[
|
||||
"frontend-modern/src/api/__tests__/agentProfiles.test.ts",
|
||||
"frontend-modern/src/api/__tests__/monitoring.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx",
|
||||
"frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx",
|
||||
],
|
||||
)
|
||||
|
||||
def test_lookup_paths_reports_windows_installer_as_shared_boundary(self) -> None:
|
||||
result = lookup_paths(["scripts/install.ps1"])
|
||||
self.assertEqual(result["unowned_runtime_files"], [])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue