diff --git a/frontend-modern/src/components/Settings/DockerAgents.tsx b/frontend-modern/src/components/Settings/DockerAgents.tsx deleted file mode 100644 index e92d784..0000000 --- a/frontend-modern/src/components/Settings/DockerAgents.tsx +++ /dev/null @@ -1,1580 +0,0 @@ -import { Component, createSignal, Show, For, onMount, createEffect, createMemo } from 'solid-js'; -import { useWebSocket } from '@/App'; -import { Card } from '@/components/shared/Card'; -import { formatRelativeTime, formatAbsoluteTime } from '@/utils/format'; -import { MonitoringAPI } from '@/api/monitoring'; -import { SecurityAPI } from '@/api/security'; -import { notificationStore } from '@/stores/notifications'; -import type { SecurityStatus } from '@/types/config'; -import type { DockerHost } from '@/types/api'; -import type { APITokenRecord } from '@/api/security'; -import { DOCKER_REPORT_SCOPE } from '@/constants/apiScopes'; -import { resolveHostRuntime } from '@/components/Docker/runtimeDisplay'; -import { copyToClipboard } from '@/utils/clipboard'; -import { getPulseBaseUrl } from '@/utils/url'; -import { logger } from '@/utils/logger'; - - -export const DockerAgents: Component = () => { - const { state } = useWebSocket(); - - let hasLoggedSecurityStatusError = false; - - const [showHidden, setShowHidden] = createSignal(false); - - const allDockerHosts = () => state.dockerHosts || []; - - const dockerHosts = createMemo(() => { - const all = allDockerHosts(); - const includeHidden = showHidden(); - let filtered = includeHidden ? all : all.filter(host => !host.hidden); - - if (!includeHidden) { - filtered = filtered.filter(host => { - if (!host.pendingUninstall) { - return true; - } - const status = host.command?.status; - return status === 'failed' || status === 'expired'; - }); - } - - return filtered; - }); - - const hiddenCount = () => allDockerHosts().filter(host => host.hidden).length; - - const pendingHosts = createMemo(() => - allDockerHosts().filter(host => { - if (host.pendingUninstall) return true; - const status = host.command?.status; - return status === 'queued' || status === 'dispatched' || status === 'acknowledged' || status === 'completed'; - }), - ); - - const removedHosts = () => state.removedDockerHosts ?? []; - const hasRemovedHosts = () => removedHosts().length > 0; - - const [removingHostId, setRemovingHostId] = createSignal(null); - const [showRemoveModal, setShowRemoveModal] = createSignal(false); - const [hostToRemoveId, setHostToRemoveId] = createSignal(null); - const [uninstallCommandCopied, setUninstallCommandCopied] = createSignal(false); - const [removeActionLoading, setRemoveActionLoading] = createSignal< - 'queue' | 'force' | 'hide' | 'awaitingCommand' | null - >(null); - const [showAdvancedOptions, setShowAdvancedOptions] = createSignal(false); - const [securityStatus, setSecurityStatus] = createSignal(null); - const [isGeneratingToken, setIsGeneratingToken] = createSignal(false); - const [currentToken, setCurrentToken] = createSignal(null); - const [latestRecord, setLatestRecord] = createSignal(null); - const [tokenName, setTokenName] = createSignal(''); - const [commandQueuedTime, setCommandQueuedTime] = createSignal(null); - const [elapsedSeconds, setElapsedSeconds] = createSignal(0); - const [editingHostId, setEditingHostId] = createSignal(null); - const [editingDisplayName, setEditingDisplayName] = createSignal(''); - const [savingDisplayName, setSavingDisplayName] = createSignal(false); - - const pulseUrl = () => getPulseBaseUrl(); - - const TOKEN_PLACEHOLDER = ''; - - const hostToRemove = createMemo(() => { - const id = hostToRemoveId(); - if (!id) return null; - return (state.dockerHosts || []).find(host => host.id === id) ?? null; - }); - - const getDisplayName = (host: DockerHost | { id: string; displayName?: string | null; hostname?: string | null; customDisplayName?: string | null }) => { - if ('customDisplayName' in host && host.customDisplayName) { - return host.customDisplayName; - } - return host.displayName || host.hostname || host.id; - }; - - const describeRuntime = (host: DockerHost) => { - const runtimeInfo = resolveHostRuntime(host); - const version = host.runtimeVersion || host.dockerVersion || ''; - return version ? `${runtimeInfo.label} ${version}` : runtimeInfo.label; - }; - - const modalDisplayName = () => { - const host = hostToRemove(); - return host ? getDisplayName(host) : ''; - }; - - const modalHostname = () => { - const host = hostToRemove(); - return host?.hostname || host?.id || ''; - }; - - const modalHostStatus = () => { - const host = hostToRemove(); - return host?.status || 'unknown'; - }; - - const modalHostIsOnline = () => modalHostStatus().toLowerCase() === 'online'; - const modalHostHidden = () => Boolean(hostToRemove()?.hidden); - const modalCommand = createMemo(() => hostToRemove()?.command ?? null); - const modalCommandStatus = createMemo(() => modalCommand()?.status ?? null); - const modalCommandInProgress = createMemo(() => { - const status = modalCommandStatus(); - return status === 'queued' || status === 'dispatched' || status === 'acknowledged'; - }); - const modalCommandFailed = createMemo(() => modalCommandStatus() === 'failed'); - const modalCommandCompleted = createMemo(() => modalCommandStatus() === 'completed'); - const modalCommandProgress = createMemo(() => { - const cmd = modalCommand(); - if (!cmd) return []; - - const statusOrder: Record = { - queued: 0, - dispatched: 1, - acknowledged: 2, - completed: 3, - failed: 4, - expired: 5, - }; - const currentIndex = statusOrder[cmd.status] ?? 0; - const steps = [ - { key: 'queued', label: 'Stop command queued' }, - { key: 'dispatched', label: 'Instruction delivered to the agent' }, - { key: 'acknowledged', label: 'Agent acknowledged the stop request' }, - { key: 'completed', label: 'Agent disabled the service and removed autostart' }, - ]; - - return steps.map((step) => { - const stepIndex = statusOrder[step.key] ?? 0; - return { - label: step.label, - done: currentIndex > stepIndex, - active: currentIndex === stepIndex, - }; - }); - }); - - const modalCommandTimedOut = createMemo(() => { - return modalCommandInProgress() && elapsedSeconds() > 120; // 2 minutes - }); - - const modalLastHeartbeat = createMemo(() => { - const host = hostToRemove(); - return host?.lastSeen ? formatRelativeTime(host.lastSeen) : null; - }); - - const modalHostPendingUninstall = createMemo(() => Boolean(hostToRemove()?.pendingUninstall)); - const modalHasCommand = createMemo(() => Boolean(modalCommand())); - const [hasShownCommandCompletion, setHasShownCommandCompletion] = createSignal(false); - - const formatElapsedTime = (seconds: number) => { - if (seconds < 60) { - return `${seconds}s`; - } - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - return `${mins}m ${secs}s`; - }; - - type RemovalStatusTone = 'info' | 'success' | 'danger'; - - const removalBadgeClassMap: Record = { - info: 'inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-blue-700 dark:bg-blue-900/40 dark:text-blue-200', - success: - 'inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200', - danger: - 'inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-red-600 dark:bg-red-900/40 dark:text-red-200', - }; - - const removalTextClassMap: Record = { - info: 'text-blue-700 dark:text-blue-300', - success: 'text-emerald-700 dark:text-emerald-300', - danger: 'text-red-600 dark:text-red-300', - }; - - const getRemovalStatusInfo = (host: DockerHost): { label: string; tone: RemovalStatusTone } | null => { - const status = host.command?.status ?? null; - - switch (status) { - case 'failed': - return { - label: host.command?.failureReason || 'Pulse could not stop the agent automatically.', - tone: 'danger', - }; - case 'expired': - return { - label: 'Stop command expired before the agent responded.', - tone: 'danger', - }; - case 'completed': - return { - label: 'Agent stopped. Pulse will hide this host after the next missed heartbeat.', - tone: 'success', - }; - case 'acknowledged': - return { label: 'Agent acknowledged the stop command—waiting for shutdown.', tone: 'info' }; - case 'dispatched': - return { label: 'Instruction delivered to the agent.', tone: 'info' }; - case 'queued': - return { label: 'Stop command queued; waiting to reach the agent.', tone: 'info' }; - default: - if (host.pendingUninstall) { - return { label: 'Marked for uninstall; waiting for agent confirmation.', tone: 'info' }; - } - return null; - } - }; - - createEffect(() => { - if (!showRemoveModal()) return; - const id = hostToRemoveId(); - const host = hostToRemove(); - if (id && !host) { - closeRemoveModal(); - } - }); - - createEffect(() => { - if (!showRemoveModal()) { - return; - } - if (removeActionLoading() === 'awaitingCommand') { - if (modalHasCommand() || modalHostPendingUninstall() || modalCommandFailed()) { - setRemoveActionLoading(null); - } - } - }); - - // Track elapsed time for command execution - createEffect(() => { - const cmd = modalCommand(); - if (!cmd) { - setCommandQueuedTime(null); - setElapsedSeconds(0); - return; - } - - // Set queued time when command first appears - if (cmd.createdAt && !commandQueuedTime()) { - setCommandQueuedTime(new Date(cmd.createdAt)); - } - - // Update elapsed time every second while command is in progress - if (modalCommandInProgress()) { - const interval = setInterval(() => { - const queuedTime = commandQueuedTime(); - if (queuedTime) { - const now = new Date(); - const elapsed = Math.floor((now.getTime() - queuedTime.getTime()) / 1000); - setElapsedSeconds(elapsed); - } - }, 1000); - - return () => clearInterval(interval); - } - }); - - createEffect(() => { - if (!showRemoveModal()) { - return; - } - if (modalCommandCompleted() && !hasShownCommandCompletion()) { - setHasShownCommandCompletion(true); - notificationStore.success('Agent stopped. Pulse will hide this host after the next heartbeat.', 5000); - if (typeof window !== 'undefined') { - window.setTimeout(() => { - closeRemoveModal(); - }, 1200); - } else { - closeRemoveModal(); - } - } - }); - - onMount(() => { - if (typeof window === 'undefined') { - return; - } - - const fetchSecurityStatus = async () => { - try { - const response = await fetch('/api/security/status', { credentials: 'include' }); - if (response.ok) { - const data = (await response.json()) as SecurityStatus; - setSecurityStatus(data); - } - } catch (err) { - if (!hasLoggedSecurityStatusError) { - hasLoggedSecurityStatusError = true; - logger.error('Failed to load security status', err); - } - } - }; - fetchSecurityStatus(); - }); - - const showInstallCommand = () => !requiresToken() || Boolean(currentToken()); - - const handleGenerateToken = async () => { - if (isGeneratingToken()) return; - setIsGeneratingToken(true); - try { - const name = tokenName().trim() || `Container host ${new Date().toISOString().slice(0, 10)}`; - const { token, record } = await SecurityAPI.createToken(name, [DOCKER_REPORT_SCOPE]); - - setCurrentToken(token); - setLatestRecord(record); - setTokenName(''); - notificationStore.success('Token generated and inserted into the command below.', 4000); - } catch (err) { - logger.error('Failed to generate API token', err); - const errorMsg = err instanceof Error ? err.message : 'Unknown error'; - notificationStore.error(`Failed to generate API token: ${errorMsg}`, 8000); - } finally { - setIsGeneratingToken(false); - } - }; - - const requiresToken = () => { - const status = securityStatus(); - if (status) { - return status.requiresAuth || status.apiTokenConfigured; - } - return true; - }; - - // Always return command template with placeholder; the UI replaces it with the selected token. - const getInstallCommandTemplate = () => { - const url = pulseUrl(); - const tokenValue = requiresToken() ? TOKEN_PLACEHOLDER : 'disabled'; - const tokenSegment = `--token '${tokenValue}'`; - return `curl -fsSL '${url}/install.sh' | bash -s -- --url '${url}' ${tokenSegment} --enable-docker`; - }; - - const getUninstallCommand = () => { - const url = pulseUrl(); - return `curl -fsSL '${url}/install.sh' | bash -s -- --uninstall`; - }; - - const getSystemdService = () => { - const token = requiresToken() ? TOKEN_PLACEHOLDER : 'disabled'; - return `[Unit] -Description=Pulse Unified Agent -After=network-online.target docker.service -Wants=network-online.target - -[Service] -Type=simple -ExecStart=/usr/local/bin/pulse-agent --url ${pulseUrl()} --token ${token} --interval 30s --enable-docker -Restart=always -RestartSec=5s -User=root - -[Install] -WantedBy=multi-user.target`; - }; - - const getAllowReenrollCommand = (hostId: string) => { - const url = pulseUrl(); - return `curl -X POST -H "X-API-Token: " ${url}/api/agents/docker/hosts/${hostId}/allow-reenroll`; - }; - - const handleAllowReenroll = async (hostId: string, label: string) => { - try { - await MonitoringAPI.allowDockerHostReenroll(hostId); - notificationStore.success(`Allowed ${label} to report again`, 4000); - } catch (error) { - logger.error('Failed to allow host re-enroll', error); - const message = - error instanceof Error - ? error.message - : 'Failed to clear the removal block. Confirm your account has docker:manage access.'; - notificationStore.error(message, 8000); - } - }; - - const handleCopyAllowCommand = async (hostId: string, label: string) => { - const command = getAllowReenrollCommand(hostId); - const copied = await copyToClipboard(command); - if (copied) { - notificationStore.success(`Command copied for ${label}`, 3500); - } else { - notificationStore.error('Copy failed. You can still manually copy the snippet.', 4000); - } - }; - - const isRemovingHost = (hostId: string) => removingHostId() === hostId; - - const openRemoveModal = (host: DockerHost) => { - setHostToRemoveId(host.id); - setUninstallCommandCopied(false); - setRemoveActionLoading(null); - setShowAdvancedOptions(false); - setShowRemoveModal(true); - setHasShownCommandCompletion(false); - }; - - const closeRemoveModal = () => { - setShowRemoveModal(false); - setHostToRemoveId(null); - setUninstallCommandCopied(false); - setRemoveActionLoading(null); - setShowAdvancedOptions(false); - setHasShownCommandCompletion(false); - }; - - const handleQueueStopCommand = async () => { - const host = hostToRemove(); - if (!host || removeActionLoading()) return; - - const displayName = getDisplayName(host); - setRemovingHostId(host.id); - setRemoveActionLoading('queue'); - - try { - await MonitoringAPI.deleteDockerHost(host.id); - notificationStore.success(`Stop command sent to ${displayName}`, 3500); - setRemoveActionLoading('awaitingCommand'); - } catch (error) { - logger.error('Failed to queue host stop command', error); - const message = error instanceof Error ? error.message : 'Failed to send stop command'; - notificationStore.error(message, 8000); - setRemoveActionLoading(null); - } finally { - setRemovingHostId(null); - } - }; - - const handleHideHostFromModal = async () => { - const host = hostToRemove(); - if (!host || (removeActionLoading() && removeActionLoading() !== 'awaitingCommand')) return; - - const displayName = getDisplayName(host); - setRemovingHostId(host.id); - setRemoveActionLoading('hide'); - - try { - await MonitoringAPI.deleteDockerHost(host.id, { hide: true }); - notificationStore.success(`Hidden host ${displayName}`, 3500); - closeRemoveModal(); - } catch (error) { - logger.error('Failed to hide host', error); - const message = error instanceof Error ? error.message : 'Failed to hide host'; - notificationStore.error(message, 8000); - } finally { - setRemovingHostId(null); - setRemoveActionLoading(null); - } - }; - - const handleRemoveHostNow = async () => { - const host = hostToRemove(); - if (!host || (removeActionLoading() && removeActionLoading() !== 'awaitingCommand')) return; - - const displayName = getDisplayName(host); - setRemovingHostId(host.id); - setRemoveActionLoading('force'); - - try { - await MonitoringAPI.deleteDockerHost(host.id, { force: true }); - notificationStore.success(`Removed host ${displayName}`, 3500); - closeRemoveModal(); - } catch (error) { - logger.error('Failed to remove host', error); - const message = error instanceof Error ? error.message : 'Failed to remove host'; - notificationStore.error(message, 8000); - } finally { - setRemovingHostId(null); - setRemoveActionLoading(null); - } - }; - - const handleCleanupOfflineHost = async (hostId: string, displayName: string) => { - if (isRemovingHost(hostId)) return; - - setRemovingHostId(hostId); - - try { - await MonitoringAPI.deleteDockerHost(hostId, { force: true }); - notificationStore.success(`Removed host ${displayName}`, 3500); - if (hostToRemoveId() === hostId) { - closeRemoveModal(); - } - } catch (error) { - logger.error('Failed to remove host', error); - const message = error instanceof Error ? error.message : 'Failed to remove host'; - notificationStore.error(message, 8000); - } finally { - setRemovingHostId(null); - } - }; - - const handleUnhideHost = async (hostId: string, displayName: string) => { - if (isRemovingHost(hostId)) return; - - setRemovingHostId(hostId); - - try { - await MonitoringAPI.unhideDockerHost(hostId); - notificationStore.success(`Unhidden host ${displayName}`, 3500); - } catch (error) { - logger.error('Failed to unhide host', error); - const message = error instanceof Error ? error.message : 'Failed to unhide host'; - notificationStore.error(message, 8000); - } finally { - setRemovingHostId(null); - } - }; - - const startEditingDisplayName = (host: DockerHost) => { - setEditingHostId(host.id); - setEditingDisplayName(host.customDisplayName || ''); - }; - - const cancelEditingDisplayName = () => { - setEditingHostId(null); - setEditingDisplayName(''); - }; - - const saveDisplayName = async (hostId: string, originalName: string) => { - const newName = editingDisplayName().trim(); - - setSavingDisplayName(true); - try { - await MonitoringAPI.setDockerHostDisplayName(hostId, newName); - notificationStore.success(`Updated display name for ${originalName}`, 3500); - setEditingHostId(null); - setEditingDisplayName(''); - } catch (error) { - logger.error('Failed to update display name', error); - const message = error instanceof Error ? error.message : 'Failed to update display name'; - notificationStore.error(message, 8000); - } finally { - setSavingDisplayName(false); - } - }; - - return ( -
- - -
-

Recently removed container hosts

-

- Pulse is currently blocking these hosts because they were explicitly removed. Allow them to re-enroll or - copy the command below and run it with a token that includes the docker:manage scope. -

-
- -
- - {(entry) => { - const label = entry.displayName || entry.hostname || entry.id; - return ( -
-
-
-

{label}

-

Host ID: {entry.id}

-
-
- Removed {formatRelativeTime(entry.removedAt)} -
-
- -
- - -
-
- ); - }} -
-

- If you removed a host intentionally, you can simply ignore it—entries expire automatically after 24 hours. -

-
-
-
- - -
-

Enroll a container runtime

-

- Run the command below on any host running Docker or Podman. The installer will automatically detect your container runtime. -

-
- -
- -
-
-

Generate API token

-

- Create a fresh token scoped to {DOCKER_REPORT_SCOPE} -

-
- -
- setTokenName(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && !isGeneratingToken()) { - handleGenerateToken(); - } - }} - placeholder="Token name (optional)" - class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-900/60" - /> - -
- - -
- - - - - Token {latestRecord()?.name} created and inserted into the command below. - -
-
-
-
- - -
-
- - -
-
-                {getInstallCommandTemplate().replace(TOKEN_PLACEHOLDER, currentToken() || TOKEN_PLACEHOLDER)}
-              
-

- The unified installer downloads the agent, detects your container runtime, configures a systemd service, and starts reporting automatically. -

-
-
- - -

- Generate a token to see the install command. -

-
-
- -
- - Advanced options (uninstall & manual install) - -
-
-

Uninstall

-
- - {getUninstallCommand()} - - -
-

- Stops the agent, removes the binary, the systemd unit, and related files. -

-
- -
-

Manual installation

-
-

1. Build the binary

-
- - cd /opt/pulse -
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o pulse-agent ./cmd/pulse-agent -
-
-

- Building with CGO_ENABLED=0 keeps the binary fully static so it runs on hosts with older glibc (e.g. Debian 11). -

-

2. Copy to host

-
- - scp pulse-agent user@docker-host:/usr/local/bin/ -
- ssh user@docker-host chmod +x /usr/local/bin/pulse-agent -
-
-

3. Systemd template

-
- -
-
{getSystemdService()}
-
-
-

4. Enable & start

-
- - systemctl daemon-reload -
- systemctl enable --now pulse-agent -
-
-
-
-
-
-
- - {/* Remove Container Host Modal */} - -
-
-
-

- Remove container host "{modalDisplayName()}" -

-

- Pulse guides you through uninstalling the agent and safely cleaning up the host entry. -

-
- -
-
-
- - - -
-

Step 1 · Pulse stops the agent

-

- Pulse will queue a stop command with this host. The agent shuts down its system service (or Unraid autostart hook if present), confirms back to Pulse, and the row disappears as soon as that acknowledgement arrives—or after the next missed heartbeat. -

- -
-
- Command status - - {modalCommandStatus()} - -
- -

{modalCommand()?.message}

-
- -

- {modalCommand()?.failureReason || 'Pulse could not stop the agent automatically.'} -

-
-
-
- - -
-
- - - -
-

Stop command sent.

-

- Pulse is waiting for {modalHostname()} to pick up the shutdown instruction. This usually finishes within 30-60 seconds. -

-
-
-
-
- -
- -
-
- Progress - Completed} - > - {formatElapsedTime(elapsedSeconds())} elapsed - -
-
    - - {(step) => ( -
  • - - {step.label} -
  • - )} -
    -
