diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 99ecca04e..50d1c6b33 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -32,13 +32,14 @@ management, and fleet control surfaces. 8. `frontend-modern/src/api/agentProfiles.ts` 9. `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx` 10. `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx` -11. `frontend-modern/src/components/Settings/UnifiedAgents.tsx` -12. `frontend-modern/src/components/Settings/NodeModal.tsx` -13. `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx` -14. `frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx` -15. `frontend-modern/src/utils/agentProfilesPresentation.ts` -16. `frontend-modern/src/utils/agentInstallCommand.ts` -17. `frontend-modern/src/api/nodes.ts` +11. `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx` +12. `frontend-modern/src/components/Settings/UnifiedAgents.tsx` +13. `frontend-modern/src/components/Settings/NodeModal.tsx` +14. `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx` +15. `frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx` +16. `frontend-modern/src/utils/agentProfilesPresentation.ts` +17. `frontend-modern/src/utils/agentInstallCommand.ts` +18. `frontend-modern/src/api/nodes.ts` ## Shared Boundaries @@ -46,12 +47,13 @@ management, and fleet control surfaces. 2. `frontend-modern/src/api/nodes.ts` shared with `api-contracts`: the shared Proxmox node client is both an agent lifecycle setup/install control surface and a canonical API payload contract boundary. 3. `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx` shared with `api-contracts`: the infrastructure operations controller is both an agent fleet lifecycle control surface and an API token, lookup, assignment, and reporting/install contract boundary. 4. `frontend-modern/src/components/Settings/UnifiedAgents.tsx` shared with `api-contracts`: the UnifiedAgents module is a compatibility shim for the canonical infrastructure operations controller and remains on the same shared agent lifecycle and API contract boundary while the old module path exists. -5. `frontend-modern/src/utils/agentInstallCommand.ts` shared with `api-contracts`: the shared frontend install-command helper is both an agent lifecycle control surface and a canonical API/install transport contract boundary. -6. `internal/api/agent_install_command_shared.go` shared with `api-contracts`: agent install command assembly is both an agent lifecycle control surface and a canonical API payload contract boundary. -7. `internal/api/config_setup_handlers.go` shared with `api-contracts`: auto-register and setup handlers are both an agent lifecycle control surface and a canonical API payload contract boundary. -8. `internal/api/unified_agent.go` shared with `api-contracts`: unified agent download and installer handlers are both an agent lifecycle control surface and a canonical API payload contract boundary. -9. `scripts/install.ps1` shared with `deployment-installability`: the Windows installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary. -10. `scripts/install.sh` shared with `deployment-installability`: the shell installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary. +5. `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx` shared with `api-contracts`: the shared infrastructure operations state hook is both an agent fleet lifecycle control surface and an API token, lookup, assignment, and reporting/install contract boundary. +6. `frontend-modern/src/utils/agentInstallCommand.ts` shared with `api-contracts`: the shared frontend install-command helper is both an agent lifecycle control surface and a canonical API/install transport contract boundary. +7. `internal/api/agent_install_command_shared.go` shared with `api-contracts`: agent install command assembly is both an agent lifecycle control surface and a canonical API payload contract boundary. +8. `internal/api/config_setup_handlers.go` shared with `api-contracts`: auto-register and setup handlers are both an agent lifecycle control surface and a canonical API payload contract boundary. +9. `internal/api/unified_agent.go` shared with `api-contracts`: unified agent download and installer handlers are both an agent lifecycle control surface and a canonical API payload contract boundary. +10. `scripts/install.ps1` shared with `deployment-installability`: the Windows installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary. +11. `scripts/install.sh` shared with `deployment-installability`: the shell installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary. ## Extension Points @@ -60,7 +62,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, 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/InfrastructureOperationsController.tsx`, `frontend-modern/src/components/Settings/NodeModal.tsx`, `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, 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/InfrastructureOperationsController.tsx`, `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`, `frontend-modern/src/components/Settings/NodeModal.tsx`, `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`, `frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx`, and `frontend-modern/src/utils/agentInstallCommand.ts`. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 85cd34ea9..2a29201ba 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -29,14 +29,15 @@ Own canonical runtime payload shapes between backend and frontend. 6. `frontend-modern/src/api/responseUtils.ts` 7. `frontend-modern/src/components/Settings/APITokenManager.tsx` 8. `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx` -9. `frontend-modern/src/components/Settings/UnifiedAgents.tsx` -10. `frontend-modern/src/utils/agentInstallCommand.ts` -11. `frontend-modern/src/api/nodes.ts` -12. `frontend-modern/src/api/license.ts` -13. `frontend-modern/src/api/monitoredSystemLedger.ts` -14. `frontend-modern/src/api/resources.ts` -15. `frontend-modern/src/api/monitoring.ts` -16. `internal/api/monitored_system_ledger.go` +9. `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx` +10. `frontend-modern/src/components/Settings/UnifiedAgents.tsx` +11. `frontend-modern/src/utils/agentInstallCommand.ts` +12. `frontend-modern/src/api/nodes.ts` +13. `frontend-modern/src/api/license.ts` +14. `frontend-modern/src/api/monitoredSystemLedger.ts` +15. `frontend-modern/src/api/resources.ts` +16. `frontend-modern/src/api/monitoring.ts` +17. `internal/api/monitored_system_ledger.go` ## Shared Boundaries @@ -50,24 +51,25 @@ Own canonical runtime payload shapes between backend and frontend. 8. `frontend-modern/src/components/Settings/APITokenManager.tsx` shared with `security-privacy`: the API token settings surface is both a security/privacy control surface and a canonical API payload contract boundary. 9. `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx` shared with `agent-lifecycle`: the infrastructure operations controller is both an agent fleet lifecycle control surface and an API token, lookup, assignment, and reporting/install contract boundary. 10. `frontend-modern/src/components/Settings/UnifiedAgents.tsx` shared with `agent-lifecycle`: the UnifiedAgents module is a compatibility shim for the canonical infrastructure operations controller and remains on the same shared agent lifecycle and API contract boundary while the old module path exists. -11. `frontend-modern/src/utils/agentInstallCommand.ts` shared with `agent-lifecycle`: the shared frontend install-command helper is both an agent lifecycle control surface and a canonical API/install transport contract boundary. -12. `internal/api/agent_install_command_shared.go` shared with `agent-lifecycle`: agent install command assembly is both an agent lifecycle control surface and a canonical API payload contract boundary. -13. `internal/api/ai_handler.go` shared with `ai-runtime`: Pulse Assistant handlers are both an AI runtime control surface and a canonical API payload contract boundary. -14. `internal/api/ai_handlers.go` shared with `ai-runtime`: AI settings and remediation handlers are both an AI runtime control surface and a canonical API payload contract boundary. -15. `internal/api/ai_intelligence_handlers.go` shared with `ai-runtime`: AI intelligence handlers are both an AI runtime control surface and a canonical API payload contract boundary. -16. `internal/api/config_setup_handlers.go` shared with `agent-lifecycle`: auto-register and setup handlers are both an agent lifecycle control surface and a canonical API payload contract boundary. -17. `internal/api/licensing_bridge.go` shared with `cloud-paid`: commercial licensing bridge handlers carry both API payload contract and cloud-paid entitlement boundary ownership. -18. `internal/api/licensing_handlers.go` shared with `cloud-paid`: commercial licensing handlers carry both API payload contract and cloud-paid entitlement boundary ownership. -19. `internal/api/notifications.go` shared with `notifications`: notification handlers are both a notification delivery control surface and a canonical API payload contract boundary. -20. `internal/api/payments_webhook_handlers.go` shared with `cloud-paid`: commercial payment webhook handlers carry both API payload contract and cloud-paid billing boundary ownership. -21. `internal/api/public_signup_handlers.go` shared with `cloud-paid`: hosted signup handlers carry both API payload contract and cloud-paid hosted provisioning boundary ownership. -22. `internal/api/resources.go` shared with `unified-resources`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary. -23. `internal/api/security.go` shared with `security-privacy`: the security handlers are both a security/privacy control surface and a canonical API payload contract boundary. -24. `internal/api/security_tokens.go` shared with `security-privacy`: the security token handlers are both a security/privacy control surface and a canonical API payload contract boundary. -25. `internal/api/slo.go` shared with `performance-and-scalability`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary. -26. `internal/api/system_settings.go` shared with `security-privacy`: the system settings telemetry and auth controls are both a security/privacy control surface and a canonical API payload contract boundary. -27. `internal/api/unified_agent.go` shared with `agent-lifecycle`: unified agent download and installer handlers are both an agent lifecycle control surface and a canonical API payload contract boundary. -28. `internal/api/updates.go` shared with `deployment-installability`: update handlers are both a deployment-installability control surface and a canonical API payload contract boundary. +11. `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx` shared with `agent-lifecycle`: the shared infrastructure operations state hook is both an agent fleet lifecycle control surface and an API token, lookup, assignment, and reporting/install contract boundary. +12. `frontend-modern/src/utils/agentInstallCommand.ts` shared with `agent-lifecycle`: the shared frontend install-command helper is both an agent lifecycle control surface and a canonical API/install transport contract boundary. +13. `internal/api/agent_install_command_shared.go` shared with `agent-lifecycle`: agent install command assembly is both an agent lifecycle control surface and a canonical API payload contract boundary. +14. `internal/api/ai_handler.go` shared with `ai-runtime`: Pulse Assistant handlers are both an AI runtime control surface and a canonical API payload contract boundary. +15. `internal/api/ai_handlers.go` shared with `ai-runtime`: AI settings and remediation handlers are both an AI runtime control surface and a canonical API payload contract boundary. +16. `internal/api/ai_intelligence_handlers.go` shared with `ai-runtime`: AI intelligence handlers are both an AI runtime control surface and a canonical API payload contract boundary. +17. `internal/api/config_setup_handlers.go` shared with `agent-lifecycle`: auto-register and setup handlers are both an agent lifecycle control surface and a canonical API payload contract boundary. +18. `internal/api/licensing_bridge.go` shared with `cloud-paid`: commercial licensing bridge handlers carry both API payload contract and cloud-paid entitlement boundary ownership. +19. `internal/api/licensing_handlers.go` shared with `cloud-paid`: commercial licensing handlers carry both API payload contract and cloud-paid entitlement boundary ownership. +20. `internal/api/notifications.go` shared with `notifications`: notification handlers are both a notification delivery control surface and a canonical API payload contract boundary. +21. `internal/api/payments_webhook_handlers.go` shared with `cloud-paid`: commercial payment webhook handlers carry both API payload contract and cloud-paid billing boundary ownership. +22. `internal/api/public_signup_handlers.go` shared with `cloud-paid`: hosted signup handlers carry both API payload contract and cloud-paid hosted provisioning boundary ownership. +23. `internal/api/resources.go` shared with `unified-resources`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary. +24. `internal/api/security.go` shared with `security-privacy`: the security handlers are both a security/privacy control surface and a canonical API payload contract boundary. +25. `internal/api/security_tokens.go` shared with `security-privacy`: the security token handlers are both a security/privacy control surface and a canonical API payload contract boundary. +26. `internal/api/slo.go` shared with `performance-and-scalability`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary. +27. `internal/api/system_settings.go` shared with `security-privacy`: the system settings telemetry and auth controls are both a security/privacy control surface and a canonical API payload contract boundary. +28. `internal/api/unified_agent.go` shared with `agent-lifecycle`: unified agent download and installer handlers are both an agent lifecycle control surface and a canonical API payload contract boundary. +29. `internal/api/updates.go` shared with `deployment-installability`: update handlers are both a deployment-installability control surface and a canonical API payload contract boundary. ## Extension Points 1. Add or change payload fields through handler + contract tests together @@ -91,7 +93,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` -10. Add or change infrastructure operations token generation, lookup, assignment, and reporting/install presentation through `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx` +10. Add or change infrastructure operations token generation, lookup, assignment, and reporting/install presentation through `frontend-modern/src/components/Settings/InfrastructureOperationsController.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. diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index fcf771b8d..42d09a2ff 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -129,6 +129,14 @@ "api-contracts" ] }, + { + "path": "frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx", + "rationale": "the shared infrastructure operations state hook is both an agent fleet lifecycle control surface and an API token, lookup, assignment, and reporting/install contract boundary", + "subsystems": [ + "agent-lifecycle", + "api-contracts" + ] + }, { "path": "frontend-modern/src/utils/agentInstallCommand.ts", "rationale": "the shared frontend install-command helper is both an agent lifecycle control surface and a canonical API/install transport contract boundary", @@ -307,6 +315,7 @@ "frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx", "frontend-modern/src/components/Settings/NodeModal.tsx", "frontend-modern/src/components/Settings/UnifiedAgents.tsx", + "frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx", "frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx", "frontend-modern/src/utils/agentInstallCommand.ts", "frontend-modern/src/utils/agentProfilesPresentation.ts", @@ -503,7 +512,8 @@ "match_prefixes": [], "match_files": [ "frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx", - "frontend-modern/src/components/Settings/UnifiedAgents.tsx" + "frontend-modern/src/components/Settings/UnifiedAgents.tsx", + "frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx" ], "allow_same_subsystem_tests": false, "test_prefixes": [], @@ -696,6 +706,7 @@ "frontend-modern/src/components/Settings/APITokenManager.tsx", "frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx", "frontend-modern/src/components/Settings/UnifiedAgents.tsx", + "frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx", "frontend-modern/src/types/api.ts", "frontend-modern/src/utils/agentInstallCommand.ts" ], @@ -904,7 +915,8 @@ "match_prefixes": [], "match_files": [ "frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx", - "frontend-modern/src/components/Settings/UnifiedAgents.tsx" + "frontend-modern/src/components/Settings/UnifiedAgents.tsx", + "frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx" ], "allow_same_subsystem_tests": false, "test_prefixes": [], diff --git a/frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx b/frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx index 17b67c3b5..add5d5ee7 100644 --- a/frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx @@ -1,8 +1,10 @@ import type { Component } from 'solid-js'; -import { InfrastructureOperationsController } from './InfrastructureOperationsController'; +import { useInfrastructureOperationsState } from './useInfrastructureOperationsState'; -export const InfrastructureInstallPanel: Component = () => ( - -); +export const InfrastructureInstallPanel: Component = () => { + const state = useInfrastructureOperationsState({ embedded: true }); + + return state.renderInstallerSection(); +}; export default InfrastructureInstallPanel; diff --git a/frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx b/frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx index a9c6c6a62..e2993ad59 100644 --- a/frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx @@ -1,599 +1,11 @@ -import { Component, createSignal, Show, For, onMount, createEffect, createMemo } from 'solid-js'; -import { useNavigate } from '@solidjs/router'; -import { unwrap } from 'solid-js/store'; -import { useWebSocket } from '@/App'; -import SettingsPanel from '@/components/shared/SettingsPanel'; -import { Dialog } from '@/components/shared/Dialog'; -import { PulseDataGrid } from '@/components/shared/PulseDataGrid'; -import { SearchField } from '@/components/shared/SearchField'; -import Server from 'lucide-solid/icons/server'; -import Users from 'lucide-solid/icons/users'; -import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon'; -import { formatRelativeTime, formatAbsoluteTime } from '@/utils/format'; +import type { Component } from 'solid-js'; +import { Show } from 'solid-js'; import { - MonitoringAPI, - type RemovedDockerHost, - type RemovedHostAgent, - type RemovedKubernetesCluster, -} from '@/api/monitoring'; -import { - AgentProfilesAPI, - MISSING_AGENT_PROFILE_ASSIGNMENT_MESSAGE, - type AgentProfile, - type AgentProfileAssignment, -} from '@/api/agentProfiles'; -import { SecurityAPI } from '@/api/security'; -import { notificationStore } from '@/stores/notifications'; -import { useResources } from '@/hooks/useResources'; -import type { SecurityStatus } from '@/types/config'; -import type { - AgentLookupResponse, - ConnectedInfrastructureItem, - ConnectedInfrastructureSurface, -} from '@/types/api'; -import type { APITokenRecord } from '@/api/security'; -import { - AGENT_REPORT_SCOPE, - AGENT_CONFIG_READ_SCOPE, - DOCKER_REPORT_SCOPE, - KUBERNETES_REPORT_SCOPE, - AGENT_EXEC_SCOPE, -} from '@/constants/apiScopes'; -import { copyToClipboard } from '@/utils/clipboard'; -import { getPulseBaseUrl } from '@/utils/url'; -import { logger } from '@/utils/logger'; -import { STORAGE_KEYS } from '@/utils/localStorage'; -import { - buildPowerShellInstallScriptBootstrap, - buildUnixAgentInstallCommand, - buildWindowsAgentInstallCommand, - resolveInstallerBaseUrl, - powerShellQuote, -} from '@/utils/agentInstallCommand'; -import { - getPreferredNamedEntityLabel, - getPreferredResourceHostname, -} from '@/utils/resourceIdentity'; -import { - getActionableAgentIdFromResource, - getActionableDockerRuntimeIdFromResource, - getActionableKubernetesClusterIdFromResource, - getExplicitAgentIdFromResource, - getPlatformAgentRecord, - getPlatformDataRecord, - hasAgentFacet, - hasDockerWorkloadsScope, -} from '@/utils/agentResources'; -import { - getAgentCapabilityBadgeClass, - getAgentCapabilityLabel, - type AgentCapability, -} from '@/utils/agentCapabilityPresentation'; -import { - ALLOW_RECONNECT_LABEL, - getUnifiedAgentLookupStatusPresentation, - getUnifiedAgentStatusPresentation, - MONITORING_STOPPED_STATUS_LABEL, -} from '@/utils/unifiedAgentStatusPresentation'; -import { - getUnifiedAgentAllowReconnectErrorMessage, - getUnifiedAgentAllowReconnectSuccessMessage, - getUnifiedAgentClipboardCopyErrorMessage, - getUnifiedAgentClipboardCopySuccessMessage, - getInventorySubjectLabel, - getMonitoringStoppedEmptyState, - getRemovedUnifiedAgentItemLabel, - getUnifiedAgentStopMonitoringErrorMessage, - getUnifiedAgentStopMonitoringSuccessMessage, - getUnifiedAgentStopMonitoringUnavailableMessage, - getUnifiedAgentLastSeenLabel, - getUnifiedAgentUninstallCommandCopiedMessage, - getUnifiedAgentUpgradeCommandCopiedMessage, -} from '@/utils/unifiedAgentInventoryPresentation'; -import { - trackAgentInstallCommandCopied, - trackAgentInstallProfileSelected, - trackAgentInstallTokenGenerated, -} from '@/utils/upgradeMetrics'; -import type { Resource } from '@/types/resource'; + type InfrastructureOperationsStateOptions, + useInfrastructureOperationsState, +} from './useInfrastructureOperationsState'; -const TOKEN_PLACEHOLDER = ''; -const UNIFIED_AGENT_TELEMETRY_SURFACE = 'settings_unified_agents'; - -const buildDefaultTokenName = () => { - const now = new Date(); - const iso = now.toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM - const stamp = iso.replace('T', ' ').replace(/:/g, '-'); - return `Agent ${stamp}`; -}; - -const normalizeTelemetryPart = (value: string) => - value - .toLowerCase() - .replace(/[^a-z0-9]+/g, '_') - .replace(/^_+|_+$/g, ''); - -const shellQuoteArg = (value: string) => `'${value.replace(/'/g, `'\"'\"'`)}'`; -type AgentPlatform = 'linux' | 'macos' | 'freebsd' | 'windows'; -type UnifiedAgentStatus = 'active' | 'removed'; -type ScopeCategory = 'default' | 'profile' | 'ai-managed' | 'na'; -type InstallProfile = 'auto' | 'docker' | 'kubernetes' | 'proxmox-pve' | 'proxmox-pbs' | 'truenas'; - -type SetupHandoffState = { - username: string; - password: string; - apiToken: string; - createdAt?: string; -}; - -type UnifiedAgentRow = { - rowKey: string; - id: string; - agentActionId?: string; - dockerActionId?: string; - kubernetesActionId?: string; - name: string; - hostname?: string; - displayName?: string; - capabilities: AgentCapability[]; - status: UnifiedAgentStatus; - healthStatus?: string; - lastSeen?: number; - removedAt?: number; - version?: string; - isOutdatedBinary?: boolean; - linkedNodeId?: string; - commandsEnabled?: boolean; - agentId?: string; - upgradePlatform: AgentPlatform; - scope: { - label: string; - detail?: string; - category: ScopeCategory; - }; - installFlags: string[]; - searchText: string; - kubernetesInfo?: { - server?: string; - context?: string; - tokenName?: string; - }; - surfaces: Array<{ - key: string; - kind: AgentCapability; - label: string; - detail: string; - idLabel?: string; - idValue?: string; - action?: 'stop-monitoring' | 'allow-reconnect'; - controlId?: string; - }>; -}; - -type InventoryActionType = 'stop-monitoring' | 'allow-reconnect'; - -type InventoryActionNotice = { - tone: 'success' | 'info'; - title: string; - detail: string; - showRecoveryQueueLink?: boolean; -}; - -type StopMonitoringDialogState = { - row: UnifiedAgentRow; - subject: string; - scopeLabel: string; -}; - -const getCapabilitySurfaceLabel = (capability: AgentCapability) => { - switch (capability) { - case 'agent': - return 'Host telemetry'; - case 'docker': - return 'Docker runtime data'; - case 'kubernetes': - return 'Kubernetes cluster data'; - case 'proxmox': - return 'Proxmox data'; - case 'pbs': - return 'PBS data'; - case 'pmg': - return 'PMG data'; - default: - return getAgentCapabilityLabel(capability); - } -}; - -const getReconnectActionLabel = (row: UnifiedAgentRow) => { - if (row.capabilities.includes('docker')) { - return 'Allow Docker reconnect'; - } - if (row.capabilities.includes('kubernetes')) { - return 'Allow Kubernetes reconnect'; - } - return 'Allow host reconnect'; -}; - -const joinHumanList = (parts: string[]) => { - if (parts.length === 0) return ''; - if (parts.length === 1) return parts[0]; - if (parts.length === 2) return `${parts[0]} and ${parts[1]}`; - return `${parts.slice(0, -1).join(', ')}, and ${parts.at(-1)}`; -}; - -const sentenceCaseSurfaceLabel = (label: string, index: number) => { - if (index !== 0 || label.length === 0) { - return label; - } - return `${label.slice(0, 1).toLowerCase()}${label.slice(1)}`; -}; - -const getRowReportingSummary = (row: UnifiedAgentRow) => { - const reported = row.surfaces.map((surface, index) => sentenceCaseSurfaceLabel(surface.label, index)); - if (reported.length === 0) { - return ''; - } - return `Pulse is receiving ${joinHumanList(reported)} from this item.`; -}; - -const getRowSurfaceBreakdown = (row: UnifiedAgentRow) => { - return row.surfaces; -}; - -const getStopMonitoringSurfaces = (row: UnifiedAgentRow) => { - const surfaces = getRowSurfaceBreakdown(row); - const stopMonitoringSurfaces = surfaces.filter((surface) => surface.action === 'stop-monitoring'); - const hostManagedStopApplies = stopMonitoringSurfaces.some((surface) => surface.kind === 'agent'); - if (!hostManagedStopApplies) { - return stopMonitoringSurfaces; - } - return surfaces.filter((surface) => - ['agent', 'docker', 'kubernetes', 'proxmox', 'pbs', 'pmg'].includes(surface.kind), - ); -}; - -const getStopMonitoringScopeLabel = (row: UnifiedAgentRow) => { - const surfaceLabels = getStopMonitoringSurfaces(row).map((surface) => surface.label); - if (surfaceLabels.length === 0) { - return 'Reporting for this item'; - } - return joinHumanList(surfaceLabels); -}; - -const createSurfaceScopedRow = ( - row: UnifiedAgentRow, - surfaceKey: 'agent' | 'docker' | 'kubernetes' | 'proxmox' | 'pbs' | 'pmg', -): UnifiedAgentRow => { - if (surfaceKey === 'docker') { - return { - ...row, - rowKey: `${row.rowKey}-docker-surface`, - capabilities: ['docker'], - agentActionId: undefined, - kubernetesActionId: undefined, - linkedNodeId: undefined, - surfaces: row.surfaces.filter((surface) => surface.kind === 'docker'), - }; - } - - if (surfaceKey === 'kubernetes') { - return { - ...row, - rowKey: `${row.rowKey}-kubernetes-surface`, - capabilities: ['kubernetes'], - agentActionId: undefined, - dockerActionId: undefined, - linkedNodeId: undefined, - surfaces: row.surfaces.filter((surface) => surface.kind === 'kubernetes'), - }; - } - - if (surfaceKey === 'agent') { - const hostManagedCapabilities: AgentCapability[] = ['agent']; - if (row.capabilities.includes('proxmox')) hostManagedCapabilities.push('proxmox'); - if (row.capabilities.includes('pbs')) hostManagedCapabilities.push('pbs'); - if (row.capabilities.includes('pmg')) hostManagedCapabilities.push('pmg'); - return { - ...row, - rowKey: `${row.rowKey}-agent-surface`, - capabilities: hostManagedCapabilities, - dockerActionId: undefined, - kubernetesActionId: undefined, - surfaces: row.surfaces.filter((surface) => - ['agent', 'proxmox', 'pbs', 'pmg'].includes(surface.kind), - ), - }; - } - - if (surfaceKey === 'pbs') { - return { - ...row, - rowKey: `${row.rowKey}-pbs-surface`, - capabilities: ['pbs'], - dockerActionId: undefined, - kubernetesActionId: undefined, - surfaces: row.surfaces.filter((surface) => surface.kind === 'pbs'), - }; - } - - if (surfaceKey === 'pmg') { - return { - ...row, - rowKey: `${row.rowKey}-pmg-surface`, - capabilities: ['pmg'], - dockerActionId: undefined, - kubernetesActionId: undefined, - surfaces: row.surfaces.filter((surface) => surface.kind === 'pmg'), - }; - } - - return { - ...row, - rowKey: `${row.rowKey}-proxmox-surface`, - capabilities: ['proxmox'], - dockerActionId: undefined, - kubernetesActionId: undefined, - surfaces: row.surfaces.filter((surface) => surface.kind === 'proxmox'), - }; -}; - -const INSTALL_PROFILE_OPTIONS: { - value: InstallProfile; - label: string; - description: string; - flags: string[]; -}[] = [ - { - value: 'auto', - label: 'Auto-detect (recommended)', - description: - 'Let the installer detect Docker, Kubernetes, Proxmox, and agent capabilities automatically.', - flags: [], - }, - { - value: 'docker', - label: 'Docker / Podman runtime', - description: 'Force container runtime monitoring even when detection is restricted.', - flags: ['--enable-docker', '--disable-host'], - }, - { - value: 'kubernetes', - label: 'Kubernetes node', - description: 'Force Kubernetes monitoring on cluster nodes.', - flags: ['--enable-kubernetes'], - }, - { - value: 'proxmox-pve', - label: 'Proxmox VE node', - description: 'Force Proxmox integration and register as a PVE node.', - flags: ['--enable-proxmox', '--proxmox-type pve'], - }, - { - value: 'proxmox-pbs', - label: 'Proxmox Backup node', - description: 'Force Proxmox integration and register as a PBS node.', - flags: ['--enable-proxmox', '--proxmox-type pbs'], - }, - { - value: 'truenas', - label: 'TrueNAS SCALE agent', - description: - 'Use default auto-detection; installer applies TrueNAS-safe service handling automatically.', - flags: [], - }, -]; - -// Generate platform-specific commands with the appropriate Pulse URL -// Uses agentUrl from API (PULSE_PUBLIC_URL) if configured, otherwise falls back to window.location -const buildCommandsByPlatform = ( - unixCommand: string, - windowsInteractiveCommand: string, - windowsParameterizedCommand: string, -): Record< - AgentPlatform, - { - title: string; - description: string; - snippets: { label: string; command: string; note?: string | any }[]; - } -> => ({ - linux: { - title: 'Install on Linux', - description: - 'The unified installer downloads the agent binary and configures the appropriate service for your system.', - snippets: [ - { - label: 'Install', - command: unixCommand, - note: ( - - Command auto-escalates with sudo when available; otherwise run from a root - shell (for example su -). Auto-detects your init system and works on - Debian, Ubuntu, Proxmox, Fedora, Alpine, Unraid, Synology, and more. - - ), - }, - ], - }, - macos: { - title: 'Install on macOS', - description: - 'The unified installer downloads the universal binary and sets up a launchd service for background monitoring.', - snippets: [ - { - label: 'Install with launchd', - command: unixCommand, - note: ( - - Command auto-escalates with sudo when available; otherwise run from a root - shell. Creates /Library/LaunchDaemons/com.pulse.agent.plist and starts the - agent automatically. - - ), - }, - ], - }, - freebsd: { - title: 'Install on FreeBSD / pfSense / OPNsense', - description: - 'The unified installer downloads the FreeBSD binary and sets up an rc.d service for background monitoring.', - snippets: [ - { - label: 'Install with rc.d', - command: unixCommand, - note: ( - - Run as root. Note: pfSense/OPNsense don't include bash by default. - Install it first: pkg install bash. Creates{' '} - /usr/local/etc/rc.d/pulse-agent and starts the agent automatically. - - ), - }, - ], - }, - windows: { - title: 'Install on Windows', - description: - 'Run the PowerShell script to install and configure the unified agent as a Windows service with automatic startup.', - snippets: [ - { - label: 'Install as Windows Service (PowerShell)', - command: windowsInteractiveCommand, - note: ( - - Run in PowerShell as Administrator. The script will prompt for the Pulse URL and API - token, download the agent binary, and install it as a Windows service with automatic - startup. - - ), - }, - { - label: 'Install with parameters (PowerShell)', - command: windowsParameterizedCommand, - note: ( - - Non-interactive installation. Set environment variables before running to skip prompts. - - ), - }, - ], - }, -}); - -const agentCapabilityFromSurfaceKind = (kind: ConnectedInfrastructureSurface['kind']): AgentCapability => { - switch (kind) { - case 'agent': - case 'docker': - case 'kubernetes': - case 'proxmox': - case 'pbs': - case 'pmg': - return kind; - default: - return 'agent'; - } -}; - -const installFlagsForCapabilities = (capabilities: AgentCapability[]) => { - const flags = new Set(); - if (capabilities.includes('docker')) { - flags.add('--enable-docker'); - flags.add('--disable-host'); - } - if (capabilities.includes('kubernetes')) { - flags.add('--enable-kubernetes'); - } - if (capabilities.includes('proxmox')) { - flags.add('--enable-proxmox'); - flags.add('--proxmox-type pve'); - } else if (capabilities.includes('pbs')) { - flags.add('--enable-proxmox'); - flags.add('--proxmox-type pbs'); - } - return Array.from(flags); -}; - -const surfaceBreakdownFromConnectedSurface = (surface: ConnectedInfrastructureSurface) => ({ - key: surface.kind, - kind: agentCapabilityFromSurfaceKind(surface.kind), - label: surface.label || getCapabilitySurfaceLabel(agentCapabilityFromSurfaceKind(surface.kind)), - detail: surface.detail || '', - idLabel: surface.idLabel, - idValue: surface.idValue, - action: surface.action, - controlId: surface.controlId, -}); - -const rowFromConnectedInfrastructureItem = ( - item: ConnectedInfrastructureItem, - scope: UnifiedAgentRow['scope'], -): UnifiedAgentRow => { - const surfaces = item.surfaces.map(surfaceBreakdownFromConnectedSurface); - const capabilities = Array.from(new Set(surfaces.map((surface) => surface.kind))); - const agentSurface = item.surfaces.find((surface) => surface.kind === 'agent'); - const dockerSurface = item.surfaces.find((surface) => surface.kind === 'docker'); - const kubernetesSurface = item.surfaces.find((surface) => surface.kind === 'kubernetes'); - const name = item.name || item.displayName || item.hostname || item.id; - const rowKey = - item.status === 'ignored' - ? item.surfaces[0]?.kind === 'docker' - ? `removed-docker-${item.id}` - : item.surfaces[0]?.kind === 'kubernetes' - ? `removed-k8s-${item.id}` - : `removed-host-${item.id}` - : kubernetesSurface && !agentSurface && !dockerSurface - ? `k8s-${kubernetesSurface.controlId || item.id}` - : `agent-${item.id}`; - return { - rowKey, - id: item.id, - agentActionId: item.uninstallAgentId || agentSurface?.controlId, - dockerActionId: dockerSurface?.controlId, - kubernetesActionId: kubernetesSurface?.controlId, - name, - hostname: item.hostname, - displayName: item.displayName, - capabilities, - status: item.status === 'ignored' ? 'removed' : 'active', - healthStatus: item.healthStatus, - lastSeen: item.lastSeen, - removedAt: item.removedAt, - version: item.version, - isOutdatedBinary: item.isOutdatedBinary, - linkedNodeId: item.linkedNodeId, - commandsEnabled: item.commandsEnabled, - agentId: item.scopeAgentId || item.uninstallAgentId, - upgradePlatform: item.upgradePlatform || 'linux', - scope, - installFlags: installFlagsForCapabilities(capabilities), - searchText: [ - name, - item.displayName, - item.hostname, - item.id, - item.scopeAgentId, - item.uninstallAgentId, - agentSurface?.controlId, - dockerSurface?.controlId, - kubernetesSurface?.controlId, - ] - .filter(Boolean) - .join(' ') - .toLowerCase(), - kubernetesInfo: - kubernetesSurface || capabilities.includes('kubernetes') - ? { - server: undefined, - context: undefined, - tokenName: undefined, - } - : undefined, - surfaces, - }; -}; - -export interface InfrastructureOperationsControllerProps { - embedded?: boolean; +export interface InfrastructureOperationsControllerProps extends InfrastructureOperationsStateOptions { showInstaller?: boolean; showInventory?: boolean; } @@ -601,3050 +13,13 @@ export interface InfrastructureOperationsControllerProps { export const InfrastructureOperationsController: Component< InfrastructureOperationsControllerProps > = (props) => { - const { state } = useWebSocket(); - const { resources, mutate: mutateResources, refetch: refetchResources } = useResources(); - const navigate = useNavigate(); - - const pd = (r: Resource) => { - const platformData = getPlatformDataRecord(r); - return platformData ? unwrap(platformData) : undefined; - }; - const asRecord = (value: unknown) => - value && typeof value === 'object' ? (value as Record) : undefined; - const asBoolean = (value: unknown) => (typeof value === 'boolean' ? value : undefined); - const platformAgent = (r: Resource) => asRecord(getPlatformAgentRecord(r)); - - const getAgentId = (r: Resource) => getExplicitAgentIdFromResource(r); - - const getAgentActionId = (r: Resource) => getActionableAgentIdFromResource(r); - - const getDockerActionId = (r: Resource) => getActionableDockerRuntimeIdFromResource(r); - - const getKubernetesActionId = (r: Resource) => getActionableKubernetesClusterIdFromResource(r); - - const getCommandsEnabled = (r: Resource) => { - const platformData = pd(r); - return ( - r.agent?.commandsEnabled ?? - asBoolean(platformAgent(r)?.commandsEnabled) ?? - asBoolean(platformData?.commandsEnabled) - ); - }; - - let hasLoggedSecurityStatusError = false; - - const [securityStatus, setSecurityStatus] = createSignal(null); - const [latestRecord, setLatestRecord] = createSignal(null); - const [tokenName, setTokenName] = createSignal(''); - const [confirmedNoToken, setConfirmedNoToken] = createSignal(false); - const [currentToken, setCurrentToken] = createSignal(null); - const [isGeneratingToken, setIsGeneratingToken] = createSignal(false); - const [lookupValue, setLookupValue] = createSignal(''); - const [lookupResult, setLookupResult] = createSignal(null); - const [lookupError, setLookupError] = createSignal(null); - const [lookupLoading, setLookupLoading] = createSignal(false); - const [insecureMode, setInsecureMode] = createSignal(false); // For self-signed certificates (issue #806) - const [enableCommands, setEnableCommands] = createSignal(false); // Enable Pulse command execution (issue #903) - const [installProfile, setInstallProfile] = createSignal('auto'); - const [customAgentUrl, setCustomAgentUrl] = createSignal(''); - const [customCaPath, setCustomCaPath] = createSignal(''); - const [setupHandoff, setSetupHandoff] = createSignal(null); - const [profiles, setProfiles] = createSignal([]); - const [assignments, setAssignments] = createSignal([]); - // Track pending command config changes: agentId -> { desired value, timestamp } - const [pendingCommandConfig, setPendingCommandConfig] = createSignal< - Record - >({}); - const [pendingScopeUpdates, setPendingScopeUpdates] = createSignal>({}); - const [pendingInventoryActions, setPendingInventoryActions] = createSignal< - Record - >({}); - const [inventoryActionNotice, setInventoryActionNotice] = - createSignal(null); - const [optimisticRemovedHostAgents, setOptimisticRemovedHostAgents] = createSignal< - RemovedHostAgent[] - >([]); - const [optimisticRemovedDockerHosts, setOptimisticRemovedDockerHosts] = createSignal< - RemovedDockerHost[] - >([]); - const [optimisticRemovedKubernetesClusters, setOptimisticRemovedKubernetesClusters] = - createSignal([]); - const [stopMonitoringDialog, setStopMonitoringDialog] = - createSignal(null); - const [expandedRowKey, setExpandedRowKey] = createSignal(null); - const [selectedIgnoredRowKey, setSelectedIgnoredRowKey] = createSignal(null); - const [filterCapability, setFilterCapability] = createSignal<'all' | AgentCapability>('all'); - const [filterScope, setFilterScope] = createSignal<'all' | Exclude>('all'); - const [filterSearch, setFilterSearch] = createSignal(''); - let recoveryQueueSectionRef: HTMLDivElement | undefined; - - const loadAgentProfiles = async () => { - try { - const [profilesData, assignmentsData] = await Promise.all([ - AgentProfilesAPI.listProfiles(), - AgentProfilesAPI.listAssignments(), - ]); - setProfiles(profilesData); - setAssignments(assignmentsData); - } catch (err) { - logger.debug('Failed to load agent profiles', err); - notificationStore.error(err instanceof Error ? err.message : 'Failed to load agent profiles'); - } - }; - - createEffect(() => { - if (requiresToken()) { - setConfirmedNoToken(false); - } - }); - - // Use agentUrl from API (PULSE_PUBLIC_URL) if configured, otherwise fall back to window.location - const agentUrl = () => securityStatus()?.agentUrl || getPulseBaseUrl(); - const selectedAgentUrl = () => resolveInstallerBaseUrl(customAgentUrl(), agentUrl()); - - onMount(() => { - if (typeof window === 'undefined') { - return; - } - - const rawSetupHandoff = sessionStorage.getItem(STORAGE_KEYS.SETUP_HANDOFF); - if (rawSetupHandoff) { - try { - const parsed = JSON.parse(rawSetupHandoff) as Partial; - if (parsed.username && parsed.password && parsed.apiToken) { - setSetupHandoff({ - username: parsed.username, - password: parsed.password, - apiToken: parsed.apiToken, - createdAt: parsed.createdAt, - }); - } else { - sessionStorage.removeItem(STORAGE_KEYS.SETUP_HANDOFF); - } - } catch (_err) { - sessionStorage.removeItem(STORAGE_KEYS.SETUP_HANDOFF); - } - } - - const fetchSecurityStatus = async () => { - try { - const data = await SecurityAPI.getStatus(); - setSecurityStatus(data); - } catch (err) { - if (!hasLoggedSecurityStatusError) { - hasLoggedSecurityStatusError = true; - logger.error('Failed to load security status', err); - } - } - }; - fetchSecurityStatus(); - void loadAgentProfiles(); - }); - - const clearSetupHandoff = () => { - if (typeof window !== 'undefined') { - try { - sessionStorage.removeItem(STORAGE_KEYS.SETUP_HANDOFF); - } catch (_err) { - // Ignore storage cleanup failures. - } - } - setSetupHandoff(null); - }; - - const copySetupHandoffField = async (value: string, successMessage: string) => { - const copied = await copyToClipboard(value); - if (copied) { - notificationStore.success(successMessage); - } else { - notificationStore.error(getUnifiedAgentClipboardCopyErrorMessage()); - } - }; - - const downloadSetupHandoff = () => { - const handoff = setupHandoff(); - if (!handoff) return; - - const baseUrl = getPulseBaseUrl(); - const content = `Pulse First-Run Credentials -============================ -Generated: ${handoff.createdAt || new Date().toISOString()} - -Web Login: ----------- -URL: ${baseUrl} -Username: ${handoff.username} -Password: ${handoff.password} - -Admin API Token: ----------------- -${handoff.apiToken} - -Canonical install workspace: ----------------------------- -${baseUrl.replace(/\/$/, '')}/settings/infrastructure/install - -Generate a scoped install token below before copying Unified Agent install commands. -`; - - const blob = new Blob([content], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `pulse-first-run-credentials-${Date.now()}.txt`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - }; - - const requiresToken = () => { - const status = securityStatus(); - if (status) { - return status.requiresAuth || status.apiTokenConfigured; - } - return true; - }; - - const hasToken = () => Boolean(currentToken()); - const commandsUnlocked = () => (requiresToken() ? hasToken() : hasToken() || confirmedNoToken()); - - const acknowledgeNoToken = () => { - if (requiresToken()) { - notificationStore.info('Generate or select a token before continuing.', 4000); - return; - } - setCurrentToken(null); - setLatestRecord(null); - setConfirmedNoToken(true); - notificationStore.success('Confirmed install commands without an API token.', 3500); - }; - - const handleGenerateToken = async () => { - if (isGeneratingToken()) return; - - setIsGeneratingToken(true); - try { - const desiredName = tokenName().trim() || buildDefaultTokenName(); - // Generate token with unified agent reporting scopes - const scopes = [ - AGENT_REPORT_SCOPE, - AGENT_CONFIG_READ_SCOPE, - DOCKER_REPORT_SCOPE, - KUBERNETES_REPORT_SCOPE, - AGENT_EXEC_SCOPE, - ]; - const { token, record } = await SecurityAPI.createToken(desiredName, scopes); - - setCurrentToken(token); - setLatestRecord(record); - setTokenName(''); - setConfirmedNoToken(false); - trackAgentInstallTokenGenerated(UNIFIED_AGENT_TELEMETRY_SURFACE, 'manual'); - notificationStore.success( - 'Token generated with Agent config + reporting, Docker, and Kubernetes permissions.', - 4000, - ); - } catch (err) { - logger.error('Failed to generate agent token', err); - notificationStore.error( - 'Failed to generate agent token. Confirm you are signed in as an administrator.', - 6000, - ); - } finally { - setIsGeneratingToken(false); - } - }; - - const resolvedCommandToken = () => { - if (requiresToken()) { - return currentToken() || TOKEN_PLACEHOLDER; - } - return currentToken(); - }; - - const handleLookup = async () => { - const query = lookupValue().trim(); - setLookupError(null); - - if (!query) { - setLookupResult(null); - setLookupError('Enter a hostname or agent ID to check.'); - return; - } - - setLookupLoading(true); - try { - const result = await MonitoringAPI.lookupAgent({ id: query, hostname: query }); - if (!result) { - setLookupResult(null); - setLookupError(`No agent has reported with "${query}" yet. Try again in a few seconds.`); - } else { - setLookupResult(result); - setLookupError(null); - } - } catch (err) { - const message = err instanceof Error ? err.message : 'Agent lookup failed.'; - setLookupResult(null); - setLookupError(message); - } finally { - setLookupLoading(false); - } - }; - - const withPrivilegeEscalation = (command: string) => { - if (!command.includes('| bash -s --')) return command; - return command.replace(/\|\s*bash -s --([\s\S]*)$/, (_match, args: string) => { - return `| { if [ "$(id -u)" -eq 0 ]; then bash -s --${args}; elif command -v sudo >/dev/null 2>&1; then sudo bash -s --${args}; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }`; - }); - }; - const selectedCustomCaPath = () => customCaPath().trim(); - const urlRequiresInstallerInsecure = (url: string) => - insecureMode() || url.trim().toLowerCase().startsWith('http://'); - const getInsecureFlag = (url: string) => (urlRequiresInstallerInsecure(url) ? ' --insecure' : ''); - const getCurlFlags = () => (insecureMode() ? '-kfsSL' : '-fsSL'); - const getShellCustomCaCurlFlag = () => { - const caPath = selectedCustomCaPath(); - return caPath ? ` --cacert ${shellQuoteArg(caPath)}` : ''; - }; - const getShellCustomCaInstallerFlag = () => { - const caPath = selectedCustomCaPath(); - return caPath ? ` --cacert ${shellQuoteArg(caPath)}` : ''; - }; - const getSelectedInstallProfile = () => - INSTALL_PROFILE_OPTIONS.find((option) => option.value === installProfile()) ?? - INSTALL_PROFILE_OPTIONS[0]; - const getInstallProfileFlags = () => getSelectedInstallProfile().flags; - const getPowerShellInstallProfileEnvFromFlags = (flags: string[]) => { - const envAssignments: string[] = []; - for (let index = 0; index < flags.length; index += 1) { - const flag = flags[index]; - switch (flag) { - case '--enable-docker': - envAssignments.push(`$env:PULSE_ENABLE_DOCKER="true"`); - break; - case '--disable-host': - envAssignments.push(`$env:PULSE_ENABLE_HOST="false"`); - break; - case '--enable-kubernetes': - envAssignments.push(`$env:PULSE_ENABLE_KUBERNETES="true"`); - break; - case '--enable-proxmox': - envAssignments.push(`$env:PULSE_ENABLE_PROXMOX="true"`); - break; - case '--proxmox-type': - if (typeof flags[index + 1] === 'string' && flags[index + 1].trim()) { - envAssignments.push(`$env:PULSE_PROXMOX_TYPE="${flags[index + 1].trim()}"`); - index += 1; - } - break; - default: - if (flag.startsWith('--proxmox-type ')) { - const proxmoxType = flag.slice('--proxmox-type '.length).trim(); - if (proxmoxType) { - envAssignments.push(`$env:PULSE_PROXMOX_TYPE="${proxmoxType}"`); - } - } - break; - } - } - return envAssignments; - }; - const getPowerShellInstallProfileEnv = () => - getPowerShellInstallProfileEnvFromFlags(getInstallProfileFlags()); - const getPowerShellTransportEnv = () => { - const envAssignments: string[] = []; - if (insecureMode()) { - envAssignments.push(`$env:PULSE_INSECURE_SKIP_VERIFY="true"`); - } - if (selectedCustomCaPath()) { - envAssignments.push(`$env:PULSE_CACERT="${powerShellQuote(selectedCustomCaPath())}"`); - } - return envAssignments; - }; - const getPowerShellModeEnv = () => { - const envAssignments = getPowerShellTransportEnv(); - if (enableCommands()) { - envAssignments.push(`$env:PULSE_ENABLE_COMMANDS="true"`); - } - return envAssignments; - }; - const getPowerShellInstallEnv = () => { - const envAssignments = getPowerShellInstallProfileEnv(); - if (enableCommands()) { - envAssignments.push(`$env:PULSE_ENABLE_COMMANDS="true"`); - } - return envAssignments; - }; - const getInstallerExtraArgs = () => [ - ...getInstallProfileFlags(), - ...(enableCommands() ? ['--enable-commands'] : []), - ]; - const handleInstallProfileChange = (profile: InstallProfile) => { - setInstallProfile(profile); - trackAgentInstallProfileSelected(UNIFIED_AGENT_TELEMETRY_SURFACE, profile); - }; - - const getCanonicalUninstallAgentId = (row?: UnifiedAgentRow) => - row?.agentActionId?.trim() || row?.agentId?.trim() || ''; - const getCanonicalUninstallHostname = (row?: UnifiedAgentRow) => row?.hostname?.trim() || ''; - - const getUninstallCommand = (row?: UnifiedAgentRow) => { - const url = selectedAgentUrl(); - const token = resolvedCommandToken(); - const insecure = getInsecureFlag(url); - const agentId = getCanonicalUninstallAgentId(row); - const hostname = getCanonicalUninstallHostname(row); - const baseArgs = token - ? `--uninstall --url ${shellQuoteArg(url)} --token ${shellQuoteArg(token)}${insecure}${getShellCustomCaInstallerFlag()}` - : `--uninstall --url ${shellQuoteArg(url)}${insecure}${getShellCustomCaInstallerFlag()}`; - const identityArgs = `${agentId ? ` --agent-id ${shellQuoteArg(agentId)}` : ''}${hostname ? ` --hostname ${shellQuoteArg(hostname)}` : ''}`; - return withPrivilegeEscalation( - `curl ${getCurlFlags()}${getShellCustomCaCurlFlag()} ${shellQuoteArg(`${url}/install.sh`)} | bash -s -- ${baseArgs}${identityArgs}`, - ); - }; - - const getWindowsUninstallCommand = (row?: UnifiedAgentRow) => { - const url = selectedAgentUrl(); - const token = resolvedCommandToken(); - const transportEnv = getPowerShellTransportEnv(); - const agentId = getCanonicalUninstallAgentId(row); - const hostname = getCanonicalUninstallHostname(row); - const identityEnv: string[] = []; - if (agentId) { - identityEnv.push(`$env:PULSE_AGENT_ID="${powerShellQuote(agentId)}"`); - } - if (hostname) { - identityEnv.push(`$env:PULSE_HOSTNAME="${powerShellQuote(hostname)}"`); - } - const prefixParts = [...transportEnv, ...identityEnv]; - const prefix = prefixParts.length > 0 ? `${prefixParts.join('; ')}; ` : ''; - // Include URL and token for server notification (removes agent from dashboard) - if (token) { - return `${prefix}$env:PULSE_URL="${powerShellQuote(url)}"; $env:PULSE_TOKEN="${powerShellQuote(token)}"; $env:PULSE_UNINSTALL="true"; ${buildPowerShellInstallScriptBootstrap(url)}`; - } - return `${prefix}$env:PULSE_URL="${powerShellQuote(url)}"; $env:PULSE_UNINSTALL="true"; ${buildPowerShellInstallScriptBootstrap(url)}`; - }; - - const getPlatformUninstallCommand = (platform: AgentPlatform, row?: UnifiedAgentRow) => { - if (platform === 'windows') { - return getWindowsUninstallCommand(row); - } - return getUninstallCommand(row); - }; - - const commandSections = createMemo(() => { - const url = selectedAgentUrl(); - const token = resolvedCommandToken(); - const unixCommand = buildUnixAgentInstallCommand({ - baseUrl: url, - token, - insecure: insecureMode(), - caCertPath: selectedCustomCaPath(), - extraArgs: getInstallerExtraArgs(), - }); - const windowsInteractiveCommand = buildWindowsAgentInstallCommand({ - baseUrl: url, - token: currentToken(), - insecure: insecureMode(), - caCertPath: selectedCustomCaPath(), - extraEnvAssignments: getPowerShellInstallEnv(), - }); - const windowsParameterizedCommand = buildWindowsAgentInstallCommand({ - baseUrl: url, - token, - insecure: insecureMode(), - caCertPath: selectedCustomCaPath(), - extraEnvAssignments: getPowerShellInstallEnv(), - }); - const commands = buildCommandsByPlatform( - unixCommand, - windowsInteractiveCommand, - windowsParameterizedCommand, - ); - return Object.entries(commands).map(([platform, meta]) => ({ - platform: platform as AgentPlatform, - ...meta, - })); - }); - - const matchesRemovedAgent = ( - resource: Resource, - ids: { agentId?: string; dockerId?: string }, - capabilities: AgentCapability[], - ) => { - if (capabilities.includes('agent') && ids.agentId) { - const agentId = ids.agentId; - if ( - resource.id === agentId || - getAgentId(resource) === agentId || - getAgentActionId(resource) === agentId - ) { - return true; - } - } - - if (capabilities.includes('docker') && ids.dockerId) { - const dockerId = ids.dockerId; - if (resource.id === dockerId || getDockerActionId(resource) === dockerId) { - return true; - } - } - - return false; - }; - - const reconcileRemovedAgent = ( - ids: { agentId?: string; dockerId?: string }, - capabilities: AgentCapability[], - row: UnifiedAgentRow, - ) => { - const removedAt = Date.now(); - if (capabilities.includes('agent') && ids.agentId) { - setOptimisticRemovedHostAgents((prev) => [ - { - id: ids.agentId!, - hostname: row.hostname, - displayName: row.displayName || row.name, - removedAt, - }, - ...prev.filter((item) => item.id !== ids.agentId), - ]); - } - if (capabilities.includes('docker') && ids.dockerId) { - setOptimisticRemovedDockerHosts((prev) => [ - { - id: ids.dockerId!, - hostname: row.hostname, - displayName: row.displayName || row.name, - removedAt, - }, - ...prev.filter((item) => item.id !== ids.dockerId), - ]); - } - mutateResources((prev) => - prev.filter((resource) => !matchesRemovedAgent(resource, ids, capabilities)), - ); - void refetchResources().catch((err) => { - logger.debug('Failed to refresh resources after agent removal', err); - }); - }; - - const reconcileRemovedKubernetesCluster = (clusterId: string, clusterName?: string) => { - setOptimisticRemovedKubernetesClusters((prev) => [ - { - id: clusterId, - name: clusterName || clusterId, - displayName: clusterName, - removedAt: Date.now(), - }, - ...prev.filter((item) => item.id !== clusterId), - ]); - mutateResources((prev) => - prev.filter( - (resource) => - !( - resource.type === 'k8s-cluster' && - (getKubernetesActionId(resource) === clusterId || resource.id === clusterId) - ), - ), - ); - void refetchResources().catch((err) => { - logger.debug('Failed to refresh resources after kubernetes removal', err); - }); - }; - - /** - * All resources managed by an agent. - * In v6, the backend already merges resources by identity — a PVE node with a - * linked agent is a single resource of type "agent" with agent + proxmox data. - * No frontend merge logic or type-flapping prevention needed. - * - * Includes docker-host resources that may lack the `agent` facet when the - * agent's docker data is represented as a separate resource type. - */ - const agentResources = createMemo(() => { - return resources() - .filter((r) => r.type !== 'k8s-cluster' && (hasAgentFacet(r) || hasDockerWorkloadsScope(r))) - .sort((a, b) => - (getPreferredResourceHostname(a) || '').localeCompare( - getPreferredResourceHostname(b) || '', - ), - ); - }); - - const agentByActionId = createMemo(() => { - const map = new Map(); - // Only include resources with an agent facet (not docker-only resources) - // to avoid polluting the command config sync lookup. - for (const agentResource of agentResources()) { - if (!hasAgentFacet(agentResource)) continue; - const actionId = getAgentActionId(agentResource); - if (!actionId || map.has(actionId)) continue; - map.set(actionId, agentResource); - } - return map; - }); - - const profileById = createMemo(() => { - const map = new Map(); - for (const profile of profiles()) { - map.set(profile.id, profile); - } - return map; - }); - - const assignmentByAgent = createMemo(() => { - const map = new Map(); - for (const assignment of assignments()) { - map.set(assignment.agent_id, assignment); - } - return map; - }); - - const getScopeInfo = (agentId: string | undefined) => { - if (!agentId) { - return { label: 'N/A', detail: '', category: 'na' as const }; - } - const assignment = assignmentByAgent().get(agentId); - if (!assignment) { - return { label: 'Default', detail: 'Auto-detect', category: 'default' as const }; - } - const profile = profileById().get(assignment.profile_id); - if (!profile) { - return { - label: 'Profile assigned', - detail: assignment.profile_id, - category: 'profile' as const, - }; - } - const name = profile.name || assignment.profile_id; - const isAIManaged = - profile.description?.toLowerCase().includes('pulse ai') || - name.toLowerCase().startsWith('ai scope'); - return isAIManaged - ? { label: 'Patrol-managed', detail: name, category: 'ai-managed' as const } - : { label: name, detail: 'Assigned profile', category: 'profile' as const }; - }; - - const getProfileOptionLabel = (profileId: string) => { - const profile = profileById().get(profileId); - if (profile) { - return profile.name || profile.id; - } - return `Missing profile (${profileId})`; - }; - - const updateScopeAssignment = async ( - agentId: string, - profileId: string | null, - agentName: string, - ) => { - if (!agentId) { - return; - } - if (pendingScopeUpdates()[agentId]) { - return; - } - - setPendingScopeUpdates((prev) => ({ ...prev, [agentId]: true })); - try { - if (profileId) { - await AgentProfilesAPI.assignProfile(agentId, profileId); - setAssignments((prev) => { - const updatedAt = new Date().toISOString(); - const next = prev.filter((a) => a.agent_id !== agentId); - next.push({ agent_id: agentId, profile_id: profileId, updated_at: updatedAt }); - return next; - }); - notificationStore.success( - `Scope updated for ${agentName}. Restart the agent to apply changes.`, - ); - } else { - await AgentProfilesAPI.unassignProfile(agentId); - setAssignments((prev) => prev.filter((a) => a.agent_id !== agentId)); - notificationStore.success( - `Scope reset for ${agentName}. Restart the agent to apply changes.`, - ); - } - } catch (err) { - logger.error('Failed to update agent scope', err); - if (err instanceof Error && err.message === MISSING_AGENT_PROFILE_ASSIGNMENT_MESSAGE) { - await loadAgentProfiles(); - } - notificationStore.error( - err instanceof Error && err.message ? err.message : 'Failed to update agent scope', - ); - } finally { - setPendingScopeUpdates((prev) => { - const next = { ...prev }; - delete next[agentId]; - return next; - }); - } - }; - - const handleResetScope = async (agentId: string, agentName: string) => { - if ( - !confirm( - `Reset scope for ${agentName}? This removes any assigned profile and reverts to auto-detect.`, - ) - ) - return; - await updateScopeAssignment(agentId, null, agentName); - }; - - const toggleAgentDetails = (rowKey: string) => { - setExpandedRowKey(rowKey); - }; - - const connectedInfrastructureItems = createMemo( - () => state.connectedInfrastructure, - ); - const showInstaller = () => props.showInstaller ?? true; - const showInventory = () => props.showInventory ?? true; - - const unifiedRows = createMemo(() => { - const rows: UnifiedAgentRow[] = []; - const optimisticHostIDs = new Set( - optimisticRemovedHostAgents() - .map((item) => item.id?.trim()) - .filter((id): id is string => Boolean(id)), - ); - const optimisticDockerIDs = new Set( - optimisticRemovedDockerHosts() - .map((item) => item.id?.trim()) - .filter((id): id is string => Boolean(id)), - ); - const optimisticKubernetesIDs = new Set( - optimisticRemovedKubernetesClusters() - .map((item) => item.id?.trim()) - .filter((id): id is string => Boolean(id)), - ); - - connectedInfrastructureItems().forEach((item) => { - const optimisticFilteredItem: ConnectedInfrastructureItem = - item.status === 'active' - ? { - ...item, - surfaces: item.surfaces.filter((surface) => { - const controlId = surface.controlId?.trim(); - if (!controlId) return true; - if (surface.kind === 'agent') return !optimisticHostIDs.has(controlId); - if (surface.kind === 'docker') return !optimisticDockerIDs.has(controlId); - if (surface.kind === 'kubernetes') return !optimisticKubernetesIDs.has(controlId); - return true; - }), - } - : item; - - if (optimisticFilteredItem.status === 'active' && optimisticFilteredItem.surfaces.length === 0) { - return; - } - - rows.push( - rowFromConnectedInfrastructureItem( - optimisticFilteredItem, - getScopeInfo(optimisticFilteredItem.scopeAgentId), - ), - ); - }); - - const backendIgnoredSurfaceKeys = new Set( - rows - .filter((row) => row.status === 'removed') - .flatMap((row) => - row.surfaces.map((surface) => `${surface.kind}:${surface.controlId || surface.idValue || row.id}`), - ), - ); - - optimisticRemovedDockerHosts().forEach((runtime) => { - const key = `docker:${runtime.id}`; - if (backendIgnoredSurfaceKeys.has(key)) return; - const name = getPreferredNamedEntityLabel(runtime); - rows.push({ - rowKey: `removed-docker-${runtime.id}`, - id: runtime.id, - dockerActionId: runtime.id, - name, - hostname: runtime.hostname, - displayName: runtime.displayName, - capabilities: ['docker'], - status: 'removed', - removedAt: runtime.removedAt, - upgradePlatform: 'linux', - scope: getScopeInfo(undefined), - installFlags: ['--enable-docker', '--disable-host'], - searchText: [name, runtime.hostname, runtime.id].filter(Boolean).join(' ').toLowerCase(), - surfaces: [ - { - key: 'docker', - kind: 'docker', - label: 'Docker runtime data', - detail: 'Pulse is blocking Docker runtime reports from this machine.', - idLabel: 'Docker runtime ID', - idValue: runtime.id, - action: 'allow-reconnect', - controlId: runtime.id, - }, - ], - }); - }); - - optimisticRemovedHostAgents().forEach((host) => { - const key = `agent:${host.id}`; - if (backendIgnoredSurfaceKeys.has(key)) return; - const name = getPreferredNamedEntityLabel(host); - rows.push({ - rowKey: `removed-host-${host.id}`, - id: host.id, - agentActionId: host.id, - name, - hostname: host.hostname, - displayName: host.displayName, - capabilities: ['agent'], - status: 'removed', - removedAt: host.removedAt, - upgradePlatform: 'linux', - scope: getScopeInfo(undefined), - installFlags: [], - searchText: [name, host.hostname, host.id].filter(Boolean).join(' ').toLowerCase(), - surfaces: [ - { - key: 'agent', - kind: 'agent', - label: 'Host telemetry', - detail: 'Pulse is blocking host telemetry from this machine.', - idLabel: 'Agent ID', - idValue: host.id, - action: 'allow-reconnect', - controlId: host.id, - }, - ], - }); - }); - - optimisticRemovedKubernetesClusters().forEach((cluster) => { - const key = `kubernetes:${cluster.id}`; - if (backendIgnoredSurfaceKeys.has(key)) return; - const name = getPreferredNamedEntityLabel(cluster); - rows.push({ - rowKey: `removed-k8s-${cluster.id}`, - id: cluster.id, - kubernetesActionId: cluster.id, - name, - capabilities: ['kubernetes'], - status: 'removed', - removedAt: cluster.removedAt, - upgradePlatform: 'linux', - scope: getScopeInfo(undefined), - installFlags: ['--enable-kubernetes'], - searchText: [name, cluster.name, cluster.id].filter(Boolean).join(' ').toLowerCase(), - surfaces: [ - { - key: 'kubernetes', - kind: 'kubernetes', - label: 'Kubernetes cluster data', - detail: 'Pulse is blocking Kubernetes telemetry for this cluster.', - idLabel: 'Cluster ID', - idValue: cluster.id, - action: 'allow-reconnect', - controlId: cluster.id, - }, - ], - }); - }); - - rows.sort((a, b) => { - if (a.status !== b.status) { - return a.status === 'active' ? -1 : 1; - } - return a.name.localeCompare(b.name); - }); - - return rows; - }); - - const matchesInventoryFilters = (row: UnifiedAgentRow) => { - const query = filterSearch().trim().toLowerCase(); - if ( - filterCapability() !== 'all' && - !row.capabilities.includes(filterCapability() as AgentCapability) - ) { - return false; - } - if (filterScope() !== 'all' && row.scope.category !== filterScope()) { - return false; - } - if (query && !row.searchText.includes(query)) { - return false; - } - return true; - }; - - const activeRows = createMemo(() => unifiedRows().filter((row) => row.status === 'active')); - const monitoringStoppedRows = createMemo(() => - unifiedRows().filter((row) => row.status === 'removed'), - ); - const linkedAgents = createMemo(() => - activeRows() - .filter((row) => Boolean(row.linkedNodeId)) - .map((row) => ({ - id: row.id, - hostname: row.hostname || row.name, - displayName: row.displayName, - linkedNodeId: row.linkedNodeId!, - status: row.healthStatus || 'online', - version: row.version, - lastSeen: row.lastSeen, - })), - ); - const hasLinkedAgents = createMemo(() => linkedAgents().length > 0); - const outdatedAgents = createMemo(() => activeRows().filter((row) => row.isOutdatedBinary)); - const hasOutdatedAgents = createMemo(() => outdatedAgents().length > 0); - - const filteredActiveRows = createMemo(() => activeRows().filter(matchesInventoryFilters)); - const filteredMonitoringStoppedRows = createMemo(() => - monitoringStoppedRows().filter(matchesInventoryFilters), - ); - const selectedActiveRow = createMemo(() => { - const selectedKey = expandedRowKey(); - return filteredActiveRows().find((row) => row.rowKey === selectedKey) || null; - }); - const selectedIgnoredRow = createMemo(() => { - const selectedKey = selectedIgnoredRowKey(); - return filteredMonitoringStoppedRows().find((row) => row.rowKey === selectedKey) || null; - }); - - const hasFilters = createMemo(() => { - return ( - filterCapability() !== 'all' || filterScope() !== 'all' || filterSearch().trim().length > 0 - ); - }); - - const hasMonitoringStoppedRows = createMemo(() => monitoringStoppedRows().length > 0); - const showMonitoringStoppedSection = createMemo(() => { - return hasMonitoringStoppedRows() || hasFilters(); - }); - const coverageSummary = createMemo(() => { - const active = activeRows(); - return { - agent: active.filter((row) => row.capabilities.includes('agent')).length, - docker: active.filter((row) => row.capabilities.includes('docker')).length, - kubernetes: active.filter((row) => row.capabilities.includes('kubernetes')).length, - proxmox: active.filter((row) => row.capabilities.includes('proxmox')).length, - pbs: active.filter((row) => row.capabilities.includes('pbs')).length, - pmg: active.filter((row) => row.capabilities.includes('pmg')).length, - }; - }); - - const reportingCoverageSummaryText = createMemo(() => { - const summary = coverageSummary(); - const activeClauses = [ - summary.agent > 0 ? `${summary.agent} host${summary.agent === 1 ? '' : 's'}` : null, - summary.docker > 0 - ? `${summary.docker} Docker runtime${summary.docker === 1 ? '' : 's'}` - : null, - summary.kubernetes > 0 - ? `${summary.kubernetes} Kubernetes cluster${summary.kubernetes === 1 ? '' : 's'}` - : null, - summary.proxmox > 0 - ? `${summary.proxmox} Proxmox node${summary.proxmox === 1 ? '' : 's'}` - : null, - summary.pbs > 0 ? `${summary.pbs} PBS server${summary.pbs === 1 ? '' : 's'}` : null, - summary.pmg > 0 ? `${summary.pmg} PMG server${summary.pmg === 1 ? '' : 's'}` : null, - ].filter((value): value is string => Boolean(value)); - - if (activeClauses.length === 0) { - return 'Pulse is not currently receiving live infrastructure reports.'; - } - - return `Pulse is currently receiving live reports from ${joinHumanList(activeClauses)}.`; - }); - - const inventoryStatusSummaryText = createMemo(() => { - const activeCount = filteredActiveRows().length; - const recoveryCount = filteredMonitoringStoppedRows().length; - const recoveryClause = - recoveryCount > 0 - ? `${recoveryCount} item${recoveryCount === 1 ? ' is' : 's are'} currently ignored by Pulse` - : 'nothing is currently ignored by Pulse'; - return `${activeCount} item${activeCount === 1 ? ' is' : 's are'} actively reporting right now, and ${recoveryClause}. Stopping monitoring in Pulse does not uninstall software on the remote system.`; - }); - - const resetFilters = () => { - setFilterCapability('all'); - setFilterScope('all'); - setFilterSearch(''); - }; - - const setInventoryActionPending = ( - rowKey: string, - action: InventoryActionType, - pending: boolean, - ) => { - setPendingInventoryActions((prev) => { - const next = { ...prev }; - if (pending) { - next[rowKey] = action; - } else { - delete next[rowKey]; - } - return next; - }); - }; - - const getPendingInventoryAction = (rowKey: string) => pendingInventoryActions()[rowKey]; - - const scrollToRecoveryQueue = () => { - recoveryQueueSectionRef?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }; - - const openStopMonitoringDialog = (row: UnifiedAgentRow) => { - setStopMonitoringDialog({ - row, - subject: getInventorySubjectLabel(row.name, 'this item'), - scopeLabel: getStopMonitoringScopeLabel(row), - }); - }; - - const getUpgradeCommand = (row: UnifiedAgentRow) => { - const token = resolvedCommandToken(); - const url = selectedAgentUrl(); - const agentId = getCanonicalUninstallAgentId(row); - const hostname = getCanonicalUninstallHostname(row); - if (row.upgradePlatform === 'windows') { - const envAssignments = [ - ...getPowerShellInstallProfileEnvFromFlags(row.installFlags), - ...getPowerShellModeEnv(), - ]; - if (agentId) { - envAssignments.push(`$env:PULSE_AGENT_ID="${powerShellQuote(agentId)}"`); - } - if (hostname) { - envAssignments.push(`$env:PULSE_HOSTNAME="${powerShellQuote(hostname)}"`); - } - const prefix = envAssignments.length > 0 ? `${envAssignments.join('; ')}; ` : ''; - const tokenEnv = token ? `$env:PULSE_TOKEN="${powerShellQuote(token)}"; ` : ''; - return `${prefix}$env:PULSE_URL="${powerShellQuote(url)}"; ${tokenEnv}${buildPowerShellInstallScriptBootstrap(url)}`; - } - let command = `curl ${getCurlFlags()}${getShellCustomCaCurlFlag()} ${shellQuoteArg(`${url}/install.sh`)} | bash -s -- --url ${shellQuoteArg(url)}`; - if (token) { - command += ` --token ${shellQuoteArg(token)}`; - } - if (row.installFlags.length > 0) { - command += ` ${row.installFlags.join(' ')}`; - } - if (urlRequiresInstallerInsecure(url)) { - command += getInsecureFlag(url); - } - command += getShellCustomCaInstallerFlag(); - if (agentId) { - command += ` --agent-id ${shellQuoteArg(agentId)}`; - } - if (hostname) { - command += ` --hostname ${shellQuoteArg(hostname)}`; - } - return withPrivilegeEscalation(command); - }; - - const handleRemoveAgent = async (row: UnifiedAgentRow) => { - const subject = getInventorySubjectLabel(row.name, 'this host'); - - setInventoryActionNotice(null); - setInventoryActionPending(row.rowKey, 'stop-monitoring', true); - try { - let removed = false; - // Remove the agent registration - if (row.capabilities.includes('agent') && row.agentActionId) { - await MonitoringAPI.deleteAgent(row.agentActionId); - removed = true; - } - // Remove docker runtime registration if present - if (row.capabilities.includes('docker') && row.dockerActionId) { - await MonitoringAPI.deleteDockerRuntime(row.dockerActionId, { force: true }); - removed = true; - } - if (removed) { - reconcileRemovedAgent( - { agentId: row.agentActionId, dockerId: row.dockerActionId }, - row.capabilities, - row, - ); - setInventoryActionNotice({ - tone: 'success', - title: `Monitoring stopped for ${subject}`, - detail: - 'Pulse removed it from active reporting and will ignore new reports until you allow reconnect. You can review it in Ignored by Pulse below.', - showRecoveryQueueLink: true, - }); - notificationStore.success(getUnifiedAgentStopMonitoringSuccessMessage(subject)); - } else { - notificationStore.error(getUnifiedAgentStopMonitoringUnavailableMessage()); - } - } catch (err) { - logger.error('Failed to stop monitoring host', err); - notificationStore.error(getUnifiedAgentStopMonitoringErrorMessage(subject)); - } finally { - setInventoryActionPending(row.rowKey, 'stop-monitoring', false); - setStopMonitoringDialog(null); - } - }; - - const handleAllowHostReconnect = async (row: UnifiedAgentRow) => { - const agentId = row.agentActionId || row.id; - const subject = getInventorySubjectLabel(row.displayName || row.hostname || row.name, agentId); - setInventoryActionNotice(null); - setInventoryActionPending(row.rowKey, 'allow-reconnect', true); - try { - await MonitoringAPI.allowHostAgentReenroll(agentId); - setOptimisticRemovedHostAgents((prev) => prev.filter((item) => item.id !== agentId)); - setInventoryActionNotice({ - tone: 'info', - title: `Reconnect allowed for ${subject}`, - detail: 'Pulse will accept reports from it again the next time it checks in.', - }); - notificationStore.success(getUnifiedAgentAllowReconnectSuccessMessage(subject)); - } catch (err) { - logger.error('Failed to allow reconnect for host agent', err); - notificationStore.error(getUnifiedAgentAllowReconnectErrorMessage(subject)); - } finally { - setInventoryActionPending(row.rowKey, 'allow-reconnect', false); - } - }; - - const handleAllowDockerReconnect = async (row: UnifiedAgentRow) => { - const agentId = row.dockerActionId || row.id; - const subject = getInventorySubjectLabel(row.displayName || row.hostname || row.name, agentId); - setInventoryActionNotice(null); - setInventoryActionPending(row.rowKey, 'allow-reconnect', true); - try { - await MonitoringAPI.allowDockerRuntimeReenroll(agentId); - setOptimisticRemovedDockerHosts((prev) => prev.filter((item) => item.id !== agentId)); - setInventoryActionNotice({ - tone: 'info', - title: `Reconnect allowed for ${subject}`, - detail: 'Pulse will accept reports from it again the next time it checks in.', - }); - notificationStore.success(getUnifiedAgentAllowReconnectSuccessMessage(subject)); - } catch (err) { - logger.error('Failed to allow reconnect for docker runtime', err); - notificationStore.error(getUnifiedAgentAllowReconnectErrorMessage(subject)); - } finally { - setInventoryActionPending(row.rowKey, 'allow-reconnect', false); - } - }; - - const handleRemoveKubernetesCluster = async (row: UnifiedAgentRow) => { - const clusterId = row.kubernetesActionId || row.id; - const subject = getInventorySubjectLabel(row.name, 'this cluster'); - - setInventoryActionNotice(null); - setInventoryActionPending(row.rowKey, 'stop-monitoring', true); - try { - await MonitoringAPI.deleteKubernetesCluster(clusterId); - reconcileRemovedKubernetesCluster(clusterId, row.name); - setInventoryActionNotice({ - tone: 'success', - title: `Monitoring stopped for ${subject}`, - detail: - 'Pulse removed it from active reporting and will ignore new reports until you allow reconnect. You can review it in Ignored by Pulse below.', - showRecoveryQueueLink: true, - }); - notificationStore.success(getUnifiedAgentStopMonitoringSuccessMessage(subject)); - } catch (err) { - logger.error('Failed to stop monitoring kubernetes cluster', err); - notificationStore.error(getUnifiedAgentStopMonitoringErrorMessage(subject)); - } finally { - setInventoryActionPending(row.rowKey, 'stop-monitoring', false); - setStopMonitoringDialog(null); - } - }; - - const handleAllowKubernetesReconnect = async (row: UnifiedAgentRow) => { - const clusterId = row.kubernetesActionId || row.id; - const subject = getInventorySubjectLabel(row.name, clusterId); - setInventoryActionNotice(null); - setInventoryActionPending(row.rowKey, 'allow-reconnect', true); - try { - await MonitoringAPI.allowKubernetesClusterReenroll(clusterId); - setOptimisticRemovedKubernetesClusters((prev) => - prev.filter((item) => item.id !== clusterId), - ); - setInventoryActionNotice({ - tone: 'info', - title: `Reconnect allowed for ${subject}`, - detail: 'Pulse will accept reports from it again the next time it checks in.', - }); - notificationStore.success(getUnifiedAgentAllowReconnectSuccessMessage(subject)); - } catch (err) { - logger.error('Failed to allow reconnect for kubernetes cluster', err); - notificationStore.error(getUnifiedAgentAllowReconnectErrorMessage(subject)); - } finally { - setInventoryActionPending(row.rowKey, 'allow-reconnect', false); - } - }; - - // Clear pending state when agent reports matching the expected value, or after timeout - createEffect(() => { - const pending = pendingCommandConfig(); - const agents = agentByActionId(); - const now = Date.now(); - const TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes - - // Check if any pending config now matches the reported state or has timed out - let updated = false; - const newPending = { ...pending }; - const timedOut: string[] = []; - - for (const agentId of Object.keys(pending)) { - const entry = pending[agentId]; - const agent = agents.get(agentId); - - const agentCommandsEnabled = agent ? getCommandsEnabled(agent) : undefined; - if ( - agent && - typeof agentCommandsEnabled === 'boolean' && - agentCommandsEnabled === entry.enabled - ) { - // Agent confirmed the change - delete newPending[agentId]; - updated = true; - } else if (now - entry.timestamp > TIMEOUT_MS) { - // Timed out waiting for agent - delete newPending[agentId]; - const agentLabel = agent ? agent.identity?.hostname || agent.name || agentId : agentId; - timedOut.push(agentLabel); - updated = true; - } - } - - if (updated) { - setPendingCommandConfig(newPending); - if (timedOut.length > 0) { - notificationStore.warning( - `Config sync timed out for ${timedOut.join(', ')}. Agent may be offline.`, - ); - } - } - }); - - createEffect(() => { - const rows = filteredActiveRows(); - const selectedKey = expandedRowKey(); - - if (rows.length === 0) { - if (selectedKey !== null) { - setExpandedRowKey(null); - } - return; - } - - if (selectedKey && !rows.some((row) => row.rowKey === selectedKey)) { - setExpandedRowKey(null); - } - }); - - createEffect(() => { - const rows = filteredMonitoringStoppedRows(); - const selectedKey = selectedIgnoredRowKey(); - - if (rows.length === 0) { - if (selectedKey !== null) { - setSelectedIgnoredRowKey(null); - } - return; - } - - if (selectedKey && !rows.some((row) => row.rowKey === selectedKey)) { - setSelectedIgnoredRowKey(null); - } - }); - - const reportingColumns = [ - { - key: 'name', - label: 'Name', - render: (row: UnifiedAgentRow) => { - const selected = () => expandedRowKey() === row.rowKey; - const agentName = row.displayName || row.hostname || row.name; - const reportingSummary = getRowReportingSummary(row); - return ( -
-
-
{row.name}
- -
{row.hostname}
-
- -
{reportingSummary}
-
-
- -
- ); - }, - }, - { - key: 'capabilities', - label: 'Reporting surfaces', - render: (row: UnifiedAgentRow) => ( -
- - {(cap) => ( -
- - {getCapabilitySurfaceLabel(cap)} - -
- )} -
-
- ), - }, - { - key: 'status', - label: 'Status', - render: (row: UnifiedAgentRow) => { - const statusPresentation = () => - getUnifiedAgentStatusPresentation(row.status, row.healthStatus); - return ( - - {statusPresentation().label} - - ); - }, - }, - { - key: 'lastSeen', - label: 'Last Seen', - render: (row: UnifiedAgentRow) => ( - - {getUnifiedAgentLastSeenLabel(row, MONITORING_STOPPED_STATUS_LABEL)} - - ), - }, - { - key: 'actions', - label: 'Actions', - align: 'right' as const, - render: (row: UnifiedAgentRow) => { - const isRemoved = () => row.status === 'removed'; - const isKubernetes = () => - row.capabilities.includes('kubernetes') && !row.capabilities.includes('agent'); - const pendingAction = () => getPendingInventoryAction(row.rowKey); - const isStopping = () => pendingAction() === 'stop-monitoring'; - const isAllowingReconnect = () => pendingAction() === 'allow-reconnect'; - const canRemove = () => { - const needsAgent = row.capabilities.includes('agent') && !row.agentActionId; - const needsDocker = - row.capabilities.includes('docker') && !row.dockerActionId && !row.agentActionId; - return !needsAgent && !needsDocker; - }; - return ( - openStopMonitoringDialog(row)} - disabled={!canRemove() || Boolean(pendingAction())} - title={!canRemove() ? 'Agent ID unavailable for removal' : undefined} - class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-2.5 py-1.5 text-sm font-medium text-red-600 hover:bg-red-50 hover:text-red-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-red-400 dark:hover:bg-red-900 dark:hover:text-red-300" - > - {isStopping() ? 'Stopping…' : 'Stop monitoring'} - - } - > - - - } - > - handleAllowHostReconnect(row)} - disabled={Boolean(pendingAction())} - class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-2.5 py-1.5 text-sm font-medium text-blue-600 hover:bg-blue-50 hover:text-blue-900 dark:text-blue-400 dark:hover:bg-blue-900 dark:hover:text-blue-300" - > - {isAllowingReconnect() - ? 'Allowing reconnect…' - : ALLOW_RECONNECT_LABEL} - - } - > - - - } - > - - - - ); - }, - }, - ]; - - const renderSelectedActiveRowDetails = ( - rowAccessor: () => UnifiedAgentRow, - ) => { - const row = () => rowAccessor(); - const isKubernetes = () => - row().capabilities.includes('kubernetes') && !row().capabilities.includes('agent'); - const resolvedAgentId = () => row().agentId || ''; - const assignment = () => - resolvedAgentId() ? assignmentByAgent().get(resolvedAgentId()) : undefined; - const isScopeUpdating = () => - resolvedAgentId() ? Boolean(pendingScopeUpdates()[resolvedAgentId()]) : false; - const agentName = () => row().displayName || row().hostname || row().name; - const surfaces = () => getRowSurfaceBreakdown(row()); - - const renderHeader = () => ( -
-
-
-
-
- Selected reporting item -
-
{row().name}
- -
{row().hostname}
-
-
- Use surface controls to stop specific reporting without removing the machine. -
-
-
- - {(cap) => ( - - {getAgentCapabilityLabel(cap)} - - )} - - - - Outdated - - - - - Linked - - -
-
- -
-
- ); - - const renderMachineOverview = () => ( -
-
Machine overview
-
-
-
- Item ID: {row().id} -
- -
- Agent ID: {row().agentActionId} -
-
- -
- Container Agent ID:{' '} - {row().dockerActionId} -
-
- -
- Cluster ID:{' '} - {row().kubernetesActionId} -
-
- -
- Reporting agent ID:{' '} - {row().agentId} -
-
- -
- Linked node ID:{' '} - {row().linkedNodeId} -
-
-
-
- -
- Last seen {formatRelativeTime(row().lastSeen!)} ( - {formatAbsoluteTime(row().lastSeen!)}) -
-
- -
-
Scope profile
- - {row().scope.label} - - } - > - 0} - fallback={ - - {row().scope.label} - - } - > -
- - - Updating… - -
-
- } - > - - {row().scope.label} - -
- -
-
- -
-
- Kubernetes connection -
- -
- Server:{' '} - {row().kubernetesInfo?.server} -
-
- -
- Context:{' '} - {row().kubernetesInfo?.context} -
-
- -
- Token:{' '} - {row().kubernetesInfo?.tokenName} -
-
-
-
-
-
- -
-
- Restart required to apply scope changes. -
- -
-
-
- ); - - const renderSurfaceControls = () => ( - 0}> -
-
-
- Surface controls -
-
- Stop a specific surface. Other surfaces keep reporting. -
-
-
-
-
Surface
-
What Pulse receives
-
ID
-
Control
-
- - {(surface) => ( -
-
{surface.label}
-
{surface.detail}
-
- Not separately addressed} - > -
-
{surface.idLabel}
-
{surface.idValue}
-
-
-
-
- Managed with host telemetry - } - > - - -
-
- )} -
-
-
-
- ); - - const renderMachineActions = () => ( -
-
Machine actions
-
Machine-level utilities.
-
- - - - - - -
- Use surface controls above to stop reporting without uninstalling. -
-
-
- ); - - return ( -
- {renderHeader()} -
- {renderMachineOverview()} - {renderSurfaceControls()} - {renderMachineActions()} -
-
- ); - }; - - const renderSelectedIgnoredRowDetails = ( - rowAccessor: () => UnifiedAgentRow, - ) => { - const row = () => rowAccessor(); - const pendingAction = () => getPendingInventoryAction(row().rowKey); - const isAllowingReconnect = () => pendingAction() === 'allow-reconnect'; - const reconnectLabel = () => getReconnectActionLabel(row()); - const blockedId = () => - row().dockerActionId || row().kubernetesActionId || row().agentActionId || row().id; - - const renderHeader = () => ( -
-
-
-
- Selected ignored item -
-
{row().name}
-
- Ignored by Pulse -
-
- Pulse is blocking reports from this surface. -
-
- -
-
- ); - - const renderIgnoredSurface = () => ( -
-
Ignored surface
-
-
-
Ignored surface
-
What Pulse is ignoring
-
ID
-
Recovery
-
-
-
- {row().capabilities.map(getCapabilitySurfaceLabel).join(', ')} -
-
- Pulse will ignore new reports for this surface until reconnect is allowed. -
-
- {blockedId()} -
-
Ready to return
-
-
-
- ); - - const renderRecoveryAction = () => ( -
-
Recovery action
-
Allow this blocked ID to report again.
-
- -
- This only changes Pulse. It does not reinstall software. -
-
-
- ); - - return ( -
- {renderHeader()} -
- {renderIgnoredSurface()} - {renderRecoveryAction()} -
-
- ); - }; - - const renderStopMonitoringDialog = () => ( - { - if (!stopMonitoringDialog()) return; - const row = stopMonitoringDialog()!.row; - if (getPendingInventoryAction(row.rowKey)) return; - setStopMonitoringDialog(null); - }} - panelClass="max-w-lg" - closeOnBackdrop={ - !stopMonitoringDialog() || !getPendingInventoryAction(stopMonitoringDialog()!.row.rowKey) - } - ariaLabel="Confirm stop monitoring" - > - - {(dialog) => { - const row = () => dialog().row; - const pending = () => getPendingInventoryAction(row().rowKey) === 'stop-monitoring'; - const isKubernetes = () => - row().capabilities.includes('kubernetes') && !row().capabilities.includes('agent'); - const affectedSurfaces = () => getStopMonitoringSurfaces(row()); - return ( -
-
-

Stop monitoring?

-

- Pulse will remove{' '} - {dialog().subject} from - active reporting. -

-
-
-
-

{dialog().scopeLabel} will stop in Pulse.

-

- The remote system keeps running. Pulse will ignore future reports and move this - item into Ignored by Pulse until you allow reconnect. -

-
- 0}> -
-

- Pulse will stop these reporting surfaces -

-
- - {(surface) => ( -
-
{surface.label}
-
{surface.detail}
- -
- {surface.idLabel}:{' '} - {surface.idValue} -
-
-
- )} -
-
-
-
-
-

What stays unchanged

-

- {isKubernetes() - ? 'The cluster itself is not uninstalled or shut down.' - : 'The host, containers, and installed agent binaries are not uninstalled or shut down.'} -

-
-
-
- - -
-
- ); - }} -
-
- ); - - const renderInstallerSection = () => ( - - } - bodyClass="space-y-5" - > - - {(handoff) => ( -
-
-
-

Security configured. Save these first-run credentials now.

-

- This is the canonical handoff from first-run setup into Infrastructure Install. - Generate a scoped install token below before copying agent commands. -

-
-
-
- Username -
-
{handoff().username}
-
-
-
- Password -
-
- {handoff().password} -
-
-
-
- Admin API Token -
-
- {handoff().apiToken} -
-
-
-
-
- - - - -
-
-
- )} -
- -
-