-
-
- - -
-

Agent already stopped.

-

- Pulse is waiting for {modalHostname()} to miss its next heartbeat so the host can be removed automatically. No further action is required—this usually finishes within 60 seconds. -

- -

- Last heartbeat: {modalLastHeartbeat()}. Pulse will clear the entry after the next missed report. -

-
-
-
- - -
- - - -
-

- - This usually takes 30-60 seconds. - - - {' '}Last heartbeat: {modalLastHeartbeat()}. - -

-
-
-
- - -
-
- - - -
-

Command taking longer than expected

-

- The agent may be offline or experiencing issues. Consider using "Force remove now" below to skip the agent stop and remove the host immediately. -

-
-
-
-
-
-
- -
-

- Agent confirmed the stop. Pulse has already cleaned up everything it controls: -

-
    -
  • • Terminated the running pulse-agent process
  • -
  • • Disabled future auto-start (stops the systemd unit or removes the Unraid autostart script if one exists)
  • -
  • • Cleared the host from the dashboard so new reports won’t appear unexpectedly
  • -
-

- The binary remains at /usr/local/bin/pulse-agent for quick reinstalls. Use the uninstall command below if you prefer to remove it too. -

-
-
- -

- {modalCommand()?.failureReason} -

-
- -
- - Behind the scenes - - {modalCommand()?.id} - - -
-
    - - {(step) => ( -
  • - - {step.label} -
  • - )} -
    -