Unified Agent is the default monitoring gateway.

-

- Install it on each system you want Pulse to monitor. The installer auto-detects - available platforms on that machine and enables the right integrations. -

-
- - -
-
- -
-

- 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). -

- -
-
-
-
- -
-
-
-

- - 1 - - Generate API token -

-

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

-
- -
- setTokenName(event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key === 'Enter' && !isGeneratingToken()) { - 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" - /> - -
- - -
- - - - - Token {latestRecord()?.name} created. Commands below now - include this credential. - -
-
-
- - -
-
- Tokens are optional on this Pulse instance. Confirm to generate commands without - embedding a token. -
- -
-
- - -
-
-
-

- - 2 - - Installation commands -

-

- Generate a token above to unlock installation commands. -

-
-
-
- - - -

- Click "Generate token" above to see installation commands -

-
-
-
- - -
-
-
-
-

- - - 2 - - - Installation commands -

-

- The installer auto-detects Docker, Kubernetes, and Proxmox on the target - machine. -

-
-
- -
- -
- setCustomAgentUrl(e.currentTarget.value)} - placeholder={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" - /> -
-

- Override the address agents use to connect to this server (e.g., use IP - address http://192.0.2.50:7655 if DNS fails). - - - Currently using auto-detected: {agentUrl()} - - -

-
-
- -
- setCustomCaPath(e.currentTarget.value)} - placeholder={ - 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" - /> -
-

- Preserves custom trust for copied install, upgrade, and uninstall commands. - Shell commands pass --cacert to both the download and the - installer. Windows commands set PULSE_CACERT and use a - transport-aware PowerShell bootstrap for the initial script fetch. -

-
- -
- TLS verification disabled — skip cert checks - for self-signed setups. Not recommended for production. -
-
- - - -
- Pulse commands enabled — The agent will - accept diagnostic and fix commands from Pulse Patrol features. -
-
-
- Config signing (optional) — Require signed - remote config payloads with{' '} - PULSE_AGENT_CONFIG_SIGNATURE_REQUIRED=true. Provide keys via{' '} - PULSE_AGENT_CONFIG_SIGNING_KEY (Pulse) and{' '} - PULSE_AGENT_CONFIG_PUBLIC_KEYS (agents). -
-
- - -

- {getSelectedInstallProfile().description} -

- 0}> -

- Adds flags to shell-based install commands:{' '} - {getInstallProfileFlags().join(' ')} -

-
-
-
- -
- - {(section) => ( -
-
-
{section.title}
-

{section.description}

-
-
- - {(snippet) => { - const copyCommand = () => snippet.command; - const commandTelemetryCapability = () => { - const label = normalizeTelemetryPart(snippet.label) || 'install'; - return `${section.platform}:${installProfile()}:${label}`; - }; - - return ( -
-
- {snippet.label} -
-
- -
-                                    {copyCommand()}
-                                  
-
- -

{snippet.note}

-
-
- ); - }} -
-
-
- )} -
-
- -
-
-
Check installation status
- -
-

- Enter the hostname (or agent ID) from the machine you just installed. Pulse - returns the latest status instantly. -

-
- { - setLookupValue(event.currentTarget.value); - setLookupError(null); - setLookupResult(null); - }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - void 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" - /> -
- -

- {lookupError()} -

-
- - {(result) => { - const agent = () => result().agent!; - const lookupStatusPresentation = () => - getUnifiedAgentLookupStatusPresentation(agent().connected); - return ( -
-
-
- {agent().displayName || agent().hostname} -
-
- - {lookupStatusPresentation().label} - - - {agent().status || 'unknown'} - -
-
-
- Last seen {formatRelativeTime(agent().lastSeen)} ( - {formatAbsoluteTime(agent().lastSeen)}) -
- -
- Agent version {agent().agentVersion} -
-
-
- ); - }} -
-
-
- - Troubleshooting - -
-
-

- Auto-detection not working? -

-

- If Docker, Kubernetes, or Proxmox isn't detected automatically, add these - flags to the install command: -

-
    -
  • - --enable-docker — Force - enable Docker/Podman monitoring -
  • -
  • - --enable-kubernetes — - Force enable Kubernetes monitoring -
  • -
  • - --enable-proxmox — Force - enable Proxmox integration (creates API token) -
  • -
  • - --proxmox-type pve|pbs{' '} - — Set Proxmox node mode explicitly -
  • -
  • - --disable-docker — Skip - Docker even if detected -
  • -
-
-
-
-
-
- -
-
-

Uninstall agent

-

- Run the appropriate command on your machine to remove the Pulse agent: -

-
- Linux / macOS / FreeBSD -
- -
-                    {getUninstallCommand()}
-                  
-
-
-

- If the agent can't reach this server, run directly on the machine:{' '} - - sudo bash /var/lib/pulse-agent/install.sh --uninstall - {' '} - (TrueNAS:{' '} - - /data/pulse-agent/install.sh - - , Unraid:{' '} - - /boot/config/plugins/pulse-agent/install.sh - - ) -

-
- - Windows (PowerShell as Administrator) - -
- -
-                    {getWindowsUninstallCommand()}
-                  
-
-
-
-
-
-
-
- ); - - const renderIgnoredSection = () => ( - -
- } - bodyClass="space-y-4" - > - 0} - fallback={ -
- {getMonitoringStoppedEmptyState(hasFilters())} -
- } - > -
-
-
-
- Browse ignored items -
-
- Select an ignored item to open its recovery drawer. -
-
-
- - {(row) => { - const pendingAction = () => getPendingInventoryAction(row.rowKey); - const isSelected = () => selectedIgnoredRowKey() === row.rowKey; - return ( - - ); - }} - -
-
- - setSelectedIgnoredRowKey(null)} - layout="drawer-right" - panelClass="max-w-[720px]" - ariaLabel="Ignored item details" - > - - {(rowAccessor) => renderSelectedIgnoredRowDetails(rowAccessor)} - - -
-
-
-
-
- ); + const state = useInfrastructureOperationsState({ embedded: props.embedded }); return (
- {renderStopMonitoringDialog()} - {renderInstallerSection()} - - -
-
-

{inventoryStatusSummaryText()}

-
- - } - bodyClass="space-y-4" - > -
-

{reportingCoverageSummaryText()}

-

- This workspace does not list every asset Pulse has discovered. It focuses on - systems and runtimes that are actively checking in right now. -

-
- -
-

- Active reporting -

-

- {filteredActiveRows().length} -

-

- Item{filteredActiveRows().length === 1 ? '' : 's'} actively checking in to Pulse. -

-
- - -
- - - -

- {linkedAgents().length} agent - {linkedAgents().length > 1 ? 's are' : ' is'} linked to Proxmox node - {linkedAgents().length > 1 ? 's' : ''} and flagged with a{' '} - Linked badge. -

-
-
- - -
-
- - - -
-

- {outdatedAgents().length} outdated agent {outdatedAgents().length > 1 ? 'binaries' : 'binary'} detected -

-

- Older standalone agent binaries are deprecated. Expand a row to copy the - upgrade command. -

-
-
-
-
- -
-
- - -
- -
-
- Refine results -
-
- - -
-
- - -
- -
-
- -
- - Showing {filteredActiveRows().length} of {activeRows().length} active records. - - 0}> - - {filteredMonitoringStoppedRows().length} item(s) are currently ignored by Pulse. - - -
- -
- 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. -
- - - {(notice) => ( -
-
-
-

{notice().title}

-

{notice().detail}

-
-
- - - - -
-
-
- )} -
- -
-
-
-
- Browse reporting items -
-
- Select a reporting item to open its details drawer. -
-
- row.rowKey} - onRowClick={(row) => toggleAgentDetails(row.rowKey)} - /> -
- - setExpandedRowKey(null)} - layout="drawer-right" - panelClass="max-w-[760px]" - ariaLabel="Reporting item details" - > - - {(rowAccessor) => renderSelectedActiveRowDetails(rowAccessor)} - - -
-
- - {renderIgnoredSection()} -
-
+ {state.renderStopMonitoringDialog()} + {state.renderInstallerSection()} + {state.renderInventorySection()}
); }; diff --git a/frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx b/frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx index 49972026f..8cc5c73c6 100644 --- a/frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx @@ -2,7 +2,7 @@ import type { Component } from 'solid-js'; import { Card } from '@/components/shared/Card'; import { AgentProfilesPanel } from './AgentProfilesPanel'; import type { ProxmoxSettingsPanelProps } from './ProxmoxSettingsPanel'; -import { InfrastructureOperationsController } from './InfrastructureOperationsController'; +import { useInfrastructureOperationsState } from './useInfrastructureOperationsState'; interface InfrastructureReportingPanelProps extends ProxmoxSettingsPanelProps { onManageDirectConnections: () => void; @@ -10,55 +10,60 @@ interface InfrastructureReportingPanelProps extends ProxmoxSettingsPanelProps { export const InfrastructureReportingPanel: Component = ( props, -) => ( -
- +) => { + const state = useInfrastructureOperationsState(); -
- -
-
-
-

Direct Proxmox connections

-

- Review fallback direct coverage separately from agent-managed hosts. -

-
- -
+ return ( +
+ {state.renderStopMonitoringDialog()} + {state.renderInventorySection()} -
-
-
PVE
-
- {props.pveNodes().length} +
+ +
+
+
+

Direct Proxmox connections

+

+ Review fallback direct coverage separately from agent-managed hosts. +

+
-
-
PBS
-
- {props.pbsNodes().length} + +
+
+
PVE
+
+ {props.pveNodes().length} +
-
-
-
PMG
-
- {props.pmgNodes().length} +
+
PBS
+
+ {props.pbsNodes().length} +
+
+
+
PMG
+
+ {props.pmgNodes().length} +
-
- + +
+ +
- - -
-); + ); +}; export default InfrastructureReportingPanel; diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx index 9d5441265..42a6fabf3 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx @@ -14,26 +14,18 @@ vi.mock('@solidjs/router', async () => { }; }); -vi.mock('../InfrastructureOperationsController', () => ({ - InfrastructureOperationsController: (props: { showInventory?: boolean; showInstaller?: boolean }) => ( -
- {props.showInventory === false - ? 'install' - : props.showInstaller === false - ? 'inventory' - : 'default'} -
- ), +vi.mock('../InfrastructureInstallPanel', () => ({ + InfrastructureInstallPanel: () =>
install
, +})); + +vi.mock('../InfrastructureReportingPanel', () => ({ + InfrastructureReportingPanel: () =>
profiles
, })); vi.mock('../ProxmoxSettingsPanel', () => ({ ProxmoxSettingsPanel: () =>
direct
, })); -vi.mock('../AgentProfilesPanel', () => ({ - AgentProfilesPanel: () =>
profiles
, -})); - describe('InfrastructureWorkspace', () => { beforeEach(() => { navigateSpy.mockReset(); diff --git a/frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx b/frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx index cc4ef3312..bb9f3583b 100644 --- a/frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx @@ -4,6 +4,10 @@ import { createSignal } from 'solid-js'; import { createStore } from 'solid-js/store'; import { Router, Route } from '@solidjs/router'; import { UnifiedAgents } from '../UnifiedAgents'; +import infrastructureInstallPanelSource from '../InfrastructureInstallPanel.tsx?raw'; +import infrastructureOperationsControllerSource from '../InfrastructureOperationsController.tsx?raw'; +import infrastructureReportingPanelSource from '../InfrastructureReportingPanel.tsx?raw'; +import infrastructureOperationsStateSource from '../useInfrastructureOperationsState.tsx?raw'; import type { Agent, ConnectedInfrastructureItem, @@ -42,6 +46,17 @@ const refetchResourcesMock = vi.fn(); const [mockResources, setMockResources] = createSignal([]); 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(infrastructureOperationsStateSource).toContain('renderInstallerSection'); + expect(infrastructureOperationsStateSource).toContain('renderInventorySection'); + expect(infrastructureOperationsStateSource).toContain('renderStopMonitoringDialog'); + }); +}); + vi.mock('@/App', () => ({ useWebSocket: () => mockWsStore, })); diff --git a/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts b/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts index a0f5cc25a..8dc9a7bde 100644 --- a/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import agentProfilesPanelSource from '../AgentProfilesPanel.tsx?raw'; import apiTokenManagerSource from '../APITokenManager.tsx?raw'; -import infrastructureOperationsControllerSource from '../InfrastructureOperationsController.tsx?raw'; +import infrastructureOperationsStateSource from '../useInfrastructureOperationsState.tsx?raw'; import agentLedgerPanelSource from '../MonitoredSystemLedgerPanel.tsx?raw'; import alertsPageSource from '@/pages/Alerts.tsx?raw'; import setupCompletionPanelSource from '@/components/SetupWizard/SetupCompletionPanel.tsx?raw'; @@ -156,43 +156,43 @@ describe('monitored-system model guardrails', () => { }); it('keeps UnifiedAgents free of v5 merge-workaround patterns', () => { - expect(infrastructureOperationsControllerSource).not.toContain('previousHostTypes'); - expect(infrastructureOperationsControllerSource).not.toContain('const allHosts = createMemo('); - expect(infrastructureOperationsControllerSource).toContain('@/utils/unifiedAgentStatusPresentation'); - expect(infrastructureOperationsControllerSource).not.toContain('const MONITORING_STOPPED_STATUS_LABEL ='); - expect(infrastructureOperationsControllerSource).not.toContain('const ALLOW_RECONNECT_LABEL ='); - expect(infrastructureOperationsControllerSource).toContain('withPrivilegeEscalation'); - expect(infrastructureOperationsControllerSource).toContain('@/utils/agentCapabilityPresentation'); - expect(infrastructureOperationsControllerSource).not.toContain('const getCapabilityLabel ='); - expect(infrastructureOperationsControllerSource).not.toContain('const getCapabilityBadgeClass ='); + expect(infrastructureOperationsStateSource).not.toContain('previousHostTypes'); + expect(infrastructureOperationsStateSource).not.toContain('const allHosts = createMemo('); + expect(infrastructureOperationsStateSource).toContain('@/utils/unifiedAgentStatusPresentation'); + expect(infrastructureOperationsStateSource).not.toContain('const MONITORING_STOPPED_STATUS_LABEL ='); + expect(infrastructureOperationsStateSource).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(agentCapabilityPresentationSource).toContain("export type AgentCapability = 'agent'"); expect(agentCapabilityPresentationSource).toContain('export function getAgentCapabilityLabel'); expect(agentCapabilityPresentationSource).toContain( 'export function getAgentCapabilityBadgeClass', ); - expect(infrastructureOperationsControllerSource).not.toContain('isConnectedHealthStatus'); - expect(infrastructureOperationsControllerSource).not.toContain('const connectedFromStatus ='); + expect(infrastructureOperationsStateSource).not.toContain('isConnectedHealthStatus'); + expect(infrastructureOperationsStateSource).not.toContain('const connectedFromStatus ='); expect(agentProfilesPanelSource).toContain('isConnectedHealthStatus'); expect(agentProfilesPanelSource).not.toContain('const connectedFromStatus ='); expect(statusUtilsSource).toContain('export function isConnectedHealthStatus'); - expect(infrastructureOperationsControllerSource).toContain('@/utils/unifiedAgentStatusPresentation'); - expect(infrastructureOperationsControllerSource).not.toContain('const statusBadgeClass ='); - expect(infrastructureOperationsControllerSource).not.toContain('const statusBadgeClasses ='); - expect(infrastructureOperationsControllerSource).toContain('getUnifiedAgentLookupStatusPresentation'); + expect(infrastructureOperationsStateSource).toContain('@/utils/unifiedAgentStatusPresentation'); + expect(infrastructureOperationsStateSource).not.toContain('const statusBadgeClass ='); + expect(infrastructureOperationsStateSource).not.toContain('const statusBadgeClasses ='); + expect(infrastructureOperationsStateSource).toContain('getUnifiedAgentLookupStatusPresentation'); expect(unifiedAgentStatusPresentationSource).toContain( 'export function getUnifiedAgentStatusPresentation', ); expect(unifiedAgentStatusPresentationSource).toContain( 'export function getUnifiedAgentLookupStatusPresentation', ); - expect(infrastructureOperationsControllerSource).toContain('getInventorySubjectLabel'); - expect(infrastructureOperationsControllerSource).toContain('getMonitoringStoppedEmptyState'); - expect(infrastructureOperationsControllerSource).toContain('getRemovedUnifiedAgentItemLabel'); - expect(infrastructureOperationsControllerSource).toContain('getUnifiedAgentLastSeenLabel'); - expect(infrastructureOperationsControllerSource).not.toContain('const getInventorySubjectLabel ='); - expect(infrastructureOperationsControllerSource).not.toContain('const getRemovedItemLabel ='); - expect(infrastructureOperationsControllerSource).not.toContain('const lastSeenLabel = () => {'); - expect(infrastructureOperationsControllerSource).not.toContain( + 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( 'No monitoring-stopped items match the current filters.', ); expect(unifiedAgentInventoryPresentationSource).toContain( @@ -225,15 +225,15 @@ describe('monitored-system model guardrails', () => { expect(unifiedAgentInventoryPresentationSource).toContain( 'export function getUnifiedAgentClipboardCopySuccessMessage', ); - expect(infrastructureOperationsControllerSource).not.toContain( + expect(infrastructureOperationsStateSource).not.toContain( 'No host identifiers are available to stop monitoring.', ); - expect(infrastructureOperationsControllerSource).not.toContain( + expect(infrastructureOperationsStateSource).not.toContain( 'Failed to update agent configuration', ); - expect(infrastructureOperationsControllerSource).not.toContain('Uninstall command copied'); - expect(infrastructureOperationsControllerSource).not.toContain('Upgrade command copied'); - expect(infrastructureOperationsControllerSource).not.toContain( + expect(infrastructureOperationsStateSource).not.toContain('Uninstall command copied'); + expect(infrastructureOperationsStateSource).not.toContain('Upgrade command copied'); + expect(infrastructureOperationsStateSource).not.toContain( "notificationStore.error('Failed to copy')", ); expect(relaySettingsPanelSource).toContain('getRelayConnectionPresentation'); @@ -255,11 +255,11 @@ describe('monitored-system model guardrails', () => { expect(relayOnboardingCardSource).toContain('RELAY_ONBOARDING_DISCONNECTED_LABEL'); expect(relayOnboardingCardSource).not.toContain('Pair Your Mobile Device'); expect(relayOnboardingCardSource).not.toContain('Relay is currently disconnected.'); - expect(infrastructureOperationsControllerSource).toContain('STORAGE_KEYS.SETUP_HANDOFF'); - expect(infrastructureOperationsControllerSource).toContain( + expect(infrastructureOperationsStateSource).toContain('STORAGE_KEYS.SETUP_HANDOFF'); + expect(infrastructureOperationsStateSource).toContain( 'Security configured. Save these first-run credentials now.', ); - expect(infrastructureOperationsControllerSource).toContain( + expect(infrastructureOperationsStateSource).toContain( 'Generate a scoped install token below before copying agent commands.', ); expect(setupCompletionPanelSource).toContain('@/utils/relayPresentation'); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index caaa04ac3..59ffcedf5 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -5,6 +5,7 @@ import infrastructureWorkspaceSource from '../InfrastructureWorkspace.tsx?raw'; import infrastructureInstallPanelSource from '../InfrastructureInstallPanel.tsx?raw'; import infrastructureOperationsControllerSource from '../InfrastructureOperationsController.tsx?raw'; import infrastructureReportingPanelSource from '../InfrastructureReportingPanel.tsx?raw'; +import infrastructureOperationsStateSource from '../useInfrastructureOperationsState.tsx?raw'; import apiAccessPanelSource from '../APIAccessPanel.tsx?raw'; import auditLogPanelSource from '../AuditLogPanel.tsx?raw'; import auditWebhookPanelSource from '../AuditWebhookPanel.tsx?raw'; @@ -38,6 +39,7 @@ const extractedModules = [ '../settingsFeatureGates.ts', '../BackupTransferDialogs.tsx', '../InfrastructureOperationsController.tsx', + '../useInfrastructureOperationsState.tsx', '../InfrastructureWorkspace.tsx', '../InfrastructureInstallPanel.tsx', '../InfrastructureReportingPanel.tsx', @@ -277,9 +279,10 @@ describe('Settings architecture guardrails', () => { expect(infrastructureWorkspaceSource).not.toContain('tracking-[0.22em]'); expect(infrastructureWorkspaceSource).toContain('InfrastructureInstallPanel'); expect(infrastructureWorkspaceSource).toContain('InfrastructureReportingPanel'); - expect(infrastructureInstallPanelSource).toContain('InfrastructureOperationsController'); - expect(infrastructureReportingPanelSource).toContain('InfrastructureOperationsController'); - expect(infrastructureOperationsControllerSource).toContain('export const InfrastructureOperationsController'); + expect(infrastructureInstallPanelSource).toContain('useInfrastructureOperationsState'); + expect(infrastructureReportingPanelSource).toContain('useInfrastructureOperationsState'); + expect(infrastructureOperationsControllerSource).toContain('useInfrastructureOperationsState'); + expect(infrastructureOperationsStateSource).toContain('export const useInfrastructureOperationsState'); expect(infrastructureInstallPanelSource).not.toContain(' { + const now = new Date(); + const iso = now.toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM + const stamp = iso.replace('T', ' ').replace(/:/g, '-'); + return `Agent ${stamp}`; +}; + +const normalizeTelemetryPart = (value: string) => + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); + +const shellQuoteArg = (value: string) => `'${value.replace(/'/g, `'\"'\"'`)}'`; +type AgentPlatform = 'linux' | 'macos' | 'freebsd' | 'windows'; +type UnifiedAgentStatus = 'active' | 'removed'; +type ScopeCategory = 'default' | 'profile' | 'ai-managed' | 'na'; +type InstallProfile = 'auto' | 'docker' | 'kubernetes' | 'proxmox-pve' | 'proxmox-pbs' | 'truenas'; + +type SetupHandoffState = { + username: string; + password: string; + apiToken: string; + createdAt?: string; +}; + +type UnifiedAgentRow = { + rowKey: string; + id: string; + agentActionId?: string; + dockerActionId?: string; + kubernetesActionId?: string; + name: string; + hostname?: string; + displayName?: string; + capabilities: AgentCapability[]; + status: UnifiedAgentStatus; + healthStatus?: string; + lastSeen?: number; + removedAt?: number; + version?: string; + isOutdatedBinary?: boolean; + linkedNodeId?: string; + commandsEnabled?: boolean; + agentId?: string; + upgradePlatform: AgentPlatform; + scope: { + label: string; + detail?: string; + category: ScopeCategory; + }; + installFlags: string[]; + searchText: string; + kubernetesInfo?: { + server?: string; + context?: string; + tokenName?: string; + }; + surfaces: Array<{ + key: string; + kind: AgentCapability; + label: string; + detail: string; + idLabel?: string; + idValue?: string; + action?: 'stop-monitoring' | 'allow-reconnect'; + controlId?: string; + }>; +}; + +type InventoryActionType = 'stop-monitoring' | 'allow-reconnect'; + +type InventoryActionNotice = { + tone: 'success' | 'info'; + title: string; + detail: string; + showRecoveryQueueLink?: boolean; +}; + +type StopMonitoringDialogState = { + row: UnifiedAgentRow; + subject: string; + scopeLabel: string; +}; + +const getCapabilitySurfaceLabel = (capability: AgentCapability) => { + switch (capability) { + case 'agent': + return 'Host telemetry'; + case 'docker': + return 'Docker runtime data'; + case 'kubernetes': + return 'Kubernetes cluster data'; + case 'proxmox': + return 'Proxmox data'; + case 'pbs': + return 'PBS data'; + case 'pmg': + return 'PMG data'; + default: + return getAgentCapabilityLabel(capability); + } +}; + +const getReconnectActionLabel = (row: UnifiedAgentRow) => { + if (row.capabilities.includes('docker')) { + return 'Allow Docker reconnect'; + } + if (row.capabilities.includes('kubernetes')) { + return 'Allow Kubernetes reconnect'; + } + return 'Allow host reconnect'; +}; + +const joinHumanList = (parts: string[]) => { + if (parts.length === 0) return ''; + if (parts.length === 1) return parts[0]; + if (parts.length === 2) return `${parts[0]} and ${parts[1]}`; + return `${parts.slice(0, -1).join(', ')}, and ${parts.at(-1)}`; +}; + +const sentenceCaseSurfaceLabel = (label: string, index: number) => { + if (index !== 0 || label.length === 0) { + return label; + } + return `${label.slice(0, 1).toLowerCase()}${label.slice(1)}`; +}; + +const getRowReportingSummary = (row: UnifiedAgentRow) => { + const reported = row.surfaces.map((surface, index) => sentenceCaseSurfaceLabel(surface.label, index)); + if (reported.length === 0) { + return ''; + } + return `Pulse is receiving ${joinHumanList(reported)} from this item.`; +}; + +const getRowSurfaceBreakdown = (row: UnifiedAgentRow) => { + return row.surfaces; +}; + +const getStopMonitoringSurfaces = (row: UnifiedAgentRow) => { + const surfaces = getRowSurfaceBreakdown(row); + const stopMonitoringSurfaces = surfaces.filter((surface) => surface.action === 'stop-monitoring'); + const hostManagedStopApplies = stopMonitoringSurfaces.some((surface) => surface.kind === 'agent'); + if (!hostManagedStopApplies) { + return stopMonitoringSurfaces; + } + return surfaces.filter((surface) => + ['agent', 'docker', 'kubernetes', 'proxmox', 'pbs', 'pmg'].includes(surface.kind), + ); +}; + +const getStopMonitoringScopeLabel = (row: UnifiedAgentRow) => { + const surfaceLabels = getStopMonitoringSurfaces(row).map((surface) => surface.label); + if (surfaceLabels.length === 0) { + return 'Reporting for this item'; + } + return joinHumanList(surfaceLabels); +}; + +const createSurfaceScopedRow = ( + row: UnifiedAgentRow, + surfaceKey: 'agent' | 'docker' | 'kubernetes' | 'proxmox' | 'pbs' | 'pmg', +): UnifiedAgentRow => { + if (surfaceKey === 'docker') { + return { + ...row, + rowKey: `${row.rowKey}-docker-surface`, + capabilities: ['docker'], + agentActionId: undefined, + kubernetesActionId: undefined, + linkedNodeId: undefined, + surfaces: row.surfaces.filter((surface) => surface.kind === 'docker'), + }; + } + + if (surfaceKey === 'kubernetes') { + return { + ...row, + rowKey: `${row.rowKey}-kubernetes-surface`, + capabilities: ['kubernetes'], + agentActionId: undefined, + dockerActionId: undefined, + linkedNodeId: undefined, + surfaces: row.surfaces.filter((surface) => surface.kind === 'kubernetes'), + }; + } + + if (surfaceKey === 'agent') { + const hostManagedCapabilities: AgentCapability[] = ['agent']; + if (row.capabilities.includes('proxmox')) hostManagedCapabilities.push('proxmox'); + if (row.capabilities.includes('pbs')) hostManagedCapabilities.push('pbs'); + if (row.capabilities.includes('pmg')) hostManagedCapabilities.push('pmg'); + return { + ...row, + rowKey: `${row.rowKey}-agent-surface`, + capabilities: hostManagedCapabilities, + dockerActionId: undefined, + kubernetesActionId: undefined, + surfaces: row.surfaces.filter((surface) => + ['agent', 'proxmox', 'pbs', 'pmg'].includes(surface.kind), + ), + }; + } + + if (surfaceKey === 'pbs') { + return { + ...row, + rowKey: `${row.rowKey}-pbs-surface`, + capabilities: ['pbs'], + dockerActionId: undefined, + kubernetesActionId: undefined, + surfaces: row.surfaces.filter((surface) => surface.kind === 'pbs'), + }; + } + + if (surfaceKey === 'pmg') { + return { + ...row, + rowKey: `${row.rowKey}-pmg-surface`, + capabilities: ['pmg'], + dockerActionId: undefined, + kubernetesActionId: undefined, + surfaces: row.surfaces.filter((surface) => surface.kind === 'pmg'), + }; + } + + return { + ...row, + rowKey: `${row.rowKey}-proxmox-surface`, + capabilities: ['proxmox'], + dockerActionId: undefined, + kubernetesActionId: undefined, + surfaces: row.surfaces.filter((surface) => surface.kind === 'proxmox'), + }; +}; + +const INSTALL_PROFILE_OPTIONS: { + value: InstallProfile; + label: string; + description: string; + flags: string[]; +}[] = [ + { + value: 'auto', + label: 'Auto-detect (recommended)', + description: + 'Let the installer detect Docker, Kubernetes, Proxmox, and agent capabilities automatically.', + flags: [], + }, + { + value: 'docker', + label: 'Docker / Podman runtime', + description: 'Force container runtime monitoring even when detection is restricted.', + flags: ['--enable-docker', '--disable-host'], + }, + { + value: 'kubernetes', + label: 'Kubernetes node', + description: 'Force Kubernetes monitoring on cluster nodes.', + flags: ['--enable-kubernetes'], + }, + { + value: 'proxmox-pve', + label: 'Proxmox VE node', + description: 'Force Proxmox integration and register as a PVE node.', + flags: ['--enable-proxmox', '--proxmox-type pve'], + }, + { + value: 'proxmox-pbs', + label: 'Proxmox Backup node', + description: 'Force Proxmox integration and register as a PBS node.', + flags: ['--enable-proxmox', '--proxmox-type pbs'], + }, + { + value: 'truenas', + label: 'TrueNAS SCALE agent', + description: + 'Use default auto-detection; installer applies TrueNAS-safe service handling automatically.', + flags: [], + }, +]; + +// Generate platform-specific commands with the appropriate Pulse URL +// Uses agentUrl from API (PULSE_PUBLIC_URL) if configured, otherwise falls back to window.location +const buildCommandsByPlatform = ( + unixCommand: string, + windowsInteractiveCommand: string, + windowsParameterizedCommand: string, +): Record< + AgentPlatform, + { + title: string; + description: string; + snippets: { label: string; command: string; note?: string | any }[]; + } +> => ({ + linux: { + title: 'Install on Linux', + description: + 'The unified installer downloads the agent binary and configures the appropriate service for your system.', + snippets: [ + { + label: 'Install', + command: unixCommand, + note: ( + + Command auto-escalates with sudo when available; otherwise run from a root + shell (for example su -). Auto-detects your init system and works on + Debian, Ubuntu, Proxmox, Fedora, Alpine, Unraid, Synology, and more. + + ), + }, + ], + }, + macos: { + title: 'Install on macOS', + description: + 'The unified installer downloads the universal binary and sets up a launchd service for background monitoring.', + snippets: [ + { + label: 'Install with launchd', + command: unixCommand, + note: ( + + Command auto-escalates with sudo when available; otherwise run from a root + shell. Creates /Library/LaunchDaemons/com.pulse.agent.plist and starts the + agent automatically. + + ), + }, + ], + }, + freebsd: { + title: 'Install on FreeBSD / pfSense / OPNsense', + description: + 'The unified installer downloads the FreeBSD binary and sets up an rc.d service for background monitoring.', + snippets: [ + { + label: 'Install with rc.d', + command: unixCommand, + note: ( + + Run as root. Note: pfSense/OPNsense don't include bash by default. + Install it first: pkg install bash. Creates{' '} + /usr/local/etc/rc.d/pulse-agent and starts the agent automatically. + + ), + }, + ], + }, + windows: { + title: 'Install on Windows', + description: + 'Run the PowerShell script to install and configure the unified agent as a Windows service with automatic startup.', + snippets: [ + { + label: 'Install as Windows Service (PowerShell)', + command: windowsInteractiveCommand, + note: ( + + Run in PowerShell as Administrator. The script will prompt for the Pulse URL and API + token, download the agent binary, and install it as a Windows service with automatic + startup. + + ), + }, + { + label: 'Install with parameters (PowerShell)', + command: windowsParameterizedCommand, + note: ( + + Non-interactive installation. Set environment variables before running to skip prompts. + + ), + }, + ], + }, +}); + +const agentCapabilityFromSurfaceKind = (kind: ConnectedInfrastructureSurface['kind']): AgentCapability => { + switch (kind) { + case 'agent': + case 'docker': + case 'kubernetes': + case 'proxmox': + case 'pbs': + case 'pmg': + return kind; + default: + return 'agent'; + } +}; + +const installFlagsForCapabilities = (capabilities: AgentCapability[]) => { + const flags = new Set(); + if (capabilities.includes('docker')) { + flags.add('--enable-docker'); + flags.add('--disable-host'); + } + if (capabilities.includes('kubernetes')) { + flags.add('--enable-kubernetes'); + } + if (capabilities.includes('proxmox')) { + flags.add('--enable-proxmox'); + flags.add('--proxmox-type pve'); + } else if (capabilities.includes('pbs')) { + flags.add('--enable-proxmox'); + flags.add('--proxmox-type pbs'); + } + return Array.from(flags); +}; + +const surfaceBreakdownFromConnectedSurface = (surface: ConnectedInfrastructureSurface) => ({ + key: surface.kind, + kind: agentCapabilityFromSurfaceKind(surface.kind), + label: surface.label || getCapabilitySurfaceLabel(agentCapabilityFromSurfaceKind(surface.kind)), + detail: surface.detail || '', + idLabel: surface.idLabel, + idValue: surface.idValue, + action: surface.action, + controlId: surface.controlId, +}); + +const rowFromConnectedInfrastructureItem = ( + item: ConnectedInfrastructureItem, + scope: UnifiedAgentRow['scope'], +): UnifiedAgentRow => { + const surfaces = item.surfaces.map(surfaceBreakdownFromConnectedSurface); + const capabilities = Array.from(new Set(surfaces.map((surface) => surface.kind))); + const agentSurface = item.surfaces.find((surface) => surface.kind === 'agent'); + const dockerSurface = item.surfaces.find((surface) => surface.kind === 'docker'); + const kubernetesSurface = item.surfaces.find((surface) => surface.kind === 'kubernetes'); + const name = item.name || item.displayName || item.hostname || item.id; + const rowKey = + item.status === 'ignored' + ? item.surfaces[0]?.kind === 'docker' + ? `removed-docker-${item.id}` + : item.surfaces[0]?.kind === 'kubernetes' + ? `removed-k8s-${item.id}` + : `removed-host-${item.id}` + : kubernetesSurface && !agentSurface && !dockerSurface + ? `k8s-${kubernetesSurface.controlId || item.id}` + : `agent-${item.id}`; + return { + rowKey, + id: item.id, + agentActionId: item.uninstallAgentId || agentSurface?.controlId, + dockerActionId: dockerSurface?.controlId, + kubernetesActionId: kubernetesSurface?.controlId, + name, + hostname: item.hostname, + displayName: item.displayName, + capabilities, + status: item.status === 'ignored' ? 'removed' : 'active', + healthStatus: item.healthStatus, + lastSeen: item.lastSeen, + removedAt: item.removedAt, + version: item.version, + isOutdatedBinary: item.isOutdatedBinary, + linkedNodeId: item.linkedNodeId, + commandsEnabled: item.commandsEnabled, + agentId: item.scopeAgentId || item.uninstallAgentId, + upgradePlatform: item.upgradePlatform || 'linux', + scope, + installFlags: installFlagsForCapabilities(capabilities), + searchText: [ + name, + item.displayName, + item.hostname, + item.id, + item.scopeAgentId, + item.uninstallAgentId, + agentSurface?.controlId, + dockerSurface?.controlId, + kubernetesSurface?.controlId, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(), + kubernetesInfo: + kubernetesSurface || capabilities.includes('kubernetes') + ? { + server: undefined, + context: undefined, + tokenName: undefined, + } + : undefined, + surfaces, + }; +}; + +export interface InfrastructureOperationsStateOptions { + embedded?: boolean; +} + +export const useInfrastructureOperationsState = ( + options: InfrastructureOperationsStateOptions = {}, +) => { + const { state } = useWebSocket(); + const { resources, mutate: mutateResources, refetch: refetchResources } = useResources(); + const navigate = useNavigate(); + + const pd = (r: Resource) => { + const platformData = getPlatformDataRecord(r); + return platformData ? unwrap(platformData) : undefined; + }; + const asRecord = (value: unknown) => + value && typeof value === 'object' ? (value as Record) : undefined; + const asBoolean = (value: unknown) => (typeof value === 'boolean' ? value : undefined); + const platformAgent = (r: Resource) => asRecord(getPlatformAgentRecord(r)); + + const getAgentId = (r: Resource) => getExplicitAgentIdFromResource(r); + + const getAgentActionId = (r: Resource) => getActionableAgentIdFromResource(r); + + const getDockerActionId = (r: Resource) => getActionableDockerRuntimeIdFromResource(r); + + const getKubernetesActionId = (r: Resource) => getActionableKubernetesClusterIdFromResource(r); + + const getCommandsEnabled = (r: Resource) => { + const platformData = pd(r); + return ( + r.agent?.commandsEnabled ?? + asBoolean(platformAgent(r)?.commandsEnabled) ?? + asBoolean(platformData?.commandsEnabled) + ); + }; + + let hasLoggedSecurityStatusError = false; + + const [securityStatus, setSecurityStatus] = createSignal(null); + const [latestRecord, setLatestRecord] = createSignal(null); + const [tokenName, setTokenName] = createSignal(''); + const [confirmedNoToken, setConfirmedNoToken] = createSignal(false); + const [currentToken, setCurrentToken] = createSignal(null); + const [isGeneratingToken, setIsGeneratingToken] = createSignal(false); + const [lookupValue, setLookupValue] = createSignal(''); + const [lookupResult, setLookupResult] = createSignal(null); + const [lookupError, setLookupError] = createSignal(null); + const [lookupLoading, setLookupLoading] = createSignal(false); + const [insecureMode, setInsecureMode] = createSignal(false); // For self-signed certificates (issue #806) + const [enableCommands, setEnableCommands] = createSignal(false); // Enable Pulse command execution (issue #903) + const [installProfile, setInstallProfile] = createSignal('auto'); + const [customAgentUrl, setCustomAgentUrl] = createSignal(''); + const [customCaPath, setCustomCaPath] = createSignal(''); + const [setupHandoff, setSetupHandoff] = createSignal(null); + const [profiles, setProfiles] = createSignal([]); + const [assignments, setAssignments] = createSignal([]); + // Track pending command config changes: agentId -> { desired value, timestamp } + const [pendingCommandConfig, setPendingCommandConfig] = createSignal< + Record + >({}); + const [pendingScopeUpdates, setPendingScopeUpdates] = createSignal>({}); + const [pendingInventoryActions, setPendingInventoryActions] = createSignal< + Record + >({}); + const [inventoryActionNotice, setInventoryActionNotice] = + createSignal(null); + const [optimisticRemovedHostAgents, setOptimisticRemovedHostAgents] = createSignal< + RemovedHostAgent[] + >([]); + const [optimisticRemovedDockerHosts, setOptimisticRemovedDockerHosts] = createSignal< + RemovedDockerHost[] + >([]); + const [optimisticRemovedKubernetesClusters, setOptimisticRemovedKubernetesClusters] = + createSignal([]); + const [stopMonitoringDialog, setStopMonitoringDialog] = + createSignal(null); + const [expandedRowKey, setExpandedRowKey] = createSignal(null); + const [selectedIgnoredRowKey, setSelectedIgnoredRowKey] = createSignal(null); + const [filterCapability, setFilterCapability] = createSignal<'all' | AgentCapability>('all'); + const [filterScope, setFilterScope] = createSignal<'all' | Exclude>('all'); + const [filterSearch, setFilterSearch] = createSignal(''); + let recoveryQueueSectionRef: HTMLDivElement | undefined; + + const loadAgentProfiles = async () => { + try { + const [profilesData, assignmentsData] = await Promise.all([ + AgentProfilesAPI.listProfiles(), + AgentProfilesAPI.listAssignments(), + ]); + setProfiles(profilesData); + setAssignments(assignmentsData); + } catch (err) { + logger.debug('Failed to load agent profiles', err); + notificationStore.error(err instanceof Error ? err.message : 'Failed to load agent profiles'); + } + }; + + createEffect(() => { + if (requiresToken()) { + setConfirmedNoToken(false); + } + }); + + // Use agentUrl from API (PULSE_PUBLIC_URL) if configured, otherwise fall back to window.location + const agentUrl = () => securityStatus()?.agentUrl || getPulseBaseUrl(); + const selectedAgentUrl = () => resolveInstallerBaseUrl(customAgentUrl(), agentUrl()); + + onMount(() => { + if (typeof window === 'undefined') { + return; + } + + const rawSetupHandoff = sessionStorage.getItem(STORAGE_KEYS.SETUP_HANDOFF); + if (rawSetupHandoff) { + try { + const parsed = JSON.parse(rawSetupHandoff) as Partial; + if (parsed.username && parsed.password && parsed.apiToken) { + setSetupHandoff({ + username: parsed.username, + password: parsed.password, + apiToken: parsed.apiToken, + createdAt: parsed.createdAt, + }); + } else { + sessionStorage.removeItem(STORAGE_KEYS.SETUP_HANDOFF); + } + } catch (_err) { + sessionStorage.removeItem(STORAGE_KEYS.SETUP_HANDOFF); + } + } + + const fetchSecurityStatus = async () => { + try { + const data = await SecurityAPI.getStatus(); + setSecurityStatus(data); + } catch (err) { + if (!hasLoggedSecurityStatusError) { + hasLoggedSecurityStatusError = true; + logger.error('Failed to load security status', err); + } + } + }; + fetchSecurityStatus(); + void loadAgentProfiles(); + }); + + const clearSetupHandoff = () => { + if (typeof window !== 'undefined') { + try { + sessionStorage.removeItem(STORAGE_KEYS.SETUP_HANDOFF); + } catch (_err) { + // Ignore storage cleanup failures. + } + } + setSetupHandoff(null); + }; + + const copySetupHandoffField = async (value: string, successMessage: string) => { + const copied = await copyToClipboard(value); + if (copied) { + notificationStore.success(successMessage); + } else { + notificationStore.error(getUnifiedAgentClipboardCopyErrorMessage()); + } + }; + + const downloadSetupHandoff = () => { + const handoff = setupHandoff(); + if (!handoff) return; + + const baseUrl = getPulseBaseUrl(); + const content = `Pulse First-Run Credentials +============================ +Generated: ${handoff.createdAt || new Date().toISOString()} + +Web Login: +---------- +URL: ${baseUrl} +Username: ${handoff.username} +Password: ${handoff.password} + +Admin API Token: +---------------- +${handoff.apiToken} + +Canonical install workspace: +---------------------------- +${baseUrl.replace(/\/$/, '')}/settings/infrastructure/install + +Generate a scoped install token below before copying Unified Agent install commands. +`; + + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `pulse-first-run-credentials-${Date.now()}.txt`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + const requiresToken = () => { + const status = securityStatus(); + if (status) { + return status.requiresAuth || status.apiTokenConfigured; + } + return true; + }; + + const hasToken = () => Boolean(currentToken()); + const commandsUnlocked = () => (requiresToken() ? hasToken() : hasToken() || confirmedNoToken()); + + const acknowledgeNoToken = () => { + if (requiresToken()) { + notificationStore.info('Generate or select a token before continuing.', 4000); + return; + } + setCurrentToken(null); + setLatestRecord(null); + setConfirmedNoToken(true); + notificationStore.success('Confirmed install commands without an API token.', 3500); + }; + + const handleGenerateToken = async () => { + if (isGeneratingToken()) return; + + setIsGeneratingToken(true); + try { + const desiredName = tokenName().trim() || buildDefaultTokenName(); + // Generate token with unified agent reporting scopes + const scopes = [ + AGENT_REPORT_SCOPE, + AGENT_CONFIG_READ_SCOPE, + DOCKER_REPORT_SCOPE, + KUBERNETES_REPORT_SCOPE, + AGENT_EXEC_SCOPE, + ]; + const { token, record } = await SecurityAPI.createToken(desiredName, scopes); + + setCurrentToken(token); + setLatestRecord(record); + setTokenName(''); + setConfirmedNoToken(false); + trackAgentInstallTokenGenerated(UNIFIED_AGENT_TELEMETRY_SURFACE, 'manual'); + notificationStore.success( + 'Token generated with Agent config + reporting, Docker, and Kubernetes permissions.', + 4000, + ); + } catch (err) { + logger.error('Failed to generate agent token', err); + notificationStore.error( + 'Failed to generate agent token. Confirm you are signed in as an administrator.', + 6000, + ); + } finally { + setIsGeneratingToken(false); + } + }; + + const resolvedCommandToken = () => { + if (requiresToken()) { + return currentToken() || TOKEN_PLACEHOLDER; + } + return currentToken(); + }; + + const handleLookup = async () => { + const query = lookupValue().trim(); + setLookupError(null); + + if (!query) { + setLookupResult(null); + setLookupError('Enter a hostname or agent ID to check.'); + return; + } + + setLookupLoading(true); + try { + const result = await MonitoringAPI.lookupAgent({ id: query, hostname: query }); + if (!result) { + setLookupResult(null); + setLookupError(`No agent has reported with "${query}" yet. Try again in a few seconds.`); + } else { + setLookupResult(result); + setLookupError(null); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Agent lookup failed.'; + setLookupResult(null); + setLookupError(message); + } finally { + setLookupLoading(false); + } + }; + + const withPrivilegeEscalation = (command: string) => { + if (!command.includes('| bash -s --')) return command; + return command.replace(/\|\s*bash -s --([\s\S]*)$/, (_match, args: string) => { + return `| { if [ "$(id -u)" -eq 0 ]; then bash -s --${args}; elif command -v sudo >/dev/null 2>&1; then sudo bash -s --${args}; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }`; + }); + }; + const selectedCustomCaPath = () => customCaPath().trim(); + const urlRequiresInstallerInsecure = (url: string) => + insecureMode() || url.trim().toLowerCase().startsWith('http://'); + const getInsecureFlag = (url: string) => (urlRequiresInstallerInsecure(url) ? ' --insecure' : ''); + const getCurlFlags = () => (insecureMode() ? '-kfsSL' : '-fsSL'); + const getShellCustomCaCurlFlag = () => { + const caPath = selectedCustomCaPath(); + return caPath ? ` --cacert ${shellQuoteArg(caPath)}` : ''; + }; + const getShellCustomCaInstallerFlag = () => { + const caPath = selectedCustomCaPath(); + return caPath ? ` --cacert ${shellQuoteArg(caPath)}` : ''; + }; + const getSelectedInstallProfile = () => + INSTALL_PROFILE_OPTIONS.find((option) => option.value === installProfile()) ?? + INSTALL_PROFILE_OPTIONS[0]; + const getInstallProfileFlags = () => getSelectedInstallProfile().flags; + const getPowerShellInstallProfileEnvFromFlags = (flags: string[]) => { + const envAssignments: string[] = []; + for (let index = 0; index < flags.length; index += 1) { + const flag = flags[index]; + switch (flag) { + case '--enable-docker': + envAssignments.push(`$env:PULSE_ENABLE_DOCKER="true"`); + break; + case '--disable-host': + envAssignments.push(`$env:PULSE_ENABLE_HOST="false"`); + break; + case '--enable-kubernetes': + envAssignments.push(`$env:PULSE_ENABLE_KUBERNETES="true"`); + break; + case '--enable-proxmox': + envAssignments.push(`$env:PULSE_ENABLE_PROXMOX="true"`); + break; + case '--proxmox-type': + if (typeof flags[index + 1] === 'string' && flags[index + 1].trim()) { + envAssignments.push(`$env:PULSE_PROXMOX_TYPE="${flags[index + 1].trim()}"`); + index += 1; + } + break; + default: + if (flag.startsWith('--proxmox-type ')) { + const proxmoxType = flag.slice('--proxmox-type '.length).trim(); + if (proxmoxType) { + envAssignments.push(`$env:PULSE_PROXMOX_TYPE="${proxmoxType}"`); + } + } + break; + } + } + return envAssignments; + }; + const getPowerShellInstallProfileEnv = () => + getPowerShellInstallProfileEnvFromFlags(getInstallProfileFlags()); + const getPowerShellTransportEnv = () => { + const envAssignments: string[] = []; + if (insecureMode()) { + envAssignments.push(`$env:PULSE_INSECURE_SKIP_VERIFY="true"`); + } + if (selectedCustomCaPath()) { + envAssignments.push(`$env:PULSE_CACERT="${powerShellQuote(selectedCustomCaPath())}"`); + } + return envAssignments; + }; + const getPowerShellModeEnv = () => { + const envAssignments = getPowerShellTransportEnv(); + if (enableCommands()) { + envAssignments.push(`$env:PULSE_ENABLE_COMMANDS="true"`); + } + return envAssignments; + }; + const getPowerShellInstallEnv = () => { + const envAssignments = getPowerShellInstallProfileEnv(); + if (enableCommands()) { + envAssignments.push(`$env:PULSE_ENABLE_COMMANDS="true"`); + } + return envAssignments; + }; + const getInstallerExtraArgs = () => [ + ...getInstallProfileFlags(), + ...(enableCommands() ? ['--enable-commands'] : []), + ]; + const handleInstallProfileChange = (profile: InstallProfile) => { + setInstallProfile(profile); + trackAgentInstallProfileSelected(UNIFIED_AGENT_TELEMETRY_SURFACE, profile); + }; + + const getCanonicalUninstallAgentId = (row?: UnifiedAgentRow) => + row?.agentActionId?.trim() || row?.agentId?.trim() || ''; + const getCanonicalUninstallHostname = (row?: UnifiedAgentRow) => row?.hostname?.trim() || ''; + + const getUninstallCommand = (row?: UnifiedAgentRow) => { + const url = selectedAgentUrl(); + const token = resolvedCommandToken(); + const insecure = getInsecureFlag(url); + const agentId = getCanonicalUninstallAgentId(row); + const hostname = getCanonicalUninstallHostname(row); + const baseArgs = token + ? `--uninstall --url ${shellQuoteArg(url)} --token ${shellQuoteArg(token)}${insecure}${getShellCustomCaInstallerFlag()}` + : `--uninstall --url ${shellQuoteArg(url)}${insecure}${getShellCustomCaInstallerFlag()}`; + const identityArgs = `${agentId ? ` --agent-id ${shellQuoteArg(agentId)}` : ''}${hostname ? ` --hostname ${shellQuoteArg(hostname)}` : ''}`; + return withPrivilegeEscalation( + `curl ${getCurlFlags()}${getShellCustomCaCurlFlag()} ${shellQuoteArg(`${url}/install.sh`)} | bash -s -- ${baseArgs}${identityArgs}`, + ); + }; + + const getWindowsUninstallCommand = (row?: UnifiedAgentRow) => { + const url = selectedAgentUrl(); + const token = resolvedCommandToken(); + const transportEnv = getPowerShellTransportEnv(); + const agentId = getCanonicalUninstallAgentId(row); + const hostname = getCanonicalUninstallHostname(row); + const identityEnv: string[] = []; + if (agentId) { + identityEnv.push(`$env:PULSE_AGENT_ID="${powerShellQuote(agentId)}"`); + } + if (hostname) { + identityEnv.push(`$env:PULSE_HOSTNAME="${powerShellQuote(hostname)}"`); + } + const prefixParts = [...transportEnv, ...identityEnv]; + const prefix = prefixParts.length > 0 ? `${prefixParts.join('; ')}; ` : ''; + // Include URL and token for server notification (removes agent from dashboard) + if (token) { + return `${prefix}$env:PULSE_URL="${powerShellQuote(url)}"; $env:PULSE_TOKEN="${powerShellQuote(token)}"; $env:PULSE_UNINSTALL="true"; ${buildPowerShellInstallScriptBootstrap(url)}`; + } + return `${prefix}$env:PULSE_URL="${powerShellQuote(url)}"; $env:PULSE_UNINSTALL="true"; ${buildPowerShellInstallScriptBootstrap(url)}`; + }; + + const getPlatformUninstallCommand = (platform: AgentPlatform, row?: UnifiedAgentRow) => { + if (platform === 'windows') { + return getWindowsUninstallCommand(row); + } + return getUninstallCommand(row); + }; + + const commandSections = createMemo(() => { + const url = selectedAgentUrl(); + const token = resolvedCommandToken(); + const unixCommand = buildUnixAgentInstallCommand({ + baseUrl: url, + token, + insecure: insecureMode(), + caCertPath: selectedCustomCaPath(), + extraArgs: getInstallerExtraArgs(), + }); + const windowsInteractiveCommand = buildWindowsAgentInstallCommand({ + baseUrl: url, + token: currentToken(), + insecure: insecureMode(), + caCertPath: selectedCustomCaPath(), + extraEnvAssignments: getPowerShellInstallEnv(), + }); + const windowsParameterizedCommand = buildWindowsAgentInstallCommand({ + baseUrl: url, + token, + insecure: insecureMode(), + caCertPath: selectedCustomCaPath(), + extraEnvAssignments: getPowerShellInstallEnv(), + }); + const commands = buildCommandsByPlatform( + unixCommand, + windowsInteractiveCommand, + windowsParameterizedCommand, + ); + return Object.entries(commands).map(([platform, meta]) => ({ + platform: platform as AgentPlatform, + ...meta, + })); + }); + + const matchesRemovedAgent = ( + resource: Resource, + ids: { agentId?: string; dockerId?: string }, + capabilities: AgentCapability[], + ) => { + if (capabilities.includes('agent') && ids.agentId) { + const agentId = ids.agentId; + if ( + resource.id === agentId || + getAgentId(resource) === agentId || + getAgentActionId(resource) === agentId + ) { + return true; + } + } + + if (capabilities.includes('docker') && ids.dockerId) { + const dockerId = ids.dockerId; + if (resource.id === dockerId || getDockerActionId(resource) === dockerId) { + return true; + } + } + + return false; + }; + + const reconcileRemovedAgent = ( + ids: { agentId?: string; dockerId?: string }, + capabilities: AgentCapability[], + row: UnifiedAgentRow, + ) => { + const removedAt = Date.now(); + if (capabilities.includes('agent') && ids.agentId) { + setOptimisticRemovedHostAgents((prev) => [ + { + id: ids.agentId!, + hostname: row.hostname, + displayName: row.displayName || row.name, + removedAt, + }, + ...prev.filter((item) => item.id !== ids.agentId), + ]); + } + if (capabilities.includes('docker') && ids.dockerId) { + setOptimisticRemovedDockerHosts((prev) => [ + { + id: ids.dockerId!, + hostname: row.hostname, + displayName: row.displayName || row.name, + removedAt, + }, + ...prev.filter((item) => item.id !== ids.dockerId), + ]); + } + mutateResources((prev) => + prev.filter((resource) => !matchesRemovedAgent(resource, ids, capabilities)), + ); + void refetchResources().catch((err) => { + logger.debug('Failed to refresh resources after agent removal', err); + }); + }; + + const reconcileRemovedKubernetesCluster = (clusterId: string, clusterName?: string) => { + setOptimisticRemovedKubernetesClusters((prev) => [ + { + id: clusterId, + name: clusterName || clusterId, + displayName: clusterName, + removedAt: Date.now(), + }, + ...prev.filter((item) => item.id !== clusterId), + ]); + mutateResources((prev) => + prev.filter( + (resource) => + !( + resource.type === 'k8s-cluster' && + (getKubernetesActionId(resource) === clusterId || resource.id === clusterId) + ), + ), + ); + void refetchResources().catch((err) => { + logger.debug('Failed to refresh resources after kubernetes removal', err); + }); + }; + + /** + * All resources managed by an agent. + * In v6, the backend already merges resources by identity — a PVE node with a + * linked agent is a single resource of type "agent" with agent + proxmox data. + * No frontend merge logic or type-flapping prevention needed. + * + * Includes docker-host resources that may lack the `agent` facet when the + * agent's docker data is represented as a separate resource type. + */ + const agentResources = createMemo(() => { + return resources() + .filter((r) => r.type !== 'k8s-cluster' && (hasAgentFacet(r) || hasDockerWorkloadsScope(r))) + .sort((a, b) => + (getPreferredResourceHostname(a) || '').localeCompare( + getPreferredResourceHostname(b) || '', + ), + ); + }); + + const agentByActionId = createMemo(() => { + const map = new Map(); + // Only include resources with an agent facet (not docker-only resources) + // to avoid polluting the command config sync lookup. + for (const agentResource of agentResources()) { + if (!hasAgentFacet(agentResource)) continue; + const actionId = getAgentActionId(agentResource); + if (!actionId || map.has(actionId)) continue; + map.set(actionId, agentResource); + } + return map; + }); + + const profileById = createMemo(() => { + const map = new Map(); + for (const profile of profiles()) { + map.set(profile.id, profile); + } + return map; + }); + + const assignmentByAgent = createMemo(() => { + const map = new Map(); + for (const assignment of assignments()) { + map.set(assignment.agent_id, assignment); + } + return map; + }); + + const getScopeInfo = (agentId: string | undefined) => { + if (!agentId) { + return { label: 'N/A', detail: '', category: 'na' as const }; + } + const assignment = assignmentByAgent().get(agentId); + if (!assignment) { + return { label: 'Default', detail: 'Auto-detect', category: 'default' as const }; + } + const profile = profileById().get(assignment.profile_id); + if (!profile) { + return { + label: 'Profile assigned', + detail: assignment.profile_id, + category: 'profile' as const, + }; + } + const name = profile.name || assignment.profile_id; + const isAIManaged = + profile.description?.toLowerCase().includes('pulse ai') || + name.toLowerCase().startsWith('ai scope'); + return isAIManaged + ? { label: 'Patrol-managed', detail: name, category: 'ai-managed' as const } + : { label: name, detail: 'Assigned profile', category: 'profile' as const }; + }; + + const getProfileOptionLabel = (profileId: string) => { + const profile = profileById().get(profileId); + if (profile) { + return profile.name || profile.id; + } + return `Missing profile (${profileId})`; + }; + + const updateScopeAssignment = async ( + agentId: string, + profileId: string | null, + agentName: string, + ) => { + if (!agentId) { + return; + } + if (pendingScopeUpdates()[agentId]) { + return; + } + + setPendingScopeUpdates((prev) => ({ ...prev, [agentId]: true })); + try { + if (profileId) { + await AgentProfilesAPI.assignProfile(agentId, profileId); + setAssignments((prev) => { + const updatedAt = new Date().toISOString(); + const next = prev.filter((a) => a.agent_id !== agentId); + next.push({ agent_id: agentId, profile_id: profileId, updated_at: updatedAt }); + return next; + }); + notificationStore.success( + `Scope updated for ${agentName}. Restart the agent to apply changes.`, + ); + } else { + await AgentProfilesAPI.unassignProfile(agentId); + setAssignments((prev) => prev.filter((a) => a.agent_id !== agentId)); + notificationStore.success( + `Scope reset for ${agentName}. Restart the agent to apply changes.`, + ); + } + } catch (err) { + logger.error('Failed to update agent scope', err); + if (err instanceof Error && err.message === MISSING_AGENT_PROFILE_ASSIGNMENT_MESSAGE) { + await loadAgentProfiles(); + } + notificationStore.error( + err instanceof Error && err.message ? err.message : 'Failed to update agent scope', + ); + } finally { + setPendingScopeUpdates((prev) => { + const next = { ...prev }; + delete next[agentId]; + return next; + }); + } + }; + + const handleResetScope = async (agentId: string, agentName: string) => { + if ( + !confirm( + `Reset scope for ${agentName}? This removes any assigned profile and reverts to auto-detect.`, + ) + ) + return; + await updateScopeAssignment(agentId, null, agentName); + }; + + const toggleAgentDetails = (rowKey: string) => { + setExpandedRowKey(rowKey); + }; + + const connectedInfrastructureItems = createMemo( + () => state.connectedInfrastructure, + ); + const isEmbedded = () => options.embedded ?? false; + + const unifiedRows = createMemo(() => { + const rows: UnifiedAgentRow[] = []; + const optimisticHostIDs = new Set( + optimisticRemovedHostAgents() + .map((item) => item.id?.trim()) + .filter((id): id is string => Boolean(id)), + ); + const optimisticDockerIDs = new Set( + optimisticRemovedDockerHosts() + .map((item) => item.id?.trim()) + .filter((id): id is string => Boolean(id)), + ); + const optimisticKubernetesIDs = new Set( + optimisticRemovedKubernetesClusters() + .map((item) => item.id?.trim()) + .filter((id): id is string => Boolean(id)), + ); + + connectedInfrastructureItems().forEach((item) => { + const optimisticFilteredItem: ConnectedInfrastructureItem = + item.status === 'active' + ? { + ...item, + surfaces: item.surfaces.filter((surface) => { + const controlId = surface.controlId?.trim(); + if (!controlId) return true; + if (surface.kind === 'agent') return !optimisticHostIDs.has(controlId); + if (surface.kind === 'docker') return !optimisticDockerIDs.has(controlId); + if (surface.kind === 'kubernetes') return !optimisticKubernetesIDs.has(controlId); + return true; + }), + } + : item; + + if (optimisticFilteredItem.status === 'active' && optimisticFilteredItem.surfaces.length === 0) { + return; + } + + rows.push( + rowFromConnectedInfrastructureItem( + optimisticFilteredItem, + getScopeInfo(optimisticFilteredItem.scopeAgentId), + ), + ); + }); + + const backendIgnoredSurfaceKeys = new Set( + rows + .filter((row) => row.status === 'removed') + .flatMap((row) => + row.surfaces.map((surface) => `${surface.kind}:${surface.controlId || surface.idValue || row.id}`), + ), + ); + + optimisticRemovedDockerHosts().forEach((runtime) => { + const key = `docker:${runtime.id}`; + if (backendIgnoredSurfaceKeys.has(key)) return; + const name = getPreferredNamedEntityLabel(runtime); + rows.push({ + rowKey: `removed-docker-${runtime.id}`, + id: runtime.id, + dockerActionId: runtime.id, + name, + hostname: runtime.hostname, + displayName: runtime.displayName, + capabilities: ['docker'], + status: 'removed', + removedAt: runtime.removedAt, + upgradePlatform: 'linux', + scope: getScopeInfo(undefined), + installFlags: ['--enable-docker', '--disable-host'], + searchText: [name, runtime.hostname, runtime.id].filter(Boolean).join(' ').toLowerCase(), + surfaces: [ + { + key: 'docker', + kind: 'docker', + label: 'Docker runtime data', + detail: 'Pulse is blocking Docker runtime reports from this machine.', + idLabel: 'Docker runtime ID', + idValue: runtime.id, + action: 'allow-reconnect', + controlId: runtime.id, + }, + ], + }); + }); + + optimisticRemovedHostAgents().forEach((host) => { + const key = `agent:${host.id}`; + if (backendIgnoredSurfaceKeys.has(key)) return; + const name = getPreferredNamedEntityLabel(host); + rows.push({ + rowKey: `removed-host-${host.id}`, + id: host.id, + agentActionId: host.id, + name, + hostname: host.hostname, + displayName: host.displayName, + capabilities: ['agent'], + status: 'removed', + removedAt: host.removedAt, + upgradePlatform: 'linux', + scope: getScopeInfo(undefined), + installFlags: [], + searchText: [name, host.hostname, host.id].filter(Boolean).join(' ').toLowerCase(), + surfaces: [ + { + key: 'agent', + kind: 'agent', + label: 'Host telemetry', + detail: 'Pulse is blocking host telemetry from this machine.', + idLabel: 'Agent ID', + idValue: host.id, + action: 'allow-reconnect', + controlId: host.id, + }, + ], + }); + }); + + optimisticRemovedKubernetesClusters().forEach((cluster) => { + const key = `kubernetes:${cluster.id}`; + if (backendIgnoredSurfaceKeys.has(key)) return; + const name = getPreferredNamedEntityLabel(cluster); + rows.push({ + rowKey: `removed-k8s-${cluster.id}`, + id: cluster.id, + kubernetesActionId: cluster.id, + name, + capabilities: ['kubernetes'], + status: 'removed', + removedAt: cluster.removedAt, + upgradePlatform: 'linux', + scope: getScopeInfo(undefined), + installFlags: ['--enable-kubernetes'], + searchText: [name, cluster.name, cluster.id].filter(Boolean).join(' ').toLowerCase(), + surfaces: [ + { + key: 'kubernetes', + kind: 'kubernetes', + label: 'Kubernetes cluster data', + detail: 'Pulse is blocking Kubernetes telemetry for this cluster.', + idLabel: 'Cluster ID', + idValue: cluster.id, + action: 'allow-reconnect', + controlId: cluster.id, + }, + ], + }); + }); + + rows.sort((a, b) => { + if (a.status !== b.status) { + return a.status === 'active' ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + return rows; + }); + + const matchesInventoryFilters = (row: UnifiedAgentRow) => { + const query = filterSearch().trim().toLowerCase(); + if ( + filterCapability() !== 'all' && + !row.capabilities.includes(filterCapability() as AgentCapability) + ) { + return false; + } + if (filterScope() !== 'all' && row.scope.category !== filterScope()) { + return false; + } + if (query && !row.searchText.includes(query)) { + return false; + } + return true; + }; + + const activeRows = createMemo(() => unifiedRows().filter((row) => row.status === 'active')); + const monitoringStoppedRows = createMemo(() => + unifiedRows().filter((row) => row.status === 'removed'), + ); + const linkedAgents = createMemo(() => + activeRows() + .filter((row) => Boolean(row.linkedNodeId)) + .map((row) => ({ + id: row.id, + hostname: row.hostname || row.name, + displayName: row.displayName, + linkedNodeId: row.linkedNodeId!, + status: row.healthStatus || 'online', + version: row.version, + lastSeen: row.lastSeen, + })), + ); + const hasLinkedAgents = createMemo(() => linkedAgents().length > 0); + const outdatedAgents = createMemo(() => activeRows().filter((row) => row.isOutdatedBinary)); + const hasOutdatedAgents = createMemo(() => outdatedAgents().length > 0); + + const filteredActiveRows = createMemo(() => activeRows().filter(matchesInventoryFilters)); + const filteredMonitoringStoppedRows = createMemo(() => + monitoringStoppedRows().filter(matchesInventoryFilters), + ); + const selectedActiveRow = createMemo(() => { + const selectedKey = expandedRowKey(); + return filteredActiveRows().find((row) => row.rowKey === selectedKey) || null; + }); + const selectedIgnoredRow = createMemo(() => { + const selectedKey = selectedIgnoredRowKey(); + return filteredMonitoringStoppedRows().find((row) => row.rowKey === selectedKey) || null; + }); + + const hasFilters = createMemo(() => { + return ( + filterCapability() !== 'all' || filterScope() !== 'all' || filterSearch().trim().length > 0 + ); + }); + + const hasMonitoringStoppedRows = createMemo(() => monitoringStoppedRows().length > 0); + const showMonitoringStoppedSection = createMemo(() => { + return hasMonitoringStoppedRows() || hasFilters(); + }); + const coverageSummary = createMemo(() => { + const active = activeRows(); + return { + agent: active.filter((row) => row.capabilities.includes('agent')).length, + docker: active.filter((row) => row.capabilities.includes('docker')).length, + kubernetes: active.filter((row) => row.capabilities.includes('kubernetes')).length, + proxmox: active.filter((row) => row.capabilities.includes('proxmox')).length, + pbs: active.filter((row) => row.capabilities.includes('pbs')).length, + pmg: active.filter((row) => row.capabilities.includes('pmg')).length, + }; + }); + + const reportingCoverageSummaryText = createMemo(() => { + const summary = coverageSummary(); + const activeClauses = [ + summary.agent > 0 ? `${summary.agent} host${summary.agent === 1 ? '' : 's'}` : null, + summary.docker > 0 + ? `${summary.docker} Docker runtime${summary.docker === 1 ? '' : 's'}` + : null, + summary.kubernetes > 0 + ? `${summary.kubernetes} Kubernetes cluster${summary.kubernetes === 1 ? '' : 's'}` + : null, + summary.proxmox > 0 + ? `${summary.proxmox} Proxmox node${summary.proxmox === 1 ? '' : 's'}` + : null, + summary.pbs > 0 ? `${summary.pbs} PBS server${summary.pbs === 1 ? '' : 's'}` : null, + summary.pmg > 0 ? `${summary.pmg} PMG server${summary.pmg === 1 ? '' : 's'}` : null, + ].filter((value): value is string => Boolean(value)); + + if (activeClauses.length === 0) { + return 'Pulse is not currently receiving live infrastructure reports.'; + } + + return `Pulse is currently receiving live reports from ${joinHumanList(activeClauses)}.`; + }); + + const inventoryStatusSummaryText = createMemo(() => { + const activeCount = filteredActiveRows().length; + const recoveryCount = filteredMonitoringStoppedRows().length; + const recoveryClause = + recoveryCount > 0 + ? `${recoveryCount} item${recoveryCount === 1 ? ' is' : 's are'} currently ignored by Pulse` + : 'nothing is currently ignored by Pulse'; + return `${activeCount} item${activeCount === 1 ? ' is' : 's are'} actively reporting right now, and ${recoveryClause}. Stopping monitoring in Pulse does not uninstall software on the remote system.`; + }); + + const resetFilters = () => { + setFilterCapability('all'); + setFilterScope('all'); + setFilterSearch(''); + }; + + const setInventoryActionPending = ( + rowKey: string, + action: InventoryActionType, + pending: boolean, + ) => { + setPendingInventoryActions((prev) => { + const next = { ...prev }; + if (pending) { + next[rowKey] = action; + } else { + delete next[rowKey]; + } + return next; + }); + }; + + const getPendingInventoryAction = (rowKey: string) => pendingInventoryActions()[rowKey]; + + const scrollToRecoveryQueue = () => { + recoveryQueueSectionRef?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + + const openStopMonitoringDialog = (row: UnifiedAgentRow) => { + setStopMonitoringDialog({ + row, + subject: getInventorySubjectLabel(row.name, 'this item'), + scopeLabel: getStopMonitoringScopeLabel(row), + }); + }; + + const getUpgradeCommand = (row: UnifiedAgentRow) => { + const token = resolvedCommandToken(); + const url = selectedAgentUrl(); + const agentId = getCanonicalUninstallAgentId(row); + const hostname = getCanonicalUninstallHostname(row); + if (row.upgradePlatform === 'windows') { + const envAssignments = [ + ...getPowerShellInstallProfileEnvFromFlags(row.installFlags), + ...getPowerShellModeEnv(), + ]; + if (agentId) { + envAssignments.push(`$env:PULSE_AGENT_ID="${powerShellQuote(agentId)}"`); + } + if (hostname) { + envAssignments.push(`$env:PULSE_HOSTNAME="${powerShellQuote(hostname)}"`); + } + const prefix = envAssignments.length > 0 ? `${envAssignments.join('; ')}; ` : ''; + const tokenEnv = token ? `$env:PULSE_TOKEN="${powerShellQuote(token)}"; ` : ''; + return `${prefix}$env:PULSE_URL="${powerShellQuote(url)}"; ${tokenEnv}${buildPowerShellInstallScriptBootstrap(url)}`; + } + let command = `curl ${getCurlFlags()}${getShellCustomCaCurlFlag()} ${shellQuoteArg(`${url}/install.sh`)} | bash -s -- --url ${shellQuoteArg(url)}`; + if (token) { + command += ` --token ${shellQuoteArg(token)}`; + } + if (row.installFlags.length > 0) { + command += ` ${row.installFlags.join(' ')}`; + } + if (urlRequiresInstallerInsecure(url)) { + command += getInsecureFlag(url); + } + command += getShellCustomCaInstallerFlag(); + if (agentId) { + command += ` --agent-id ${shellQuoteArg(agentId)}`; + } + if (hostname) { + command += ` --hostname ${shellQuoteArg(hostname)}`; + } + return withPrivilegeEscalation(command); + }; + + const handleRemoveAgent = async (row: UnifiedAgentRow) => { + const subject = getInventorySubjectLabel(row.name, 'this host'); + + setInventoryActionNotice(null); + setInventoryActionPending(row.rowKey, 'stop-monitoring', true); + try { + let removed = false; + // Remove the agent registration + if (row.capabilities.includes('agent') && row.agentActionId) { + await MonitoringAPI.deleteAgent(row.agentActionId); + removed = true; + } + // Remove docker runtime registration if present + if (row.capabilities.includes('docker') && row.dockerActionId) { + await MonitoringAPI.deleteDockerRuntime(row.dockerActionId, { force: true }); + removed = true; + } + if (removed) { + reconcileRemovedAgent( + { agentId: row.agentActionId, dockerId: row.dockerActionId }, + row.capabilities, + row, + ); + setInventoryActionNotice({ + tone: 'success', + title: `Monitoring stopped for ${subject}`, + detail: + 'Pulse removed it from active reporting and will ignore new reports until you allow reconnect. You can review it in Ignored by Pulse below.', + showRecoveryQueueLink: true, + }); + notificationStore.success(getUnifiedAgentStopMonitoringSuccessMessage(subject)); + } else { + notificationStore.error(getUnifiedAgentStopMonitoringUnavailableMessage()); + } + } catch (err) { + logger.error('Failed to stop monitoring host', err); + notificationStore.error(getUnifiedAgentStopMonitoringErrorMessage(subject)); + } finally { + setInventoryActionPending(row.rowKey, 'stop-monitoring', false); + setStopMonitoringDialog(null); + } + }; + + const handleAllowHostReconnect = async (row: UnifiedAgentRow) => { + const agentId = row.agentActionId || row.id; + const subject = getInventorySubjectLabel(row.displayName || row.hostname || row.name, agentId); + setInventoryActionNotice(null); + setInventoryActionPending(row.rowKey, 'allow-reconnect', true); + try { + await MonitoringAPI.allowHostAgentReenroll(agentId); + setOptimisticRemovedHostAgents((prev) => prev.filter((item) => item.id !== agentId)); + setInventoryActionNotice({ + tone: 'info', + title: `Reconnect allowed for ${subject}`, + detail: 'Pulse will accept reports from it again the next time it checks in.', + }); + notificationStore.success(getUnifiedAgentAllowReconnectSuccessMessage(subject)); + } catch (err) { + logger.error('Failed to allow reconnect for host agent', err); + notificationStore.error(getUnifiedAgentAllowReconnectErrorMessage(subject)); + } finally { + setInventoryActionPending(row.rowKey, 'allow-reconnect', false); + } + }; + + const handleAllowDockerReconnect = async (row: UnifiedAgentRow) => { + const agentId = row.dockerActionId || row.id; + const subject = getInventorySubjectLabel(row.displayName || row.hostname || row.name, agentId); + setInventoryActionNotice(null); + setInventoryActionPending(row.rowKey, 'allow-reconnect', true); + try { + await MonitoringAPI.allowDockerRuntimeReenroll(agentId); + setOptimisticRemovedDockerHosts((prev) => prev.filter((item) => item.id !== agentId)); + setInventoryActionNotice({ + tone: 'info', + title: `Reconnect allowed for ${subject}`, + detail: 'Pulse will accept reports from it again the next time it checks in.', + }); + notificationStore.success(getUnifiedAgentAllowReconnectSuccessMessage(subject)); + } catch (err) { + logger.error('Failed to allow reconnect for docker runtime', err); + notificationStore.error(getUnifiedAgentAllowReconnectErrorMessage(subject)); + } finally { + setInventoryActionPending(row.rowKey, 'allow-reconnect', false); + } + }; + + const handleRemoveKubernetesCluster = async (row: UnifiedAgentRow) => { + const clusterId = row.kubernetesActionId || row.id; + const subject = getInventorySubjectLabel(row.name, 'this cluster'); + + setInventoryActionNotice(null); + setInventoryActionPending(row.rowKey, 'stop-monitoring', true); + try { + await MonitoringAPI.deleteKubernetesCluster(clusterId); + reconcileRemovedKubernetesCluster(clusterId, row.name); + setInventoryActionNotice({ + tone: 'success', + title: `Monitoring stopped for ${subject}`, + detail: + 'Pulse removed it from active reporting and will ignore new reports until you allow reconnect. You can review it in Ignored by Pulse below.', + showRecoveryQueueLink: true, + }); + notificationStore.success(getUnifiedAgentStopMonitoringSuccessMessage(subject)); + } catch (err) { + logger.error('Failed to stop monitoring kubernetes cluster', err); + notificationStore.error(getUnifiedAgentStopMonitoringErrorMessage(subject)); + } finally { + setInventoryActionPending(row.rowKey, 'stop-monitoring', false); + setStopMonitoringDialog(null); + } + }; + + const handleAllowKubernetesReconnect = async (row: UnifiedAgentRow) => { + const clusterId = row.kubernetesActionId || row.id; + const subject = getInventorySubjectLabel(row.name, clusterId); + setInventoryActionNotice(null); + setInventoryActionPending(row.rowKey, 'allow-reconnect', true); + try { + await MonitoringAPI.allowKubernetesClusterReenroll(clusterId); + setOptimisticRemovedKubernetesClusters((prev) => + prev.filter((item) => item.id !== clusterId), + ); + setInventoryActionNotice({ + tone: 'info', + title: `Reconnect allowed for ${subject}`, + detail: 'Pulse will accept reports from it again the next time it checks in.', + }); + notificationStore.success(getUnifiedAgentAllowReconnectSuccessMessage(subject)); + } catch (err) { + logger.error('Failed to allow reconnect for kubernetes cluster', err); + notificationStore.error(getUnifiedAgentAllowReconnectErrorMessage(subject)); + } finally { + setInventoryActionPending(row.rowKey, 'allow-reconnect', false); + } + }; + + // Clear pending state when agent reports matching the expected value, or after timeout + createEffect(() => { + const pending = pendingCommandConfig(); + const agents = agentByActionId(); + const now = Date.now(); + const TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes + + // Check if any pending config now matches the reported state or has timed out + let updated = false; + const newPending = { ...pending }; + const timedOut: string[] = []; + + for (const agentId of Object.keys(pending)) { + const entry = pending[agentId]; + const agent = agents.get(agentId); + + const agentCommandsEnabled = agent ? getCommandsEnabled(agent) : undefined; + if ( + agent && + typeof agentCommandsEnabled === 'boolean' && + agentCommandsEnabled === entry.enabled + ) { + // Agent confirmed the change + delete newPending[agentId]; + updated = true; + } else if (now - entry.timestamp > TIMEOUT_MS) { + // Timed out waiting for agent + delete newPending[agentId]; + const agentLabel = agent ? agent.identity?.hostname || agent.name || agentId : agentId; + timedOut.push(agentLabel); + updated = true; + } + } + + if (updated) { + setPendingCommandConfig(newPending); + if (timedOut.length > 0) { + notificationStore.warning( + `Config sync timed out for ${timedOut.join(', ')}. Agent may be offline.`, + ); + } + } + }); + + createEffect(() => { + const rows = filteredActiveRows(); + const selectedKey = expandedRowKey(); + + if (rows.length === 0) { + if (selectedKey !== null) { + setExpandedRowKey(null); + } + return; + } + + if (selectedKey && !rows.some((row) => row.rowKey === selectedKey)) { + setExpandedRowKey(null); + } + }); + + createEffect(() => { + const rows = filteredMonitoringStoppedRows(); + const selectedKey = selectedIgnoredRowKey(); + + if (rows.length === 0) { + if (selectedKey !== null) { + setSelectedIgnoredRowKey(null); + } + return; + } + + if (selectedKey && !rows.some((row) => row.rowKey === selectedKey)) { + setSelectedIgnoredRowKey(null); + } + }); + + const reportingColumns = [ + { + key: 'name', + label: 'Name', + render: (row: UnifiedAgentRow) => { + const selected = () => expandedRowKey() === row.rowKey; + const agentName = row.displayName || row.hostname || row.name; + const reportingSummary = getRowReportingSummary(row); + return ( +
+
+
{row.name}
+ +
{row.hostname}
+
+ +
{reportingSummary}
+
+
+ +
+ ); + }, + }, + { + key: 'capabilities', + label: 'Reporting surfaces', + render: (row: UnifiedAgentRow) => ( +
+ + {(cap) => ( +
+ + {getCapabilitySurfaceLabel(cap)} + +
+ )} +
+
+ ), + }, + { + key: 'status', + label: 'Status', + render: (row: UnifiedAgentRow) => { + const statusPresentation = () => + getUnifiedAgentStatusPresentation(row.status, row.healthStatus); + return ( + + {statusPresentation().label} + + ); + }, + }, + { + key: 'lastSeen', + label: 'Last Seen', + render: (row: UnifiedAgentRow) => ( + + {getUnifiedAgentLastSeenLabel(row, MONITORING_STOPPED_STATUS_LABEL)} + + ), + }, + { + key: 'actions', + label: 'Actions', + align: 'right' as const, + render: (row: UnifiedAgentRow) => { + const isRemoved = () => row.status === 'removed'; + const isKubernetes = () => + row.capabilities.includes('kubernetes') && !row.capabilities.includes('agent'); + const pendingAction = () => getPendingInventoryAction(row.rowKey); + const isStopping = () => pendingAction() === 'stop-monitoring'; + const isAllowingReconnect = () => pendingAction() === 'allow-reconnect'; + const canRemove = () => { + const needsAgent = row.capabilities.includes('agent') && !row.agentActionId; + const needsDocker = + row.capabilities.includes('docker') && !row.dockerActionId && !row.agentActionId; + return !needsAgent && !needsDocker; + }; + return ( + openStopMonitoringDialog(row)} + disabled={!canRemove() || Boolean(pendingAction())} + title={!canRemove() ? 'Agent ID unavailable for removal' : undefined} + class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-2.5 py-1.5 text-sm font-medium text-red-600 hover:bg-red-50 hover:text-red-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-red-400 dark:hover:bg-red-900 dark:hover:text-red-300" + > + {isStopping() ? 'Stopping…' : 'Stop monitoring'} + + } + > + + + } + > + handleAllowHostReconnect(row)} + disabled={Boolean(pendingAction())} + class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-2.5 py-1.5 text-sm font-medium text-blue-600 hover:bg-blue-50 hover:text-blue-900 dark:text-blue-400 dark:hover:bg-blue-900 dark:hover:text-blue-300" + > + {isAllowingReconnect() + ? 'Allowing reconnect…' + : ALLOW_RECONNECT_LABEL} + + } + > + + + } + > + + + + ); + }, + }, + ]; + + const renderSelectedActiveRowDetails = ( + rowAccessor: () => UnifiedAgentRow, + ) => { + const row = () => rowAccessor(); + const isKubernetes = () => + row().capabilities.includes('kubernetes') && !row().capabilities.includes('agent'); + const resolvedAgentId = () => row().agentId || ''; + const assignment = () => + resolvedAgentId() ? assignmentByAgent().get(resolvedAgentId()) : undefined; + const isScopeUpdating = () => + resolvedAgentId() ? Boolean(pendingScopeUpdates()[resolvedAgentId()]) : false; + const agentName = () => row().displayName || row().hostname || row().name; + const surfaces = () => getRowSurfaceBreakdown(row()); + + const renderHeader = () => ( +
+
+
+
+
+ Selected reporting item +
+
{row().name}
+ +
{row().hostname}
+
+
+ Use surface controls to stop specific reporting without removing the machine. +
+
+
+ + {(cap) => ( + + {getAgentCapabilityLabel(cap)} + + )} + + + + Outdated + + + + + Linked + + +
+
+ +
+
+ ); + + const renderMachineOverview = () => ( +
+
Machine overview
+
+
+
+ Item ID: {row().id} +
+ +
+ Agent ID: {row().agentActionId} +
+
+ +
+ Container Agent ID:{' '} + {row().dockerActionId} +
+
+ +
+ Cluster ID:{' '} + {row().kubernetesActionId} +
+
+ +
+ Reporting agent ID:{' '} + {row().agentId} +
+
+ +
+ Linked node ID:{' '} + {row().linkedNodeId} +
+
+
+
+ +
+ Last seen {formatRelativeTime(row().lastSeen!)} ( + {formatAbsoluteTime(row().lastSeen!)}) +
+
+ +
+
Scope profile
+ + {row().scope.label} + + } + > + 0} + fallback={ + + {row().scope.label} + + } + > +
+ + + Updating… + +
+
+ } + > + + {row().scope.label} + +
+ +
+
+ +
+
+ Kubernetes connection +
+ +
+ Server:{' '} + {row().kubernetesInfo?.server} +
+
+ +
+ Context:{' '} + {row().kubernetesInfo?.context} +
+
+ +
+ Token:{' '} + {row().kubernetesInfo?.tokenName} +
+
+
+
+
+
+ +
+
+ Restart required to apply scope changes. +
+ +
+
+
+ ); + + const renderSurfaceControls = () => ( + 0}> +
+
+
+ Surface controls +
+
+ Stop a specific surface. Other surfaces keep reporting. +
+
+
+
+
Surface
+
What Pulse receives
+
ID
+
Control
+
+ + {(surface) => ( +
+
{surface.label}
+
{surface.detail}
+
+ Not separately addressed} + > +
+
{surface.idLabel}
+
{surface.idValue}
+
+
+
+
+ Managed with host telemetry + } + > + + +
+
+ )} +
+
+
+
+ ); + + const renderMachineActions = () => ( +
+
Machine actions
+
Machine-level utilities.
+
+ + + + + + +
+ Use surface controls above to stop reporting without uninstalling. +
+
+
+ ); + + return ( +
+ {renderHeader()} +
+ {renderMachineOverview()} + {renderSurfaceControls()} + {renderMachineActions()} +
+
+ ); + }; + + const renderSelectedIgnoredRowDetails = ( + rowAccessor: () => UnifiedAgentRow, + ) => { + const row = () => rowAccessor(); + const pendingAction = () => getPendingInventoryAction(row().rowKey); + const isAllowingReconnect = () => pendingAction() === 'allow-reconnect'; + const reconnectLabel = () => getReconnectActionLabel(row()); + const blockedId = () => + row().dockerActionId || row().kubernetesActionId || row().agentActionId || row().id; + + const renderHeader = () => ( +
+
+
+
+ Selected ignored item +
+
{row().name}
+
+ Ignored by Pulse +
+
+ Pulse is blocking reports from this surface. +
+
+ +
+
+ ); + + const renderIgnoredSurface = () => ( +
+
Ignored surface
+
+
+
Ignored surface
+
What Pulse is ignoring
+
ID
+
Recovery
+
+
+
+ {row().capabilities.map(getCapabilitySurfaceLabel).join(', ')} +
+
+ Pulse will ignore new reports for this surface until reconnect is allowed. +
+
+ {blockedId()} +
+
Ready to return
+
+
+
+ ); + + const renderRecoveryAction = () => ( +
+
Recovery action
+
Allow this blocked ID to report again.
+
+ +
+ This only changes Pulse. It does not reinstall software. +
+
+
+ ); + + return ( +
+ {renderHeader()} +
+ {renderIgnoredSurface()} + {renderRecoveryAction()} +
+
+ ); + }; + + const renderStopMonitoringDialog = () => ( + { + if (!stopMonitoringDialog()) return; + const row = stopMonitoringDialog()!.row; + if (getPendingInventoryAction(row.rowKey)) return; + setStopMonitoringDialog(null); + }} + panelClass="max-w-lg" + closeOnBackdrop={ + !stopMonitoringDialog() || !getPendingInventoryAction(stopMonitoringDialog()!.row.rowKey) + } + ariaLabel="Confirm stop monitoring" + > + + {(dialog) => { + const row = () => dialog().row; + const pending = () => getPendingInventoryAction(row().rowKey) === 'stop-monitoring'; + const isKubernetes = () => + row().capabilities.includes('kubernetes') && !row().capabilities.includes('agent'); + const affectedSurfaces = () => getStopMonitoringSurfaces(row()); + return ( +
+
+

Stop monitoring?

+

+ Pulse will remove{' '} + {dialog().subject} from + active reporting. +

+
+
+
+

{dialog().scopeLabel} will stop in Pulse.

+

+ The remote system keeps running. Pulse will ignore future reports and move this + item into Ignored by Pulse until you allow reconnect. +

+
+ 0}> +
+

+ Pulse will stop these reporting surfaces +

+
+ + {(surface) => ( +
+
{surface.label}
+
{surface.detail}
+ +
+ {surface.idLabel}:{' '} + {surface.idValue} +
+
+
+ )} +
+
+
+
+
+

What stays unchanged

+

+ {isKubernetes() + ? 'The cluster itself is not uninstalled or shut down.' + : 'The host, containers, and installed agent binaries are not uninstalled or shut down.'} +

+
+
+
+ + +
+
+ ); + }} +
+
+ ); + + const renderInstallerSection = () => ( + } + bodyClass="space-y-5" + > + + {(handoff) => ( +
+
+
+

Security configured. Save these first-run credentials now.

+

+ This is the canonical handoff from first-run setup into Infrastructure Install. + Generate a scoped install token below before copying agent commands. +

+
+
+
+ Username +
+
{handoff().username}
+
+
+
+ Password +
+
+ {handoff().password} +
+
+
+
+ Admin API Token +
+
+ {handoff().apiToken} +
+
+
+
+
+ + + + +
+
+
+ )} +
+ +
+

Unified Agent is the default monitoring gateway.

+

+ Install it on each system you want Pulse to monitor. The installer auto-detects + available platforms on that machine and enables the right integrations. +

+
+ + +
+
+ +
+

+ 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). +

+ +
+
+
+
+ +
+
+
+

+ + 1 + + Generate API token +

+

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

+
+ +
+ setTokenName(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !isGeneratingToken()) { + 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" + /> + +
+ + +
+ + + + + Token {latestRecord()?.name} created. Commands below now + include this credential. + +
+
+
+ + +
+
+ Tokens are optional on this Pulse instance. Confirm to generate commands without + embedding a token. +
+ +
+
+ + +
+
+
+

+ + 2 + + Installation commands +

+

+ Generate a token above to unlock installation commands. +

+
+
+
+ + + +

+ Click "Generate token" above to see installation commands +

+
+
+
+ + +
+
+
+
+

+ + + 2 + + + Installation commands +

+

+ The installer auto-detects Docker, Kubernetes, and Proxmox on the target + machine. +

+
+
+ +
+ +
+ setCustomAgentUrl(e.currentTarget.value)} + placeholder={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" + /> +
+

+ Override the address agents use to connect to this server (e.g., use IP + address http://192.0.2.50:7655 if DNS fails). + + + Currently using auto-detected: {agentUrl()} + + +

+
+
+ +
+ setCustomCaPath(e.currentTarget.value)} + placeholder={ + 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" + /> +
+

+ Preserves custom trust for copied install, upgrade, and uninstall commands. + Shell commands pass --cacert to both the download and the + installer. Windows commands set PULSE_CACERT and use a + transport-aware PowerShell bootstrap for the initial script fetch. +

+
+ +
+ TLS verification disabled — skip cert checks + for self-signed setups. Not recommended for production. +
+
+ + + +
+ Pulse commands enabled — The agent will + accept diagnostic and fix commands from Pulse Patrol features. +
+
+
+ Config signing (optional) — Require signed + remote config payloads with{' '} + PULSE_AGENT_CONFIG_SIGNATURE_REQUIRED=true. Provide keys via{' '} + PULSE_AGENT_CONFIG_SIGNING_KEY (Pulse) and{' '} + PULSE_AGENT_CONFIG_PUBLIC_KEYS (agents). +
+
+ + +

+ {getSelectedInstallProfile().description} +

+ 0}> +

+ Adds flags to shell-based install commands:{' '} + {getInstallProfileFlags().join(' ')} +

+
+
+
+ +
+ + {(section) => ( +
+
+
{section.title}
+

{section.description}

+
+
+ + {(snippet) => { + const copyCommand = () => snippet.command; + const commandTelemetryCapability = () => { + const label = normalizeTelemetryPart(snippet.label) || 'install'; + return `${section.platform}:${installProfile()}:${label}`; + }; + + return ( +
+
+ {snippet.label} +
+
+ +
+                                    {copyCommand()}
+                                  
+
+ +

{snippet.note}

+
+
+ ); + }} +
+
+
+ )} +
+
+ +
+
+
Check installation status
+ +
+

+ Enter the hostname (or agent ID) from the machine you just installed. Pulse + returns the latest status instantly. +

+
+ { + setLookupValue(event.currentTarget.value); + setLookupError(null); + setLookupResult(null); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + void 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" + /> +
+ +

+ {lookupError()} +

+
+ + {(result) => { + const agent = () => result().agent!; + const lookupStatusPresentation = () => + getUnifiedAgentLookupStatusPresentation(agent().connected); + return ( +
+
+
+ {agent().displayName || agent().hostname} +
+
+ + {lookupStatusPresentation().label} + + + {agent().status || 'unknown'} + +
+
+
+ Last seen {formatRelativeTime(agent().lastSeen)} ( + {formatAbsoluteTime(agent().lastSeen)}) +
+ +
+ Agent version {agent().agentVersion} +
+
+
+ ); + }} +
+
+
+ + Troubleshooting + +
+
+

+ Auto-detection not working? +

+

+ If Docker, Kubernetes, or Proxmox isn't detected automatically, add these + flags to the install command: +

+
    +
  • + --enable-docker — Force + enable Docker/Podman monitoring +
  • +
  • + --enable-kubernetes — + Force enable Kubernetes monitoring +
  • +
  • + --enable-proxmox — Force + enable Proxmox integration (creates API token) +
  • +
  • + --proxmox-type pve|pbs{' '} + — Set Proxmox node mode explicitly +
  • +
  • + --disable-docker — Skip + Docker even if detected +
  • +
+
+
+
+
+
+ +
+
+

Uninstall agent

+

+ Run the appropriate command on your machine to remove the Pulse agent: +

+
+ Linux / macOS / FreeBSD +
+ +
+                    {getUninstallCommand()}
+                  
+
+
+

+ If the agent can't reach this server, run directly on the machine:{' '} + + sudo bash /var/lib/pulse-agent/install.sh --uninstall + {' '} + (TrueNAS:{' '} + + /data/pulse-agent/install.sh + + , Unraid:{' '} + + /boot/config/plugins/pulse-agent/install.sh + + ) +

+
+ + Windows (PowerShell as Administrator) + +
+ +
+                    {getWindowsUninstallCommand()}
+                  
+
+
+
+
+
+
+ ); + + const renderIgnoredSection = () => ( + +
+ } + bodyClass="space-y-4" + > + 0} + fallback={ +
+ {getMonitoringStoppedEmptyState(hasFilters())} +
+ } + > +
+
+
+
+ Browse ignored items +
+
+ Select an ignored item to open its recovery drawer. +
+
+
+ + {(row) => { + const pendingAction = () => getPendingInventoryAction(row.rowKey); + const isSelected = () => selectedIgnoredRowKey() === row.rowKey; + return ( + + ); + }} + +
+
+ + setSelectedIgnoredRowKey(null)} + layout="drawer-right" + panelClass="max-w-[720px]" + ariaLabel="Ignored item details" + > + + {(rowAccessor) => renderSelectedIgnoredRowDetails(rowAccessor)} + + +
+
+
+
+
+ ); + + const renderInventorySection = () => ( +
+
+

{inventoryStatusSummaryText()}

+
+ + } + bodyClass="space-y-4" + > +
+

{reportingCoverageSummaryText()}

+

+ This workspace does not list every asset Pulse has discovered. It focuses on systems + and runtimes that are actively checking in right now. +

+
+ +
+

+ Active reporting +

+

+ {filteredActiveRows().length} +

+

+ Item{filteredActiveRows().length === 1 ? '' : 's'} actively checking in to Pulse. +

+
+ + +
+ + + +

+ {linkedAgents().length} agent + {linkedAgents().length > 1 ? 's are' : ' is'} linked to Proxmox node + {linkedAgents().length > 1 ? 's' : ''} and flagged with a{' '} + Linked badge. +

+
+
+ + +
+
+ + + +
+

+ {outdatedAgents().length} outdated agent{' '} + {outdatedAgents().length > 1 ? 'binaries' : 'binary'} detected +

+

+ Older standalone agent binaries are deprecated. Expand a row to copy the upgrade + command. +

+
+
+
+
+ +
+
+ + +
+ +
+
+ Refine results +
+
+ + +
+
+ + +
+ +
+
+ +
+ + Showing {filteredActiveRows().length} of {activeRows().length} active records. + + 0}> + + {filteredMonitoringStoppedRows().length} item(s) are currently ignored by Pulse. + + +
+ +
+ 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. +
+ + + {(notice) => ( +
+
+
+

{notice().title}

+

{notice().detail}

+
+
+ + + + +
+
+
+ )} +
+ +
+
+
+
+ Browse reporting items +
+
+ Select a reporting item to open its details drawer. +
+
+ row.rowKey} + onRowClick={(row) => toggleAgentDetails(row.rowKey)} + /> +
+ + setExpandedRowKey(null)} + layout="drawer-right" + panelClass="max-w-[760px]" + ariaLabel="Reporting item details" + > + + {(rowAccessor) => renderSelectedActiveRowDetails(rowAccessor)} + + +
+
+ + {renderIgnoredSection()} +
+ ); + + return { + renderInstallerSection, + renderStopMonitoringDialog, + renderInventorySection, + }; +}; + +export type InfrastructureOperationsState = ReturnType;