-

- Pulse responds to the agent's /api/agents/docker/report call with a stop command. The agent disables its service, removes - /boot/config/go.d/pulse-agent.sh, and posts back to - /api/agents/docker/commands/<id>/ack so Pulse knows it can remove the row. -

-
-
-
-
-
-
- - {/* Force remove option when command times out */} - -
-
-
- - - -
-

Skip waiting and remove now

-

- Still waiting after {formatElapsedTime(elapsedSeconds())}. Remove the host entry immediately without waiting for the agent. -

-
-
- -
-
-
- - -
-
-
-

Host is offline

-

- Tower stopped reporting. Remove it now to finish the cleanup. -

-
- -
-
-
- -
- - -
-
-
-

Manual uninstall command

-

- Prefer to remove everything manually? Run this full uninstall on {modalHostname()}. It removes the service, startup script, log, and binary. -

-
-
- - {getUninstallCommand()} - - -
- -

Command copied to clipboard.

-
-

- This command stops the agent, removes the systemd service (or Unraid autostart hook), deletes /var/log/pulse-agent.log, and uninstalls the binary. Pulse will notice the host is gone after the next heartbeat (≈2 minutes) and clean up the row automatically. -

-
-
-
-

Force remove immediately

-

- Skips the stop command and removes the entry right away. Any new report from this host will be rejected until you reinstall. -

-
- -
-
-
-

Hide the host instead

-

- Remove it from the default view without uninstalling the agent. It will reappear if the agent reports again. -

- -

Already hidden.

-
-
- -
-
-
-
-
- -
- -
-
-
-
- - {/* Active container hosts */} - -
- {/* Pending hosts banner */} - 0}> -
-
- - - -
-
-

- Stopping {pendingHosts().length} host{pendingHosts().length !== 1 ? 's' : ''} -

-

- Pulse has the stop command in flight. You can keep working—these hosts will disappear automatically once the agent shuts down or misses its next heartbeat. -

-
- -
- - {(host) => { - const label = getDisplayName(host); - const statusInfo = getRemovalStatusInfo(host) ?? { - label: 'Marked for uninstall; waiting for agent confirmation.', - tone: 'info' as RemovalStatusTone, - }; - const status = host.command?.status ?? (host.pendingUninstall ? 'pending' : 'unknown'); - const isOnline = host.status?.toLowerCase() === 'online'; - const lastSeenLabel = host.lastSeen ? formatRelativeTime(host.lastSeen) : 'Awaiting first report'; - const badgeText = - status === 'completed' - ? 'Agent stopped' - : status === 'acknowledged' - ? 'Acknowledged' - : status === 'dispatched' - ? 'Dispatched' - : status === 'queued' - ? 'Queued' - : 'Pending'; - - return ( -
-
-
-

{label}

-

{host.hostname || host.id}

- {badgeText} -
-
- Last seen {lastSeenLabel} -
-
-

{statusInfo.label}

-
- - - - -
-
- ); - }} -
-
-
-
-
-
- -
-
-

- Reporting container hosts ({dockerHosts().length}) -

-

- Use this list to enroll or retire agents. For live health and troubleshooting, open the container monitoring view. -

-
-
- - - - - Open container monitoring - - 0}> - - -
-
- - 0} - fallback={ -
-
- - - -
-

- No container agents are currently reporting. -

-

- Click "Show deployment instructions" above to get started. -

-
- } - > -
- - - - - - - - - - - - - {(host) => { - const isOnline = host.status?.toLowerCase() === 'online'; - const displayName = getDisplayName(host); - const commandStatus = host.command?.status ?? null; - const removalStatusInfo = getRemovalStatusInfo(host); - const runtimeInfo = resolveHostRuntime(host); - const commandInProgress = - commandStatus === 'queued' || - commandStatus === 'dispatched' || - commandStatus === 'acknowledged'; - const commandFailed = commandStatus === 'failed'; - const commandCompleted = commandStatus === 'completed'; - const offlineActionLabel = - commandFailed || commandStatus === 'expired' - ? 'Force remove host' - : removalStatusInfo?.tone === 'success' - ? 'Remove host' - : host.pendingUninstall - ? 'Skip wait and remove now' - : 'Remove offline host'; - const tokenRevoked = typeof host.tokenRevokedAt === 'number'; - const tokenRevokedRelative = tokenRevoked ? formatRelativeTime(host.tokenRevokedAt!) : ''; - - return ( - - - - - - - - ); - }} - - -
HostStatusAgent & runtimeLast SeenActions
- -
-
{displayName}
- -
- Original: {host.displayName || host.hostname} -
-
-
- - - } - > -
- setEditingDisplayName(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - saveDisplayName(host.id, displayName); - } else if (e.key === 'Escape') { - cancelEditingDisplayName(); - } - }} - placeholder="Custom display name" - disabled={savingDisplayName()} - /> - - -
-
-
{host.hostname}
-
- - {runtimeInfo.label} - -
- -
- {host.os} - - - - {host.architecture} -
-
- - - Hidden - - - -
- Token revoked {tokenRevokedRelative} -
-
-
-
- - {host.status || 'unknown'} - - - - - - - Stopping - - - - - - - - Failed - - - - - - - - Stopped - - -
- - {(info) => { - const details = info(); - return ( -
- {details.label} -
- ); - }} -
-
-
- {describeRuntime(host)} -
-
- Agent {host.agentVersion ? `v${host.agentVersion}` : 'not reporting'} - - (every {host.intervalSeconds}s) - -
-
-
- {host.lastSeen ? formatRelativeTime(host.lastSeen) : '—'} -
-
- {host.lastSeen ? formatAbsoluteTime(host.lastSeen) : 'Awaiting first report'} -
-
- - - - - openRemoveModal(host)} - disabled={isRemovingHost(host.id)} - > - {isRemovingHost(host.id) ? 'Working…' : 'Remove'} - - } - > - - - } - > - - - - } - > - - -
-
-
-
-
-
- ); -}; diff --git a/frontend-modern/src/components/Settings/HostAgents.tsx b/frontend-modern/src/components/Settings/HostAgents.tsx deleted file mode 100644 index ce0ea22..0000000 --- a/frontend-modern/src/components/Settings/HostAgents.tsx +++ /dev/null @@ -1,1283 +0,0 @@ -import { type Component, For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; -import type { JSX } from 'solid-js'; -import { useWebSocket } from '@/App'; -import type { Host, HostLookupResponse } from '@/types/api'; -import { Card } from '@/components/shared/Card'; -import { formatBytes, formatRelativeTime, formatUptime, formatAbsoluteTime } from '@/utils/format'; -import { copyToClipboard } from '@/utils/clipboard'; -import { getPulseBaseUrl } from '@/utils/url'; -import { notificationStore } from '@/stores/notifications'; -import { HOST_AGENT_SCOPE } from '@/constants/apiScopes'; -import type { SecurityStatus } from '@/types/config'; -import type { APITokenRecord } from '@/api/security'; -import { SecurityAPI } from '@/api/security'; -import { MonitoringAPI } from '@/api/monitoring'; -import { logger } from '@/utils/logger'; - -const TOKEN_PLACEHOLDER = ''; -const pulseUrl = () => getPulseBaseUrl(); - -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 `Host agent ${stamp}`; -}; - -type HostAgentPlatform = 'linux' | 'macos' | 'windows'; - -const commandsByPlatform: Record< - HostAgentPlatform, - { - title: string; - description: string; - snippets: { label: string; command: string; note?: string | JSX.Element }[]; - } -> = { - linux: { - title: 'Install on Linux', - description: - 'The unified installer downloads the agent binary and configures it as a systemd service.', - snippets: [ - { - label: 'Install with systemd', - command: `curl -fsSL ${pulseUrl()}/install.sh | bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`, - note: ( - - Automatically installs to /usr/local/bin/pulse-agent and creates /etc/systemd/system/pulse-agent.service. - - ), - }, - ], - }, - 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: `curl -fsSL ${pulseUrl()}/install.sh | bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`, - note: ( - - Creates /Library/LaunchDaemons/com.pulse.agent.plist 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: `irm ${pulseUrl()}/install.ps1 | iex`, - 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: `$env:PULSE_URL="${pulseUrl()}"; $env:PULSE_TOKEN="${TOKEN_PLACEHOLDER}"; irm ${pulseUrl()}/install.ps1 | iex`, - note: ( - - Non-interactive installation. Set environment variables before running to skip prompts. - - ), - }, - ], - }, -}; - -const computeHostStaleness = (host: Host) => { - const intervalSeconds = host.intervalSeconds && host.intervalSeconds > 0 ? host.intervalSeconds : 30; - const staleThresholdMs = Math.max(intervalSeconds * 1000 * 3, 60_000); - const lastSeenValue = - typeof host.lastSeen === 'number' - ? host.lastSeen - : Number.isFinite(Number(host.lastSeen)) - ? Number(host.lastSeen) - : NaN; - const lastSeenMs = Number.isFinite(lastSeenValue) ? lastSeenValue : null; - const isStale = lastSeenMs === null ? true : Date.now() - lastSeenMs >= staleThresholdMs; - - return { - isStale, - lastSeenMs, - staleThresholdMs, - }; -}; - -export const HostAgents: Component = () => { - const { state } = useWebSocket(); - - 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 [highlightedHostId, setHighlightedHostId] = createSignal(null); - let highlightTimer: ReturnType | null = null; - const [showRemoveModal, setShowRemoveModal] = createSignal(false); - const [hostToRemoveId, setHostToRemoveId] = createSignal(null); - const [removeActionLoading, setRemoveActionLoading] = createSignal<'remove' | null>(null); - const [uninstallCommandCopied, setUninstallCommandCopied] = createSignal(false); - const [uninstallCommandCopiedAt, setUninstallCommandCopiedAt] = createSignal(null); - const [hostRemovalCountdownSeconds, setHostRemovalCountdownSeconds] = createSignal(null); - const [uninstallConfirmed, setUninstallConfirmed] = createSignal(false); - - createEffect(() => { - if (requiresToken()) { - setConfirmedNoToken(false); - } else { - setCurrentToken(null); - setLatestRecord(null); - } - }); - - - const allHosts = createMemo(() => { - const list = state.hosts ?? []; - return [...list].sort((a, b) => (a.hostname || '').localeCompare(b.hostname || '')); - }); - - const hostTokenUsage = createMemo(() => { - type UsageHost = { id: string; label: string }; - const usage = new Map(); - for (const host of allHosts()) { - const tokenId = host.tokenId; - if (!tokenId) continue; - const label = host.displayName?.trim() || host.hostname || host.id; - const prev = usage.get(tokenId); - if (prev) { - usage.set(tokenId, { count: prev.count + 1, hosts: [...prev.hosts, { id: host.id, label }] }); - } else { - usage.set(tokenId, { count: 1, hosts: [{ id: host.id, label }] }); - } - } - return usage; - }); - - const reusedTokens = createMemo(() => { - const entries: { tokenId: string; hosts: { id: string; label: string }[] }[] = []; - hostTokenUsage().forEach((value, tokenId) => { - if (value.count > 1) { - entries.push({ tokenId, hosts: value.hosts }); - } - }); - return entries; - }); - - const renderTags = (host: Host) => { - const tags = host.tags ?? []; - if (!tags.length) return '—'; - return tags.join(', '); - }; - - const commandSections = createMemo(() => - Object.entries(commandsByPlatform).map(([platform, meta]) => ({ - platform: platform as HostAgentPlatform, - ...meta, - })), - ); - - const connectedFromStatus = (status: string | undefined | null) => { - if (!status) return false; - const value = status.toLowerCase(); - return value === 'online' || value === 'running' || value === 'healthy'; - }; - - const hostToRemove = createMemo(() => { - const id = hostToRemoveId(); - if (!id) return null; - return allHosts().find((host) => host.id === id) ?? null; - }); - - const hostRemovalDisplayName = () => { - const host = hostToRemove(); - return host ? host.displayName || host.hostname || host.id : ''; - }; - - const hostRemovalPlatform = createMemo(() => hostToRemove()?.platform?.toLowerCase() || ''); - const hostRemovalStatus = createMemo(() => { - const host = hostToRemove(); - return (host?.status || 'unknown').toLowerCase(); - }); - - const hostRemovalStatusLabel = () => hostToRemove()?.status || 'unknown'; - - const hostRemovalIsOnline = createMemo(() => { - const status = hostRemovalStatus(); - return status === 'online' || status === 'running' || status === 'healthy'; - }); - - const hostRemovalStaleness = createMemo(() => { - const host = hostToRemove(); - if (!host) { - return { isStale: false, lastSeenMs: null as number | null, staleThresholdMs: 90_000 }; - } - return computeHostStaleness(host); - }); - - const hostRemovalIsStale = createMemo(() => hostRemovalStaleness().isStale); - - const hostRemovalLastSeen = createMemo(() => { - const { lastSeenMs } = hostRemovalStaleness(); - if (!lastSeenMs) return null; - return { - relative: formatRelativeTime(lastSeenMs), - absolute: formatAbsoluteTime(lastSeenMs), - }; - }); - - const hostRemovalStaleThresholdSeconds = createMemo(() => { - const threshold = hostRemovalStaleness().staleThresholdMs; - return Math.max(Math.round(threshold / 1000), 0); - }); - - const hostRemovalUninstallCommand = createMemo(() => getHostUninstallCommand(hostToRemove())); - - const hostRemovalUninstallNote = () => { - const platform = hostRemovalPlatform(); - if (platform === 'macos' || platform === 'darwin' || platform === 'mac') { - return 'Unloads the launch agent, removes the plist, deletes the binary, and clears the local log.'; - } - if (platform === 'windows' || platform === 'win32' || platform === 'windows_nt') { - return 'Stops the Windows service, removes it, and deletes the installed binary and log. Run from an elevated PowerShell window.'; - } - return 'Stops the agent, removes the systemd unit, deletes the binary, and reloads systemd.'; - }; - - const formatCountdown = (seconds: number | null) => { - if (seconds === null || seconds < 0) return null; - if (seconds === 0) return 'any moment now'; - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - if (mins <= 0) { - return `${secs}s`; - } - if (secs === 0) { - return `${mins}m`; - } - return `${mins}m ${secs}s`; - }; - - const countdownLabel = createMemo(() => formatCountdown(hostRemovalCountdownSeconds())); - - const canRemoveHost = createMemo(() => uninstallCommandCopied() && uninstallConfirmed()); - - const openRemoveModal = (host: Host) => { - setHostToRemoveId(host.id); - setShowRemoveModal(true); - setRemoveActionLoading(null); - setUninstallCommandCopied(false); - setUninstallCommandCopiedAt(null); - setHostRemovalCountdownSeconds(null); - setUninstallConfirmed(false); - }; - - const closeRemoveModal = () => { - setShowRemoveModal(false); - setHostToRemoveId(null); - setRemoveActionLoading(null); - setUninstallCommandCopied(false); - setUninstallCommandCopiedAt(null); - setHostRemovalCountdownSeconds(null); - setUninstallConfirmed(false); - }; - - const performHostRemoval = async () => { - const host = hostToRemove(); - if (!host || removeActionLoading()) return; - - setRemoveActionLoading('remove'); - const displayName = host.displayName || host.hostname || host.id; - - try { - await MonitoringAPI.deleteHostAgent(host.id); - notificationStore.success(`Host "${displayName}" removed`, 4000); - closeRemoveModal(); - } catch (error) { - logger.error('Failed to remove host agent', error); - const message = error instanceof Error ? error.message : 'Failed to remove host. Please try again.'; - notificationStore.error(message, 6000); - } finally { - setRemoveActionLoading(null); - } - }; - - const handleRemoveHost = () => { - void performHostRemoval(); - }; - - createEffect(() => { - const current = lookupResult(); - if (!current) return; - - const targetId = current.host.id; - const targetHostname = current.host.hostname; - const hosts = allHosts(); - const match = hosts.find((host) => host.id === targetId || host.hostname === targetHostname); - if (!match) return; - - if (highlightTimer) { - clearTimeout(highlightTimer); - highlightTimer = null; - } - - setHighlightedHostId(match.id); - highlightTimer = setTimeout(() => { - setHighlightedHostId(null); - highlightTimer = null; - }, 10_000); - - const updated = { - success: true, - host: { - id: match.id, - hostname: match.hostname, - displayName: match.displayName, - status: match.status, - connected: connectedFromStatus(match.status), - lastSeen: match.lastSeen ?? Date.now(), - agentVersion: match.agentVersion ?? current.host.agentVersion, - }, - } satisfies HostLookupResponse; - - const currentHost = current.host; - if ( - currentHost.status === updated.host.status && - currentHost.connected === updated.host.connected && - currentHost.lastSeen === updated.host.lastSeen && - currentHost.agentVersion === updated.host.agentVersion && - (currentHost.displayName || '') === (updated.host.displayName || '') && - currentHost.hostname === updated.host.hostname - ) { - return; - } - - setLookupResult(updated); - setLookupError(null); - }); - - createEffect(() => { - if (!showRemoveModal()) return; - const id = hostToRemoveId(); - const host = hostToRemove(); - if (id && !host) { - closeRemoveModal(); - } - }); - - createEffect(() => { - if (!showRemoveModal()) { - setHostRemovalCountdownSeconds(null); - return; - } - - const updateCountdown = () => { - const host = hostToRemove(); - if (!host) { - setHostRemovalCountdownSeconds(null); - return; - } - - const { lastSeenMs, staleThresholdMs } = computeHostStaleness(host); - if (!lastSeenMs) { - setHostRemovalCountdownSeconds(null); - return; - } - - const elapsed = Date.now() - lastSeenMs; - const remaining = staleThresholdMs - elapsed; - setHostRemovalCountdownSeconds(remaining > 0 ? Math.ceil(remaining / 1000) : 0); - }; - - updateCountdown(); - const interval = setInterval(updateCountdown, 1000); - - return () => { - clearInterval(interval); - setHostRemovalCountdownSeconds(null); - }; - }); - - onMount(() => { - if (typeof window === 'undefined') { - return; - } - - const fetchSecurityStatus = async () => { - try { - const response = await fetch('/api/security/status', { credentials: 'include' }); - if (response.ok) { - const data = (await response.json()) as SecurityStatus; - setSecurityStatus(data); - } - } catch (err) { - if (!hasLoggedSecurityStatusError) { - hasLoggedSecurityStatusError = true; - logger.error('Failed to load security status', err); - } - } - }; - fetchSecurityStatus(); - }); - - - const requiresToken = () => { - const status = securityStatus(); - if (status) { - return status.requiresAuth || status.apiTokenConfigured; - } - return true; - }; - - onCleanup(() => { - if (highlightTimer) { - clearTimeout(highlightTimer); - highlightTimer = null; - } - }); - - 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(); - const { token, record } = await SecurityAPI.createToken(desiredName, [HOST_AGENT_SCOPE]); - - setCurrentToken(token); - setLatestRecord(record); - setTokenName(''); - setConfirmedNoToken(false); - notificationStore.success('Token generated and inserted into the command below.', 4000); - } catch (err) { - logger.error('Failed to generate host agent token', err); - notificationStore.error('Failed to generate host agent token. Confirm you are signed in as an administrator.', 6000); - } finally { - setIsGeneratingToken(false); - } - }; - - const resolvedToken = () => { - if (requiresToken()) { - return currentToken() || TOKEN_PLACEHOLDER; - } - return currentToken() || 'disabled'; - }; - - const handleLookup = async () => { - const query = lookupValue().trim(); - setLookupError(null); - - if (!query) { - setLookupResult(null); - setLookupError('Enter a hostname or host ID to check.'); - return; - } - - setLookupLoading(true); - try { - const result = await MonitoringAPI.lookupHost({ id: query, hostname: query }); - if (!result) { - setLookupResult(null); - setLookupError(`No host 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 : 'Host lookup failed.'; - setLookupResult(null); - setLookupError(message); - } finally { - setLookupLoading(false); - } - }; - - const getSystemdServiceUnit = () => `[Unit] -Description=Pulse Host Agent -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -ExecStart=/usr/local/bin/pulse-host-agent --url ${pulseUrl()} --token ${resolvedToken()} --interval 30s -Restart=always -RestartSec=5s -User=root - -[Install] -WantedBy=multi-user.target`; - - function getManualUninstallCommand(): string { - return `sudo systemctl stop pulse-agent && \\ -sudo systemctl disable pulse-agent && \\ -sudo rm -f /etc/systemd/system/pulse-agent.service && \\ -sudo rm -f /usr/local/bin/pulse-agent && \\ -sudo systemctl daemon-reload`; - } - - function getHostUninstallCommand(host: Host | null): string { - const platform = host?.platform?.toLowerCase(); - if (platform === 'macos' || platform === 'darwin' || platform === 'mac') { - return `launchctl unload /Library/LaunchDaemons/com.pulse.agent.plist >/dev/null 2>&1 || true && \\ -rm -f /Library/LaunchDaemons/com.pulse.agent.plist && \\ -sudo rm -f /usr/local/bin/pulse-agent && \\ -rm -f /var/log/pulse-agent.log`; - } - if (platform === 'windows' || platform === 'win32' || platform === 'windows_nt') { - return `Stop-Service -Name PulseAgent -ErrorAction SilentlyContinue; \\ -sc.exe delete PulseAgent; \\ -Remove-Item 'C:\\\\Program Files\\\\Pulse\\\\pulse-agent.exe' -Force -ErrorAction SilentlyContinue; \\ -Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-agent.log' -Force -ErrorAction SilentlyContinue`; - } - return getManualUninstallCommand(); - } - - return ( -
- -
-

Add a host agent

-

- Generate a token once, then run the matching command on Linux, macOS, or Windows to register new hosts. -

-
- -
- -
-
-

Generate API token

-

- Create a fresh token scoped to {HOST_AGENT_SCOPE}. -

-
- -
- setTokenName(event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key === 'Enter' && !isGeneratingToken()) { - handleGenerateToken(); - } - }} - placeholder="Token name (optional)" - class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-900/60" - /> - -
- - -
- - - - - Token {latestRecord()?.name} created ({latestRecord()?.prefix}…{latestRecord()?.suffix}). Commands below now include this credential. - -
-
- -
-
- - -
-
- Tokens are optional on this Pulse instance. Confirm to generate commands without embedding a token. -
- -
-
- - -
-

Installation commands

-

- Copy the command for the platform you are deploying. -

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

{section.description}

-
-
- - {(snippet) => { - const copyCommand = () => - snippet.command.replace(TOKEN_PLACEHOLDER, resolvedToken()); - - return ( -
-
-
- {snippet.label} -
- -
-
-                                  {copyCommand()}
-                                
- -

{snippet.note}

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

- Enter the hostname (or host 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 host ID" - class="flex-1 rounded-lg border border-blue-200 bg-white 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/60" - /> -
- -

{lookupError()}

-
- - {(result) => { - const host = () => result().host; - const statusBadgeClasses = () => - host().connected - ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' - : 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'; - return ( -
-
-
- {host().displayName || host().hostname} -
-
- - {host().connected ? 'Connected' : 'Not reporting yet'} - - - {host().status || 'unknown'} - -
-
-
- Last seen {formatRelativeTime(host().lastSeen)} ({formatAbsoluteTime(host().lastSeen)}) -
- -
- Agent version {host().agentVersion} -
-
-
- ); - }} -
-
-
- - Advanced options (manual install & uninstall) - -
-
-

Manual Linux install

-

- Build the agent from source and manage the service yourself instead of using the helper script. -

-

1. Build the binary

-
- - cd /opt/pulse -
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o pulse-agent ./cmd/pulse-agent -
-
-

2. Copy to host

-
- - scp pulse-agent user@host:/usr/local/bin/ -
- ssh user@host sudo chmod +x /usr/local/bin/pulse-agent -
-
-

3. Systemd service template

-
- -
-
{getSystemdServiceUnit()}
-
-
-

4. Enable & start

-
- - sudo systemctl daemon-reload -
- sudo systemctl enable --now pulse-host-agent -
-
-
-
-

Manual uninstall

-
- - {getManualUninstallCommand()} - - -
-

- Stops the agent, removes the systemd unit, and deletes the binary. -

-
-
-
-
-
- - -

- Generate a new token to unlock the install commands. -

-
- -

Confirm the no-token setup to continue.

-
-
-
- - -
-
-

Reporting hosts

- {allHosts().length} connected -
- - 0}> -
-
- - - -
-

Token re-use detected across host agents.

-

- Generate a new host-agent token per machine or set a unique --agent-id to stop hosts from overwriting each other. -

- - - -
- - {(item) => ( -
- - token {item.tokenId.slice(0, 6)}…{item.tokenId.slice(-4)} - - - used by {item.hosts.length} hosts: {item.hosts.map((host) => host.label).join(', ')} - -
- )} -
-
-
-
-
-
- - 0} - fallback={ -
-
- - - -
-

- No host agents are reporting yet. -

-

- Deploy the agent using the commands above to see hosts listed here. -

-
- } - > -
- - - - - - - - - - - - - - - {(host) => { - const staleness = computeHostStaleness(host); - const isStale = staleness.isStale; - const tokenRevokedAt = host.tokenRevokedAt; - const tokenRevoked = typeof tokenRevokedAt === 'number'; - const tokenUsageEntry = host.tokenId ? hostTokenUsage().get(host.tokenId) : undefined; - const tokenReused = tokenUsageEntry ? tokenUsageEntry.count > 1 : false; - const status = (host.status || 'unknown').toLowerCase(); - const isOnline = - status === 'online' || status === 'running' || status === 'healthy'; - const isHighlighted = highlightedHostId() === host.id; - const isRemovingThisHost = hostToRemoveId() === host.id && removeActionLoading() !== null; - - const baseRowClass = isStale - ? 'bg-gray-50 dark:bg-gray-800/50 opacity-60' - : 'bg-white dark:bg-gray-900'; - - const rowClass = - tokenRevoked && !isStale ? `${baseRowClass} opacity-60` : baseRowClass; - const highlightClass = isHighlighted - ? 'ring-2 ring-blue-500/70 dark:ring-blue-400/70 shadow-md' - : ''; - - const handleRemoveClick = () => { - openRemoveModal(host); - }; - - return ( - - - - - - - - - - - ); - }} - - -
HostStatusPlatformUptimeMemoryLast SeenTags -
-
- {host.displayName || host.hostname || host.id} -
-
- {host.hostname} -
- -
- - - - Token reused ({tokenUsageEntry?.count}) -
-
- -
- Agent {host.agentVersion} -
-
-
-
- - {host.status || 'unknown'} - - - - - - - No recent data - - - - - - Token revoked - - -
-
- {host.platform || '—'} - - {host.uptimeSeconds ? formatUptime(host.uptimeSeconds) : '—'} - - {host.memory?.total - ? `${formatBytes(host.memory.used ?? 0)} / ${formatBytes(host.memory.total)}` - : '—'} - -
- {host.lastSeen ? formatRelativeTime(host.lastSeen) : '—'} -
- -
- {formatAbsoluteTime(host.lastSeen!)} -
-
-
- {renderTags(host)} - - -
-
-
-
-
- - -
-
-
-

- Remove host "{hostRemovalDisplayName()}" -

-

- Walk through uninstalling the agent and cleaning up the entry in Pulse. -

-
- -
-
-
- - - -
-

Step 1 · Stop the agent on {hostRemovalDisplayName()}

-

- Copy the tailored uninstall script below, run it on the host, then confirm once the command finishes. It runs silently, so no terminal output is expected. -

-
-
- -
-
- Manual uninstall - -
- - {hostRemovalUninstallCommand()} - -

{hostRemovalUninstallNote()}

- -
-

Command copied.

-

This script runs silently—no CLI output is expected. Once it completes, mark it finished below.

- -

Copied {formatRelativeTime(uninstallCommandCopiedAt()!)}.

-
- - -

Click once you have run the script on {hostRemovalDisplayName()}.

-
-
-
-
-
- -
-
- Host status - - {hostRemovalStatusLabel()} - -
-
-
- Last heartbeat - - {hostRemovalLastSeen()?.relative ?? 'No reports yet'} - -
- -
- {hostRemovalLastSeen()?.absolute} -
-
-
- -
-

Host still reporting

-

- Pulse revokes the host's API token as soon as you remove it. After the uninstall script stops the service, the next heartbeat will fail and the agent will disappear within about {hostRemovalStaleThresholdSeconds()} seconds. -

- -

- Waiting for the next missed heartbeat ({countdownLabel()}). -

-
-
-
- -
-

- - Host offline -

-

- Pulse no longer receives heartbeats from {hostRemovalDisplayName()}. It’s safe to remove the entry now. -

-
-
-
-
- -
- - -

Run the uninstall script and mark it complete above.

-

Once confirmed, Pulse will enable the final removal step.

-
- } - > -
- -
-

- Removing a host revokes its API token so it cannot register again. With the service already uninstalled, Pulse just needs one more missed heartbeat to clear the row automatically. -

-
-
- -
-
-
- - - ); -}; - -export default HostAgents; diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 293c46b..f23fa14 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -24,8 +24,7 @@ import { } from '@/utils/apiClient'; import { NodeModal } from './NodeModal'; import { ChangePasswordModal } from './ChangePasswordModal'; -import { DockerAgents } from './DockerAgents'; -import { HostAgents } from './HostAgents'; +import { UnifiedAgents } from './UnifiedAgents'; import APITokenManager from './APITokenManager'; import { OIDCPanel } from './OIDCPanel'; import { QuickSecuritySetup } from './QuickSecuritySetup'; @@ -1231,8 +1230,7 @@ const Settings: Component = (props) => { label: 'Platforms', items: [ { id: 'proxmox', label: 'Proxmox', icon: ProxmoxIcon }, - { id: 'docker', label: 'Docker', icon: Boxes }, - { id: 'hosts', label: 'Hosts', icon: Monitor, iconProps: { strokeWidth: 2 } }, + { id: 'agents', label: 'Agents', icon: Monitor, iconProps: { strokeWidth: 2 } }, ], }, { @@ -3535,14 +3533,9 @@ const Settings: Component = (props) => { - {/* Docker Platform Tab */} - - - - - {/* Servers Platform Tab */} - - + {/* Unified Agents Tab */} + + {/* System General Tab */} diff --git a/frontend-modern/src/components/Settings/UnifiedAgents.tsx b/frontend-modern/src/components/Settings/UnifiedAgents.tsx new file mode 100644 index 0000000..0363b92 --- /dev/null +++ b/frontend-modern/src/components/Settings/UnifiedAgents.tsx @@ -0,0 +1,510 @@ +import { Component, createSignal, Show, For, onMount, createEffect, createMemo, onCleanup } from 'solid-js'; +import { useWebSocket } from '@/App'; +import { Card } from '@/components/shared/Card'; +import { formatRelativeTime, formatAbsoluteTime } from '@/utils/format'; +import { MonitoringAPI } from '@/api/monitoring'; +import { SecurityAPI } from '@/api/security'; +import { notificationStore } from '@/stores/notifications'; +import type { SecurityStatus } from '@/types/config'; +import type { Host, HostLookupResponse } from '@/types/api'; +import type { APITokenRecord } from '@/api/security'; +import { HOST_AGENT_SCOPE, DOCKER_REPORT_SCOPE } from '@/constants/apiScopes'; +import { copyToClipboard } from '@/utils/clipboard'; +import { getPulseBaseUrl } from '@/utils/url'; +import { logger } from '@/utils/logger'; + +const TOKEN_PLACEHOLDER = ''; +const pulseUrl = () => getPulseBaseUrl(); + +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}`; +}; + +type AgentPlatform = 'linux' | 'macos' | 'windows'; + +const commandsByPlatform: 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 it as a systemd service.', + snippets: [ + { + label: 'Install with systemd', + command: `curl -fsSL ${pulseUrl()}/install.sh | sudo bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`, + note: ( + + Automatically installs to /usr/local/bin/pulse-agent and creates /etc/systemd/system/pulse-agent.service. + + ), + }, + ], + }, + 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: `curl -fsSL ${pulseUrl()}/install.sh | sudo bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`, + note: ( + + Creates /Library/LaunchDaemons/com.pulse.agent.plist 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: `irm ${pulseUrl()}/install.ps1 | iex`, + 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: `$env:PULSE_URL="${pulseUrl()}"; $env:PULSE_TOKEN="${TOKEN_PLACEHOLDER}"; irm ${pulseUrl()}/install.ps1 | iex`, + note: ( + + Non-interactive installation. Set environment variables before running to skip prompts. + + ), + }, + ], + }, +}; + +export const UnifiedAgents: Component = () => { + const { state } = useWebSocket(); + + 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 [enableDocker, setEnableDocker] = createSignal(true); // Default to true for unified experience + + createEffect(() => { + if (requiresToken()) { + setConfirmedNoToken(false); + } else { + setCurrentToken(null); + setLatestRecord(null); + } + }); + + const commandSections = createMemo(() => + Object.entries(commandsByPlatform).map(([platform, meta]) => ({ + platform: platform as AgentPlatform, + ...meta, + })), + ); + + const connectedFromStatus = (status: string | undefined | null) => { + if (!status) return false; + const value = status.toLowerCase(); + return value === 'online' || value === 'running' || value === 'healthy'; + }; + + onMount(() => { + if (typeof window === 'undefined') { + return; + } + + const fetchSecurityStatus = async () => { + try { + const response = await fetch('/api/security/status', { credentials: 'include' }); + if (response.ok) { + const data = (await response.json()) as SecurityStatus; + setSecurityStatus(data); + } + } catch (err) { + if (!hasLoggedSecurityStatusError) { + hasLoggedSecurityStatusError = true; + logger.error('Failed to load security status', err); + } + } + }; + fetchSecurityStatus(); + }); + + 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 BOTH scopes + const scopes = [HOST_AGENT_SCOPE, DOCKER_REPORT_SCOPE]; + const { token, record } = await SecurityAPI.createToken(desiredName, scopes); + + setCurrentToken(token); + setLatestRecord(record); + setTokenName(''); + setConfirmedNoToken(false); + notificationStore.success('Token generated with Host and Docker 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 resolvedToken = () => { + if (requiresToken()) { + return currentToken() || TOKEN_PLACEHOLDER; + } + return currentToken() || 'disabled'; + }; + + const handleLookup = async () => { + const query = lookupValue().trim(); + setLookupError(null); + + if (!query) { + setLookupResult(null); + setLookupError('Enter a hostname or host ID to check.'); + return; + } + + setLookupLoading(true); + try { + const result = await MonitoringAPI.lookupHost({ id: query, hostname: query }); + if (!result) { + setLookupResult(null); + setLookupError(`No host 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 : 'Host lookup failed.'; + setLookupResult(null); + setLookupError(message); + } finally { + setLookupLoading(false); + } + }; + + const getDockerFlag = () => enableDocker() ? ' --enable-docker' : ''; + + const getUninstallCommand = () => { + return `curl -fsSL ${pulseUrl()}/install.sh | sudo bash -s -- --uninstall`; + }; + + return ( +
+ +
+

Add a unified agent

+

+ Monitor server metrics (CPU, RAM, Disk) and Docker containers with a single agent. +

+
+ +
+ +
+
+

Generate API token

+

+ Create a fresh token scoped for both Host and Docker monitoring. +

+
+ +
+ setTokenName(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !isGeneratingToken()) { + handleGenerateToken(); + } + }} + placeholder="Token name (optional)" + class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-900/60" + /> + +
+ + +
+ + + + + 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. +
+ +
+
+ + +
+
+

Installation commands

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

{section.description}

+
+
+ + {(snippet) => { + const copyCommand = () => { + let cmd = snippet.command.replace(TOKEN_PLACEHOLDER, resolvedToken()); + // Append docker flag if enabled + if (enableDocker()) { + // For PowerShell, we need to handle the env var or args differently + if (cmd.includes('$env:PULSE_URL')) { + // Env var style: add $env:PULSE_ENABLE_DOCKER="true"; + cmd = `$env:PULSE_ENABLE_DOCKER="true"; ` + cmd; + } else if (cmd.includes('irm')) { + // Simple irm style: no args passed to script directly in this snippet style + // Actually, the simple irm style relies on prompts, so flags don't apply directly unless we change the snippet + // But for the bash script, we append flags + if (!cmd.includes('irm')) { + cmd += getDockerFlag(); + } + } else { + cmd += getDockerFlag(); + } + } + return cmd; + }; + + return ( +
+
+
+ {snippet.label} +
+ +
+
+                                                                    {copyCommand()}
+                                                                
+ +

{snippet.note}

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

+ Enter the hostname (or host 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 host ID" + class="flex-1 rounded-lg border border-blue-200 bg-white 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/60" + /> +
+ +

{lookupError()}

+
+ + {(result) => { + const host = () => result().host; + const statusBadgeClasses = () => + host().connected + ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' + : 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'; + return ( +
+
+
+ {host().displayName || host().hostname} +
+
+ + {host().connected ? 'Connected' : 'Not reporting yet'} + + + {host().status || 'unknown'} + +
+
+
+ Last seen {formatRelativeTime(host().lastSeen)} ({formatAbsoluteTime(host().lastSeen)}) +
+ +
+ Agent version {host().agentVersion} +
+
+
+ ); + }} +
+
+
+ + Advanced options (uninstall & manual install) + +
+
+

Uninstall

+
+ + {getUninstallCommand()} + + +
+

+ Stops the agent, removes the binary, the systemd unit, and related files. +

+
+
+
+
+
+
+
+
+ ); +}; diff --git a/scripts/install.sh b/scripts/install.sh index d43b9a6..efff309 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -14,6 +14,12 @@ set -euo pipefail +# --- Check Root --- +if [[ $EUID -ne 0 ]]; then + echo "This script must be run as root. Please use sudo." + exit 1 +fi + # --- Configuration --- AGENT_NAME="pulse-agent" BINARY_NAME="pulse-agent"