diff --git a/cmd/pulse-host-agent/main.go b/cmd/pulse-host-agent/main.go index 069aa9710..b8ec5afbd 100644 --- a/cmd/pulse-host-agent/main.go +++ b/cmd/pulse-host-agent/main.go @@ -31,6 +31,13 @@ func main() { logger := zerolog.New(os.Stdout).With().Timestamp().Logger() cfg.Logger = &logger + // Check if we should run as a Windows service + if err := runAsWindowsService(cfg, logger); err != nil { + logger.Fatal().Err(err).Msg("Windows service failed") + } + + // If runAsWindowsService returns nil without error, we're not running as a service + // or we're on a non-Windows platform, so run normally agent, err := hostagent.New(cfg) if err != nil { logger.Fatal().Err(err).Msg("failed to initialise host agent") diff --git a/cmd/pulse-host-agent/service_stub.go b/cmd/pulse-host-agent/service_stub.go new file mode 100644 index 000000000..7abbd065e --- /dev/null +++ b/cmd/pulse-host-agent/service_stub.go @@ -0,0 +1,18 @@ +// +build !windows + +package main + +import ( + "github.com/rcourtman/pulse-go-rewrite/internal/hostagent" + "github.com/rs/zerolog" +) + +// runAsWindowsService is a no-op on non-Windows platforms +func runAsWindowsService(cfg hostagent.Config, logger zerolog.Logger) error { + return nil +} + +// runServiceDebug is a no-op on non-Windows platforms +func runServiceDebug(cfg hostagent.Config, logger zerolog.Logger) error { + return nil +} diff --git a/cmd/pulse-host-agent/service_windows.go b/cmd/pulse-host-agent/service_windows.go new file mode 100644 index 000000000..8b23cbbb9 --- /dev/null +++ b/cmd/pulse-host-agent/service_windows.go @@ -0,0 +1,167 @@ +// +build windows + +package main + +import ( + "context" + "fmt" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/hostagent" + "github.com/rs/zerolog" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/debug" + "golang.org/x/sys/windows/svc/eventlog" +) + +type windowsService struct { + cfg hostagent.Config + logger zerolog.Logger + eventLog *eventlog.Log +} + +func (ws *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (ssec bool, errno uint32) { + const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown + + changes <- svc.Status{State: svc.StartPending} + + // Log to Windows Event Log + if ws.eventLog != nil { + ws.eventLog.Info(1, "Pulse Host Agent service starting") + } + + agent, err := hostagent.New(ws.cfg) + if err != nil { + ws.logger.Error().Err(err).Msg("Failed to create host agent") + changes <- svc.Status{State: svc.Stopped} + return true, 1 + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start the agent in a goroutine + errChan := make(chan error, 1) + go func() { + ws.logger.Info(). + Str("pulse_url", ws.cfg.PulseURL). + Str("agent_id", ws.cfg.AgentID). + Dur("interval", ws.cfg.Interval). + Msg("Starting Pulse host agent as Windows service") + + if err := agent.Run(ctx); err != nil && err != context.Canceled { + errChan <- err + } + close(errChan) + }() + + changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} + ws.logger.Info().Msg("Host agent service is running") + if ws.eventLog != nil { + ws.eventLog.Info(1, fmt.Sprintf("Pulse Host Agent started successfully (URL: %s, Interval: %s)", ws.cfg.PulseURL, ws.cfg.Interval)) + } + + // Service control loop +loop: + for { + select { + case c := <-r: + switch c.Cmd { + case svc.Interrogate: + changes <- c.CurrentStatus + case svc.Stop, svc.Shutdown: + ws.logger.Info().Uint32("command", uint32(c.Cmd)).Msg("Received service control command") + if ws.eventLog != nil { + ws.eventLog.Info(1, "Pulse Host Agent received stop command") + } + changes <- svc.Status{State: svc.StopPending} + cancel() + break loop + default: + ws.logger.Warn().Uint32("command", uint32(c.Cmd)).Msg("Unexpected service control command") + } + case err := <-errChan: + if err != nil { + ws.logger.Error().Err(err).Msg("Agent error") + if ws.eventLog != nil { + ws.eventLog.Error(1, fmt.Sprintf("Pulse Host Agent error: %v", err)) + } + changes <- svc.Status{State: svc.Stopped} + return true, 1 + } + break loop + } + } + + // Wait for agent to stop gracefully (with timeout) + shutdownTimeout := time.NewTimer(10 * time.Second) + defer shutdownTimeout.Stop() + + select { + case <-errChan: + ws.logger.Info().Msg("Agent stopped gracefully") + if ws.eventLog != nil { + ws.eventLog.Info(1, "Pulse Host Agent stopped gracefully") + } + case <-shutdownTimeout.C: + ws.logger.Warn().Msg("Agent shutdown timeout, forcing stop") + if ws.eventLog != nil { + ws.eventLog.Warning(1, "Pulse Host Agent shutdown timeout") + } + } + + changes <- svc.Status{State: svc.Stopped} + return false, 0 +} + +func runAsWindowsService(cfg hostagent.Config, logger zerolog.Logger) error { + // Check if we're running as a Windows service + isService, err := svc.IsWindowsService() + if err != nil { + return fmt.Errorf("failed to determine if running as service: %w", err) + } + + if !isService { + // Not running as a service, run normally + return nil + } + + logger.Info().Msg("Running as Windows service") + + // Open Windows Event Log (best effort - don't fail if it doesn't work) + elog, err := eventlog.Open("PulseHostAgent") + if err != nil { + logger.Warn().Err(err).Msg("Could not open Windows Event Log, continuing without it") + elog = nil + } + defer func() { + if elog != nil { + elog.Close() + } + }() + + ws := &windowsService{ + cfg: cfg, + logger: logger, + eventLog: elog, + } + + // Run as a Windows service + err = svc.Run("PulseHostAgent", ws) + if err != nil { + if elog != nil { + elog.Error(1, fmt.Sprintf("Failed to run service: %v", err)) + } + return fmt.Errorf("failed to run Windows service: %w", err) + } + + return nil +} + +func runServiceDebug(cfg hostagent.Config, logger zerolog.Logger) error { + ws := &windowsService{ + cfg: cfg, + logger: logger, + } + return debug.Run("PulseHostAgent", ws) +} diff --git a/frontend-modern/public/download/pulse-host-agent-windows-amd64 b/frontend-modern/public/download/pulse-host-agent-windows-amd64 new file mode 100755 index 000000000..f95d628cf Binary files /dev/null and b/frontend-modern/public/download/pulse-host-agent-windows-amd64 differ diff --git a/frontend-modern/public/install-host-agent.ps1 b/frontend-modern/public/install-host-agent.ps1 new file mode 100644 index 000000000..618b2a8b2 --- /dev/null +++ b/frontend-modern/public/install-host-agent.ps1 @@ -0,0 +1,228 @@ +# Pulse Host Agent Installation Script for Windows +# +# Usage: +# iwr -useb http://pulse-server:7656/install-host-agent.ps1 | iex +# OR with parameters: +# $url = "http://pulse-server:7656"; $token = "your-token"; iwr -useb "$url/install-host-agent.ps1" | iex +# +# Parameters can be passed via environment variables or script parameters + +param( + [string]$PulseUrl = $env:PULSE_URL, + [string]$Token = $env:PULSE_TOKEN, + [string]$Interval = $env:PULSE_INTERVAL, + [string]$InstallPath = "C:\Program Files\Pulse", + [switch]$NoService +) + +$ErrorActionPreference = "Stop" + +# ANSI color codes for output +$Red = "`e[31m" +$Green = "`e[32m" +$Yellow = "`e[33m" +$Blue = "`e[34m" +$Reset = "`e[0m" + +function Write-Color { + param([string]$Color, [string]$Message) + Write-Host "${Color}${Message}${Reset}" +} + +function Write-Success { param([string]$msg) Write-Color $Green "✓ $msg" } +function Write-Error { param([string]$msg) Write-Color $Red "✗ $msg" } +function Write-Info { param([string]$msg) Write-Color $Blue "ℹ $msg" } +function Write-Warning { param([string]$msg) Write-Color $Yellow "⚠ $msg" } + +Write-Host "" +Write-Color $Blue "═══════════════════════════════════════════════════════════" +Write-Color $Blue " Pulse Host Agent - Windows Installation" +Write-Color $Blue "═══════════════════════════════════════════════════════════" +Write-Host "" + +# Check if running as Administrator +$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +if (-not $isAdmin) { + Write-Error "This script must be run as Administrator" + Write-Info "Right-click PowerShell and select 'Run as Administrator'" + exit 1 +} + +# Interactive prompts if parameters not provided +if (-not $PulseUrl) { + $PulseUrl = Read-Host "Enter Pulse server URL (e.g., http://pulse.example.com:7656)" +} +$PulseUrl = $PulseUrl.TrimEnd('/') + +if (-not $Token) { + Write-Warning "No API token provided - agent will attempt to connect without authentication" + $response = Read-Host "Continue without token? (y/N)" + if ($response -ne 'y' -and $response -ne 'Y') { + $Token = Read-Host "Enter API token" + } +} + +if (-not $Interval) { + $Interval = "30s" +} + +Write-Info "Configuration:" +Write-Host " Pulse URL: $PulseUrl" +Write-Host " Token: $(if ($Token) { '***' + $Token.Substring([Math]::Max(0, $Token.Length - 4)) } else { 'none' })" +Write-Host " Interval: $Interval" +Write-Host " Install Path: $InstallPath" +Write-Host "" + +# Determine architecture +$arch = if ([Environment]::Is64BitOperatingSystem) { "amd64" } else { "386" } +$downloadUrl = "$PulseUrl/download/pulse-host-agent?platform=windows&arch=$arch" + +Write-Info "Downloading agent binary from $downloadUrl..." +try { + # Create install directory + if (-not (Test-Path $InstallPath)) { + New-Item -ItemType Directory -Path $InstallPath -Force | Out-Null + } + + $agentPath = Join-Path $InstallPath "pulse-host-agent.exe" + + # Download binary + Invoke-WebRequest -Uri $downloadUrl -OutFile $agentPath -UseBasicParsing + Write-Success "Downloaded agent to $agentPath" +} catch { + Write-Error "Failed to download agent: $_" + exit 1 +} + +# Create configuration +$configPath = Join-Path $InstallPath "config.json" +$config = @{ + url = $PulseUrl + interval = $Interval +} +if ($Token) { + $config.token = $Token +} + +$config | ConvertTo-Json | Set-Content $configPath +Write-Success "Created configuration at $configPath" + +# Stop existing service if running +$serviceName = "PulseHostAgent" +$existingService = Get-Service -Name $serviceName -ErrorAction SilentlyContinue +if ($existingService) { + Write-Info "Stopping existing service..." + Stop-Service -Name $serviceName -Force + Write-Success "Stopped existing service" +} + +if (-not $NoService) { + Write-Info "Installing native Windows service with built-in service support..." + + # Build service arguments + $serviceArgs = @( + "--url", $PulseUrl, + "--interval", $Interval + ) + if ($Token) { + $serviceArgs += "--token", $Token + } + + $serviceBinaryPath = "`"$agentPath`" $($serviceArgs -join ' ')" + + try { + if ($existingService) { + Write-Info "Removing existing service..." + sc.exe delete $serviceName | Out-Null + Start-Sleep -Seconds 2 + } + + # Create the service using New-Service + New-Service -Name $serviceName ` + -BinaryPathName $serviceBinaryPath ` + -DisplayName "Pulse Host Agent" ` + -Description "Monitors system metrics and reports to Pulse monitoring server" ` + -StartupType Automatic | Out-Null + + Write-Success "Created Windows service '$serviceName'" + + # Register Windows Event Log source + try { + if (-not ([System.Diagnostics.EventLog]::SourceExists($serviceName))) { + New-EventLog -LogName Application -Source $serviceName + Write-Success "Registered Event Log source" + } + } catch { + Write-Warning "Could not register Event Log source (not critical): $_" + } + + # Configure service recovery options (restart on failure) + sc.exe failure $serviceName reset= 86400 actions= restart/60000/restart/60000/restart/60000 | Out-Null + Write-Success "Configured automatic restart on failure" + + # Start the service + Write-Info "Starting service..." + Start-Service -Name $serviceName + Start-Sleep -Seconds 3 + + $status = (Get-Service -Name $serviceName).Status + if ($status -eq 'Running') { + Write-Success "Service started successfully!" + + # Optional: Validate that agent is reporting + Write-Info "Waiting 10 seconds to validate agent reporting..." + Start-Sleep -Seconds 10 + + # Check Event Log for successful startup + $recentLogs = Get-EventLog -LogName Application -Source $serviceName -Newest 5 -ErrorAction SilentlyContinue + $hasStarted = $recentLogs | Where-Object { $_.Message -like "*started successfully*" } + + if ($hasStarted) { + Write-Success "Agent is reporting successfully!" + Write-Info "Check your Pulse dashboard - this host should appear shortly." + } else { + Write-Warning "Agent started but validation incomplete. Check Event Viewer if issues occur." + } + } else { + Write-Warning "Service status: $status" + Write-Info "Checking service logs..." + Get-EventLog -LogName Application -Source $serviceName -Newest 5 -ErrorAction SilentlyContinue | Format-List TimeGenerated, Message + } + + } catch { + Write-Error "Failed to create/start service: $_" + Write-Info "You can start the agent manually with:" + Write-Host " & `"$agentPath`" --url $PulseUrl --interval $Interval $(if ($Token) { '--token ***' })" + Write-Host "" + Write-Info "Or check Windows Event Viewer (Application log) for error details." + exit 1 + } +} else { + Write-Info "Skipping service installation (--NoService flag)" + Write-Host "" + Write-Info "To start the agent manually:" + Write-Host " & `"$agentPath`" --url $PulseUrl --interval $Interval $(if ($Token) { '--token ***' })" +} + +Write-Host "" +Write-Color $Green "═══════════════════════════════════════════════════════════" +Write-Success "Installation complete!" +Write-Color $Green "═══════════════════════════════════════════════════════════" +Write-Host "" + +Write-Info "Service Management Commands:" +Write-Host " Start: Start-Service -Name PulseHostAgent" +Write-Host " Stop: Stop-Service -Name PulseHostAgent" +Write-Host " Restart: Restart-Service -Name PulseHostAgent" +Write-Host " Status: Get-Service -Name PulseHostAgent | Select Status, StartType" +Write-Host " Remove: sc.exe delete PulseHostAgent" +Write-Host " Logs: Get-EventLog -LogName Application -Source PulseHostAgent -Newest 50" +Write-Host "" + +Write-Info "Files installed:" +Write-Host " Binary: $agentPath" +Write-Host " Config: $configPath" +Write-Host "" + +Write-Info "The agent is now reporting to: $PulseUrl" +Write-Host "" diff --git a/frontend-modern/public/uninstall-host-agent.ps1 b/frontend-modern/public/uninstall-host-agent.ps1 new file mode 100644 index 000000000..d1caa89a0 --- /dev/null +++ b/frontend-modern/public/uninstall-host-agent.ps1 @@ -0,0 +1,135 @@ +# Pulse Host Agent Uninstallation Script for Windows +# +# Usage: +# iwr -useb http://pulse-server:7656/uninstall-host-agent.ps1 | iex +# + +param( + [string]$InstallPath = "C:\Program Files\Pulse" +) + +$ErrorActionPreference = "Stop" + +# ANSI color codes for output +$Red = "`e[31m" +$Green = "`e[32m" +$Yellow = "`e[33m" +$Blue = "`e[34m" +$Reset = "`e[0m" + +function Write-Color { + param([string]$Color, [string]$Message) + Write-Host "${Color}${Message}${Reset}" +} + +function Write-Success { param([string]$msg) Write-Color $Green "✓ $msg" } +function Write-Error { param([string]$msg) Write-Color $Red "✗ $msg" } +function Write-Info { param([string]$msg) Write-Color $Blue "ℹ $msg" } +function Write-Warning { param([string]$msg) Write-Color $Yellow "⚠ $msg" } + +Write-Host "" +Write-Color $Blue "═══════════════════════════════════════════════════════════" +Write-Color $Blue " Pulse Host Agent - Windows Uninstallation" +Write-Color $Blue "═══════════════════════════════════════════════════════════" +Write-Host "" + +# Check if running as Administrator +$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +if (-not $isAdmin) { + Write-Error "This script must be run as Administrator" + Write-Info "Right-click PowerShell and select 'Run as Administrator'" + exit 1 +} + +$serviceName = "PulseHostAgent" + +# Stop and remove service +Write-Info "Checking for Pulse Host Agent service..." +$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue + +if ($service) { + if ($service.Status -eq 'Running') { + Write-Info "Stopping service..." + try { + Stop-Service -Name $serviceName -Force + Write-Success "Service stopped" + } catch { + Write-Warning "Could not stop service: $_" + } + } + + Write-Info "Removing service..." + try { + sc.exe delete $serviceName | Out-Null + Write-Success "Service removed" + } catch { + Write-Warning "Could not remove service: $_" + } +} else { + Write-Info "Service not found (already removed or never installed)" +} + +# Ensure all processes are terminated +Write-Info "Ensuring all processes are terminated..." +$processes = Get-Process -Name "pulse-host-agent" -ErrorAction SilentlyContinue +if ($processes) { + $processes | Stop-Process -Force + Start-Sleep -Seconds 2 + Write-Success "Processes terminated" +} else { + Write-Info "No running processes found" +} + +# Remove Event Log source +Write-Info "Removing Event Log source..." +try { + if ([System.Diagnostics.EventLog]::SourceExists($serviceName)) { + Remove-EventLog -Source $serviceName + Write-Success "Event Log source removed" + } else { + Write-Info "Event Log source not found" + } +} catch { + Write-Warning "Could not remove Event Log source: $_" +} + +# Remove installation directory with retry logic (Windows file locking) +if (Test-Path $InstallPath) { + Write-Info "Removing installation directory..." + + $retries = 3 + $success = $false + + while ($retries -gt 0 -and -not $success) { + try { + # Wait for file handles to be released after service stop + Start-Sleep -Seconds 2 + + Remove-Item -Path $InstallPath -Recurse -Force -ErrorAction Stop + Write-Success "Installation directory removed: $InstallPath" + $success = $true + } catch { + $retries-- + if ($retries -gt 0) { + Write-Warning "File still locked, retrying... ($retries attempts remaining)" + } else { + Write-Error "Could not remove installation directory after multiple attempts: $_" + Write-Warning "The service may still have file handles open." + Write-Warning "Please wait a few seconds and manually delete: $InstallPath" + Write-Info "Or reboot and run the uninstall script again." + } + } + } +} else { + Write-Info "Installation directory not found: $InstallPath" +} + +Write-Host "" +Write-Color $Green "═══════════════════════════════════════════════════════════" +Write-Success "Uninstallation complete!" +Write-Color $Green "═══════════════════════════════════════════════════════════" +Write-Host "" + +Write-Info "The Pulse Host Agent has been removed from this system." +Write-Info "This host will no longer appear in your Pulse dashboard." +Write-Host "" diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 19be26e54..7616a781e 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -21,7 +21,7 @@ import Replication from './components/Replication/Replication'; import Settings from './components/Settings/Settings'; import { Alerts } from './pages/Alerts'; import { DockerHosts } from './components/Docker/DockerHosts'; -import { ServersOverview } from './components/Hosts/ServersOverview'; +import { HostsOverview } from './components/Hosts/HostsOverview'; import { ToastContainer } from './components/Toast/Toast'; import NotificationContainer from './components/NotificationContainer'; import { ErrorBoundary } from './components/ErrorBoundary'; @@ -43,7 +43,7 @@ import type { State } from '@/types/api'; import MailGateway from './components/PMG/MailGateway'; import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon'; import { DockerIcon } from '@/components/icons/DockerIcon'; -import { ServersIcon } from '@/components/icons/ServersIcon'; +import { HostsIcon } from '@/components/icons/HostsIcon'; import { AlertsIcon } from '@/components/icons/AlertsIcon'; import { SettingsGearIcon } from '@/components/icons/SettingsGearIcon'; import { TokenRevealDialog } from './components/TokenRevealDialog'; @@ -90,7 +90,7 @@ function HostsRoute() { } const { state } = wsContext; return ( - + ); } @@ -629,7 +629,8 @@ function App() { } /> } /> - + + } /> (props.state().dockerHosts?.length ?? 0) > 0); - const hasServers = createMemo(() => (props.state().hosts?.length ?? 0) > 0); + const hasHosts = createMemo(() => (props.state().hosts?.length ?? 0) > 0); const hasProxmoxHosts = createMemo( () => (props.state().nodes?.length ?? 0) > 0 || @@ -726,8 +728,8 @@ function AppLayout(props: { }); createEffect(() => { - if (hasServers()) { - markPlatformSeen('servers'); + if (hasHosts()) { + markPlatformSeen('hosts'); } }); @@ -758,15 +760,15 @@ function AppLayout(props: { ), }, { - id: 'servers' as const, - label: 'Servers', - route: '/servers', + id: 'hosts' as const, + label: 'Hosts', + route: '/hosts', settingsRoute: '/settings', - tooltip: 'Monitor standalone servers with the host agent', - enabled: hasServers() || !!seenPlatforms()['servers'], - live: hasServers(), + tooltip: 'Monitor hosts with the host agent', + enabled: hasHosts() || !!seenPlatforms()['hosts'], + live: hasHosts(), icon: ( - + ), }, ]; diff --git a/frontend-modern/src/components/Hosts/HostsFilter.tsx b/frontend-modern/src/components/Hosts/HostsFilter.tsx new file mode 100644 index 000000000..01dadf77b --- /dev/null +++ b/frontend-modern/src/components/Hosts/HostsFilter.tsx @@ -0,0 +1,333 @@ +import { Component, Show, For, createSignal, createMemo, onMount, createEffect, onCleanup } from 'solid-js'; +import { Card } from '@/components/shared/Card'; +import { SearchTipsPopover } from '@/components/shared/SearchTipsPopover'; +import { STORAGE_KEYS } from '@/utils/localStorage'; +import { createSearchHistoryManager } from '@/utils/searchHistory'; + +interface HostsFilterProps { + search: () => string; + setSearch: (value: string) => void; + searchInputRef?: (el: HTMLInputElement) => void; + onReset?: () => void; + activeHostName?: string; + onClearHost?: () => void; +} + +export const HostsFilter: Component = (props) => { + const historyManager = createSearchHistoryManager(STORAGE_KEYS.HOSTS_SEARCH_HISTORY); + const [searchHistory, setSearchHistory] = createSignal([]); + const [isHistoryOpen, setIsHistoryOpen] = createSignal(false); + + let searchInputEl: HTMLInputElement | undefined; + let historyMenuRef: HTMLDivElement | undefined; + let historyToggleRef: HTMLButtonElement | undefined; + + onMount(() => { + setSearchHistory(historyManager.read()); + }); + + const commitSearchToHistory = (term: string) => { + const trimmed = term.trim(); + if (!trimmed) return; + const updated = historyManager.add(trimmed); + setSearchHistory(updated); + }; + + const deleteHistoryEntry = (term: string) => { + setSearchHistory(historyManager.remove(term)); + }; + + const clearHistory = () => { + setSearchHistory(historyManager.clear()); + setIsHistoryOpen(false); + queueMicrotask(() => historyToggleRef?.blur()); + }; + + const closeHistory = () => { + setIsHistoryOpen(false); + queueMicrotask(() => historyToggleRef?.blur()); + }; + + const handleDocumentClick = (event: MouseEvent) => { + const target = event.target as Node; + const clickedMenu = historyMenuRef?.contains(target) ?? false; + const clickedToggle = historyToggleRef?.contains(target) ?? false; + if (!clickedMenu && !clickedToggle) { + closeHistory(); + } + }; + + createEffect(() => { + if (isHistoryOpen()) { + document.addEventListener('mousedown', handleDocumentClick); + } else { + document.removeEventListener('mousedown', handleDocumentClick); + } + }); + + onCleanup(() => { + document.removeEventListener('mousedown', handleDocumentClick); + }); + + const focusSearchInput = () => { + queueMicrotask(() => searchInputEl?.focus()); + }; + + let suppressBlurCommit = false; + + const markSuppressCommit = () => { + suppressBlurCommit = true; + queueMicrotask(() => { + suppressBlurCommit = false; + }); + }; + + const hasActiveFilters = createMemo( + () => + props.search().trim() !== '' || + Boolean(props.activeHostName), + ); + + const handleReset = () => { + props.setSearch(''); + props.onClearHost?.(); + props.onReset?.(); + closeHistory(); + focusSearchInput(); + }; + + return ( + +
+
+
+ { + searchInputEl = el; + props.searchInputRef?.(el); + }} + type="text" + placeholder="Search hosts by hostname, platform, or OS..." + value={props.search()} + onInput={(e) => props.setSearch(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + commitSearchToHistory(e.currentTarget.value); + closeHistory(); + } else if (e.key === 'ArrowDown' && searchHistory().length > 0) { + e.preventDefault(); + setIsHistoryOpen(true); + } + }} + onBlur={(e) => { + if (suppressBlurCommit) return; + const next = e.relatedTarget as HTMLElement | null; + const interactingWithHistory = next + ? historyMenuRef?.contains(next) || historyToggleRef?.contains(next) + : false; + const interactingWithTips = + next?.getAttribute('aria-controls') === 'hosts-search-help'; + if (!interactingWithHistory && !interactingWithTips) { + commitSearchToHistory(e.currentTarget.value); + } + }} + class="w-full pl-9 pr-16 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all" + title="Search hosts by hostname, platform, or OS" + /> + + + + + + +
+ + +
+ +
(historyMenuRef = el)} + class="absolute left-0 right-0 top-full z-50 mt-2 w-full overflow-hidden rounded-lg border border-gray-200 bg-white text-sm shadow-xl dark:border-gray-700 dark:bg-gray-800" + role="listbox" + > + 0} + fallback={ +
+ Searches you run will appear here. +
+ } + > +
+ + {(entry) => ( +
+ + +
+ )} +
+
+ +
+
+
+
+
+ +
+ +
+ Host: {props.activeHostName} + +
+
+ + + + + +
+
+
+ ); +}; diff --git a/frontend-modern/src/components/Hosts/HostsOverview.tsx b/frontend-modern/src/components/Hosts/HostsOverview.tsx new file mode 100644 index 000000000..fac40ab36 --- /dev/null +++ b/frontend-modern/src/components/Hosts/HostsOverview.tsx @@ -0,0 +1,460 @@ +import type { Component } from 'solid-js'; +import { For, Show, createMemo, createSignal, createEffect, on } from 'solid-js'; +import { useNavigate } from '@solidjs/router'; +import type { Host } from '@/types/api'; +import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format'; +import { Card } from '@/components/shared/Card'; +import { ScrollableTable } from '@/components/shared/ScrollableTable'; +import { EmptyState } from '@/components/shared/EmptyState'; +import { MetricBar } from '@/components/Dashboard/MetricBar'; +import { HostsFilter } from './HostsFilter'; +import { useWebSocket } from '@/App'; + +// Global drawer state to persist across re-renders +const drawerState = new Map(); + +interface HostsOverviewProps { + hosts: Host[]; + connectionHealth: Record; +} + +const renderStatusIndicator = (status: string | undefined) => { + const normalized = (status || 'offline').toLowerCase(); + + const indicatorStyles: Record = { + online: 'bg-green-500', + degraded: 'bg-amber-500', + offline: 'bg-red-500', + }; + + const style = indicatorStyles[normalized] || indicatorStyles.offline; + + return ( +
+ ); +}; + +export const HostsOverview: Component = (props) => { + const navigate = useNavigate(); + const wsContext = useWebSocket(); + const [search, setSearch] = createSignal(''); + + const connected = () => wsContext.connected(); + const reconnecting = () => wsContext.reconnecting(); + + const isLoading = createMemo(() => { + return !connected() && !reconnecting(); + }); + + const sortedHosts = createMemo(() => + [...props.hosts].sort((a, b) => { + const aName = a.displayName || a.hostname || a.id; + const bName = b.displayName || b.hostname || b.id; + return aName.localeCompare(bName); + }), + ); + + const matchesSearch = (host: Host) => { + const term = search().toLowerCase(); + if (!term) return true; + + const hostname = (host.hostname || '').toLowerCase(); + const displayName = (host.displayName || '').toLowerCase(); + const platform = (host.platform || '').toLowerCase(); + const osName = (host.osName || '').toLowerCase(); + + return ( + hostname.includes(term) || + displayName.includes(term) || + platform.includes(term) || + osName.includes(term) + ); + }; + + const filteredHosts = createMemo(() => { + return sortedHosts().filter(matchesSearch); + }); + + return ( +
+ + + + + + + } + title={reconnecting() ? 'Reconnecting to host agents...' : 'Loading host data...'} + description={ + reconnecting() + ? 'Re-establishing metrics from the monitoring service.' + : connected() + ? 'Waiting for the first host update.' + : 'Connecting to the monitoring service.' + } + /> + + + + + + {/* Filters */} + setSearch('')} + /> + + {/* Host Table */} + 0} + fallback={ + + + + } + > + + + + + + + + + + + + + + + {(host) => { + const cpuPercent = () => host.cpuUsage ?? 0; + const memPercent = () => host.memory?.usage ?? 0; + const memUsed = () => formatBytes(host.memory?.used ?? 0); + const memTotal = () => formatBytes(host.memory?.total ?? 0); + + // Drawer state + const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(host.id) ?? false); + + // Check if we have additional info to show in drawer + const hasDrawerContent = createMemo(() => { + return ( + (host.disks && host.disks.length > 0) || + (host.networkInterfaces && host.networkInterfaces.length > 0) || + host.loadAverage || + host.cpuCount || + host.kernelVersion || + host.architecture || + host.agentVersion || + (host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0) + ); + }); + + const toggleDrawer = (event: MouseEvent) => { + if (!hasDrawerContent()) return; + const target = event.target as HTMLElement; + if (target.closest('a, button, [data-prevent-toggle]')) { + return; + } + setDrawerOpen((prev) => !prev); + }; + + // Sync drawer state + createEffect(on(() => host.id, (id) => { + const stored = drawerState.get(id); + if (stored !== undefined) { + setDrawerOpen(stored); + } else { + setDrawerOpen(false); + } + })); + + createEffect(() => { + drawerState.set(host.id, drawerOpen()); + }); + + const rowClass = () => { + const base = 'border-b border-gray-200 dark:border-gray-700 transition-all duration-200'; + const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50'; + const clickable = hasDrawerContent() ? 'cursor-pointer' : ''; + const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : ''; + return `${base} ${hover} ${clickable} ${expanded}`; + }; + + return ( + <> + + + + + + + + + {/* Drawer - Additional Info */} + + + + + + + ); + }} + + +
+ Host + + Platform + + CPU + + Memory + + Uptime +
+
+
+ {renderStatusIndicator(host.status)} +

+ {host.displayName || host.hostname || host.id} +

+
+ +

+ {host.hostname} +

+
+ +

+ Updated {formatRelativeTime(host.lastSeen!)} +

+
+
+
+
+

{host.platform || '—'}

+ +

+ {host.osName} {host.osVersion} +

+
+
+
+ 0} + fallback={} + > + 80 ? 'danger' : 'primary'} + /> + + + 0} + fallback={} + > + 80 ? 'danger' : 'primary'} + /> + + + —} + > + + {formatUptime(host.uptimeSeconds!)} + + +
+
+ {/* System Info */} +
+
System
+
+ +
+ CPUs: + {host.cpuCount} +
+
+ 0}> +
+ Load Avg: + {host.loadAverage!.map(l => l.toFixed(2)).join(', ')} +
+
+ +
+ Arch: + {host.architecture} +
+
+ +
+ Kernel: + {host.kernelVersion} +
+
+ +
+ Agent: + {host.agentVersion} +
+
+
+
+ + {/* Network Interfaces */} + 0}> +
+
Network
+
+ + {(iface) => ( +
+
{iface.name}
+ 0}> +
+ + {(addr) => ( + + {addr} + + )} + +
+
+
+ )} +
+
+
+
+ + {/* Disk Info */} + 0}> +
+
Disks
+
+ + {(disk) => { + const diskPercent = () => disk.usage ?? 0; + return ( +
+
+ {disk.mountpoint || disk.device} + + {formatBytes(disk.used ?? 0)} / {formatBytes(disk.total ?? 0)} + +
+ 0}> +
+ 80 ? 'danger' : 'primary'} + /> +
+
+
+ ); + }} +
+
+
+
+ + {/* Temperature Sensors */} + 0}> +
+
Temperatures
+
+ + {([name, temp]) => ( +
+ {name} + 80 ? 'text-red-600 dark:text-red-400 font-semibold' : ''}> + {temp.toFixed(1)}°C + +
+ )} +
+
+
+
+
+
+
+
+
+ + } + > + + + + + } + title="No hosts reporting" + description="Install the Pulse host agent on Linux, macOS, or Windows machines to begin monitoring." + actions={ + + } + /> + +
+
+
+ ); +}; diff --git a/frontend-modern/src/components/Hosts/ServersOverview.tsx b/frontend-modern/src/components/Hosts/ServersOverview.tsx deleted file mode 100644 index fe0902e5c..000000000 --- a/frontend-modern/src/components/Hosts/ServersOverview.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { For, Show, createMemo } from 'solid-js'; -import type { Component } from 'solid-js'; -import type { Host } from '@/types/api'; -import { Card } from '@/components/shared/Card'; -import { EmptyState } from '@/components/shared/EmptyState'; -import { formatBytes } from '@/utils/format'; - -interface ServersOverviewProps { - hosts: Host[]; - connectionHealth: Record; -} - -const statusClass: Record = { - online: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20', - degraded: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20', - offline: 'bg-rose-500/10 text-rose-600 dark:text-rose-400 border border-rose-500/20', -}; - -const formatStatus = (status: string | undefined) => { - if (!status) return 'unknown'; - const normalized = status.toLowerCase(); - if (normalized === 'online' || normalized === 'degraded' || normalized === 'offline') { - return normalized; - } - return status; -}; - -export const ServersOverview: Component = (props) => { - const sortedHosts = createMemo(() => - [...props.hosts].sort((a, b) => a.hostname.localeCompare(b.hostname)), - ); - - return ( -
-
-

Servers

-

- Unified view of standalone hosts reporting via the Pulse host agent. -

-
- - 0} - fallback={ - - } - > -
- - {(host) => { - const status = formatStatus(host.status); - const statusClasses = - statusClass[status] ?? - 'bg-slate-500/10 text-slate-600 dark:text-slate-300 border border-slate-500/20'; - const lastSeen = new Date(host.lastSeen || Date.now()); - const connectionKey = `host-${host.id}`; - const isHealthy = props.connectionHealth[connectionKey] ?? status !== 'offline'; - const memoryUsage = - typeof host.memory?.usage === 'number' - ? Math.round((host.memory.usage + Number.EPSILON) * 10) / 10 - : undefined; - - return ( - -
-
-

- {host.platform ?? 'unknown'} -

-

- {host.displayName || host.hostname} -

-

- {host.osName} - {host.osVersion ? ` ${host.osVersion}` : ''} -

-
- - {status} - -
- -
-
-
CPU Usage
-
- {typeof host.cpuUsage === 'number' ? `${host.cpuUsage.toFixed(1)}%` : '—'} -
-
-
-
Memory
-
- {host.memory?.total - ? `${formatBytes(host.memory.used ?? 0)} / ${formatBytes(host.memory.total)}${ - memoryUsage !== undefined ? ` (${memoryUsage.toFixed(1)}%)` : '' - }` - : '—'} -
-
-
-
Architecture
-
- {host.architecture ?? '—'} -
-
-
-
Last Seen
-
- {lastSeen.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -
-
-
-
Connection
-
- {isHealthy ? 'Healthy' : 'Unreachable'} -
-
-
- - 0}> -
-

- Storage -

-
    - - {(disk) => ( -
  • - - {disk.mountpoint || disk.device || 'disk'} •{' '} - {disk.type ? disk.type.toUpperCase() : '—'} - - - {formatBytes(disk.used ?? 0)} / {formatBytes(disk.total ?? 0)} - {typeof disk.usage === 'number' - ? ` (${disk.usage.toFixed(1)}%)` - : ''} - -
  • - )} -
    -
-
-
-
- ); - }} -
-
-
-
- ); -}; diff --git a/frontend-modern/src/components/Settings/AgentStepSection.tsx b/frontend-modern/src/components/Settings/AgentStepSection.tsx index 5c1a13cac..0857ae2f0 100644 --- a/frontend-modern/src/components/Settings/AgentStepSection.tsx +++ b/frontend-modern/src/components/Settings/AgentStepSection.tsx @@ -10,23 +10,23 @@ interface AgentStepSectionProps { export const AgentStepSection: Component = (props) => { return ( -
-
-
-

- {props.step} -

-

+
+
+
+ + {props.step.replace('Step ', '')} + +

{props.title}

-

+

{props.description}

-
{props.children}
+
{props.children}
); }; diff --git a/frontend-modern/src/components/Settings/DockerAgents.tsx b/frontend-modern/src/components/Settings/DockerAgents.tsx index b6ba722d7..43c785136 100644 --- a/frontend-modern/src/components/Settings/DockerAgents.tsx +++ b/frontend-modern/src/components/Settings/DockerAgents.tsx @@ -1,7 +1,6 @@ import { Component, createSignal, Show, For, onMount, createEffect, createMemo } from 'solid-js'; import { useWebSocket } from '@/App'; import { Card } from '@/components/shared/Card'; -import { SectionHeader } from '@/components/shared/SectionHeader'; import { formatRelativeTime, formatAbsoluteTime } from '@/utils/format'; import { MonitoringAPI } from '@/api/monitoring'; import { notificationStore } from '@/stores/notifications'; @@ -415,9 +414,55 @@ WantedBy=multi-user.target`; }; return ( -
+
+ {/* Summary Stats */} +
+
+
+
+ + + +
+
+

{dockerHosts().length}

+

Docker Hosts

+
+
+
+
+
+
+ + + +
+
+

{dockerHosts().filter(h => h.status?.toLowerCase() === 'online').length}

+

Online Now

+
+
+
+
+
+
+ + + +
+
+

{dockerHosts().reduce((sum, h) => sum + (h.containers?.length || 0), 0)}

+

Total Containers

+
+
+
+
+
- +
+

Setup & Management

+

Deploy agents or manage existing Docker hosts

+
@@ -510,10 +555,10 @@ WantedBy=multi-user.target`; type="button" onClick={acknowledgeTokenUse} disabled={stepTwoComplete()} - class={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors ${ + class={`inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors ${ stepTwoComplete() ? 'bg-green-600 text-white cursor-default' - : 'bg-gray-900 text-white hover:bg-black dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white' + : 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400' }`} > {stepTwoComplete() ? 'Token inserted' : 'Insert token into command'} @@ -532,10 +577,10 @@ WantedBy=multi-user.target`; type="button" onClick={acknowledgeTokenUse} disabled={stepTwoComplete()} - class={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors ${ + class={`inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors ${ stepTwoComplete() ? 'bg-green-600 text-white cursor-default' - : 'bg-gray-900 text-white hover:bg-black dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white' + : 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400' }`} > {stepTwoComplete() ? 'No token confirmed' : 'Confirm without token'} @@ -556,12 +601,12 @@ WantedBy=multi-user.target`; window.showToast(success ? 'success' : 'error', success ? 'Copied!' : 'Failed to copy'); } }} - class="px-3 py-1.5 text-xs font-medium rounded transition-colors bg-blue-600 text-white hover:bg-blue-700" + class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors bg-blue-600 text-white hover:bg-blue-700" > Copy first command
-
+
{getInstallCommandTemplate().replace(TOKEN_PLACEHOLDER, apiToken() || TOKEN_PLACEHOLDER)} @@ -600,7 +645,7 @@ WantedBy=multi-user.target`; window.showToast(success ? 'success' : 'error', success ? 'Copied to clipboard' : 'Failed to copy to clipboard'); } }} - class="rounded bg-red-50 px-3 py-1 text-xs font-medium text-red-700 transition-colors hover:bg-red-100 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50" + class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 transition-colors hover:bg-red-100 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50" > Copy @@ -642,7 +687,7 @@ WantedBy=multi-user.target`; window.showToast(success ? 'success' : 'error', success ? 'Copied to clipboard' : 'Failed to copy to clipboard'); } }} - class="absolute right-2 top-2 rounded bg-gray-700 px-3 py-1 text-xs font-medium text-gray-200 transition-colors hover:bg-gray-600" + class="absolute right-2 top-2 rounded-lg bg-gray-700 px-3 py-1.5 text-xs font-medium text-gray-200 transition-colors hover:bg-gray-600" > Copy @@ -699,7 +744,7 @@ WantedBy=multi-user.target`; setNewTokenName(''); setGenerateError(null); }} - class="rounded px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700" + class="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700" > Cancel @@ -707,7 +752,7 @@ WantedBy=multi-user.target`; type="button" onClick={handleCreateToken} disabled={isGeneratingToken()} - class="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-400" + class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-400" > {isGeneratingToken() ? 'Generating…' : 'Generate token'} @@ -896,7 +941,7 @@ WantedBy=multi-user.target`; window.showToast(success ? 'success' : 'error', success ? 'Copied!' : 'Failed to copy'); } }} - class="self-start rounded bg-gray-800 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-gray-700" + class="self-start rounded-lg bg-gray-800 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-gray-700" > Copy command @@ -952,7 +997,7 @@ WantedBy=multi-user.target`; diff --git a/frontend-modern/src/components/Settings/HostAgents.tsx b/frontend-modern/src/components/Settings/HostAgents.tsx index f8c2d96ce..bc6b28685 100644 --- a/frontend-modern/src/components/Settings/HostAgents.tsx +++ b/frontend-modern/src/components/Settings/HostAgents.tsx @@ -30,17 +30,15 @@ const hostPlatformOptions: { id: HostPlatform; label: string; description: strin { id: 'macos', label: 'macOS', - description: 'Use the universal binary with launchd to keep desktops and servers reporting in the background.', + description: 'Use the universal binary with launchd to keep desktops and hosts reporting in the background.', }, { id: 'windows', label: 'Windows', - description: 'Compile the agent for Windows or run it under WSL until native builds ship. Service template included.', + description: 'Native Windows service with automatic startup. PowerShell script handles binary download and service installation.', }, ]; -const RELEASE_BASE = 'https://github.com/rcourtman/Pulse/releases/latest/download'; - const TOKEN_PLACEHOLDER = ''; const pulseUrl = () => { if (typeof window === 'undefined') return 'http://localhost:7655'; @@ -50,53 +48,32 @@ const pulseUrl = () => { const commandsByVariant: Record = { all: { - title: 'Installation quick start', + title: 'Installation', description: - 'Generate an API token from Settings → Security with the host agent reporting scope, then replace the highlighted token placeholder. Agents only require outbound HTTP(S) access to Pulse.', + 'Run the installer script to automatically download and configure the host agent on any supported platform.', snippets: [ { - label: 'Linux (systemd)', - command: [ - `curl -fsSL ${RELEASE_BASE}/pulse-host-agent-linux-amd64 -o /usr/local/bin/pulse-host-agent`, - 'sudo chmod +x /usr/local/bin/pulse-host-agent', - `sudo /usr/local/bin/pulse-host-agent --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`, - ].join(' && '), - }, - { - label: 'macOS (launchd)', - command: [ - `curl -fsSL ${RELEASE_BASE}/pulse-host-agent-darwin-arm64 -o /usr/local/bin/pulse-host-agent`, - 'sudo chmod +x /usr/local/bin/pulse-host-agent', - `sudo /usr/local/bin/pulse-host-agent --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`, - ].join(' && '), + label: 'Install host agent', + command: `curl -fsSL ${pulseUrl()}/install-host-agent.sh | bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`, note: ( - Create ~/Library/LaunchAgents/com.pulse.host-agent.plist to keep the agent running between logins. + The script downloads the agent binary from Pulse and sets up systemd (Linux) or launchd (macOS) for automatic startup. ), }, - { - label: 'Ad-hoc execution', - command: `/usr/local/bin/pulse-host-agent --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`, - }, ], }, linux: { title: 'Install on Linux', description: - 'Download the static binary, make it executable, and (optionally) register it as a systemd service. Replace the token placeholder with an API token scoped for host agent reporting.', + 'The installer downloads the agent binary and configures it as a systemd service.', snippets: [ { - label: 'Install + enable (systemd)', - command: [ - `curl -fsSL ${RELEASE_BASE}/pulse-host-agent-linux-amd64 -o /usr/local/bin/pulse-host-agent`, - 'sudo chmod +x /usr/local/bin/pulse-host-agent', - `sudo /usr/local/bin/pulse-host-agent --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`, - ].join(' && '), + label: 'Install with systemd', + command: `curl -fsSL ${pulseUrl()}/install-host-agent.sh | bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`, note: ( - For persistence, create /etc/systemd/system/pulse-host-agent.service and enable it with{' '} - systemctl enable --now pulse-host-agent. + Automatically installs to /usr/local/bin/pulse-host-agent and creates /etc/systemd/system/pulse-host-agent.service. ), }, @@ -105,22 +82,14 @@ const commandsByVariant: Record - Create a plist pointing to{' '} - /usr/local/bin/pulse-host-agent --url {pulseUrl()} --token {TOKEN_PLACEHOLDER} --interval 30s to run at login. + Creates ~/Library/LaunchAgents/com.pulse.host-agent.plist and starts the agent automatically. ), }, @@ -129,19 +98,23 @@ const commandsByVariant: Record - Consider registering the executable as a Windows Service via sc.exe or NSSM once native artefacts ship. + 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. The agent runs natively and can access all Windows performance counters. + + ), + }, + { + label: 'Install with parameters (PowerShell)', + command: `$env:PULSE_URL="${pulseUrl()}"; $env:PULSE_TOKEN="${TOKEN_PLACEHOLDER}"; irm ${pulseUrl()}/install-host-agent.ps1 | iex`, + note: ( + + Non-interactive installation. Set environment variables before running to skip prompts. ), }, @@ -327,34 +300,34 @@ export const HostAgents: Component = (props) => { const cardTitle = () => { if (variant === 'all') { - return 'Pulse host agent'; + return 'Host Monitoring'; } switch (effectiveVariant()) { case 'linux': - return 'Linux servers'; + return 'Linux Hosts'; case 'macos': - return 'macOS devices'; + return 'macOS Hosts'; case 'windows': - return 'Windows servers'; + return 'Windows Hosts'; default: - return 'Host agents'; + return 'Host Monitoring'; } }; const cardDescription = () => { if (variant === 'all') { - return 'Install the Pulse host agent on Linux, macOS, or Windows servers to surface uptime, OS metadata, and capacity metrics.'; + return 'Install the Pulse host agent on Linux, macOS, or Windows machines to monitor CPU, memory, disk, and uptime.'; } const platform = effectiveVariant(); switch (platform) { case 'linux': - return 'Install the Pulse host agent on Debian, Ubuntu, RHEL, Arch, or other Linux hosts to surface uptime and capacity metrics.'; + return 'Install the Pulse host agent on Debian, Ubuntu, RHEL, Arch, or other Linux distributions.'; case 'macos': - return 'Deploy the lightweight host agent via launchd to keep macOS hardware in view alongside your Proxmox estate.'; + return 'Deploy the lightweight host agent via launchd to monitor macOS desktops and servers.'; case 'windows': - return 'Track Windows Server hosts with the Pulse agent. Native builds are on the roadmap—compile today or run it under WSL.'; + return 'Deploy the Pulse host agent as a native Windows service with automatic startup and full access to performance counters.'; default: - return 'Install the Pulse host agent on Linux, macOS, or Windows servers to surface uptime, OS metadata, and capacity metrics.'; + return 'Install the Pulse host agent on Linux, macOS, or Windows machines to monitor CPU, memory, disk, and uptime.'; } }; @@ -394,7 +367,7 @@ export const HostAgents: Component = (props) => { return ( @@ -470,10 +443,10 @@ export const HostAgents: Component = (props) => { type="button" onClick={acknowledgeTokenUse} disabled={stepTwoComplete()} - class={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors ${ + class={`inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors ${ stepTwoComplete() ? 'bg-green-600 text-white cursor-default' - : 'bg-gray-900 text-white hover:bg-black dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white' + : 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400' }`} > {stepTwoComplete() ? 'Token inserted' : 'Insert token into commands'} @@ -518,7 +491,7 @@ export const HostAgents: Component = (props) => { window.showToast(success ? 'success' : 'error', success ? 'Copied!' : 'Failed to copy'); } }} - class="px-3 py-1.5 text-xs font-medium rounded transition-colors bg-blue-600 text-white hover:bg-blue-700" + class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors bg-blue-600 text-white hover:bg-blue-700" > Copy first command @@ -601,7 +574,7 @@ export const HostAgents: Component = (props) => { setNewTokenName(''); setGenerateError(null); }} - class="rounded px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700" + class="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700" > Cancel @@ -609,7 +582,7 @@ export const HostAgents: Component = (props) => { type="button" onClick={handleCreateToken} disabled={isGeneratingToken()} - class="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-400" + class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-400" > {isGeneratingToken() ? 'Generating…' : 'Generate token'} @@ -618,7 +591,7 @@ export const HostAgents: Component = (props) => {
- +

Reporting hosts

{hosts().length} connected @@ -629,7 +602,7 @@ export const HostAgents: Component = (props) => { fallback={

{variant === 'windows' - ? 'No Windows hosts have reported yet. Compile the agent from source or check back when native artefacts are published.' + ? 'No Windows hosts have reported yet. Run the PowerShell installation script above as Administrator to deploy the agent.' : 'No host agents are reporting yet. Deploy the binary using the commands above to see hosts listed here.'}

} @@ -644,32 +617,92 @@ export const HostAgents: Component = (props) => { Memory Last seen Tags + Actions - {(host) => ( - - - {host.displayName || host.hostname || host.id} - - - {host.platform || '—'} - - - {host.uptimeSeconds ? formatUptime(host.uptimeSeconds) : '—'} - - - {host.memory?.total - ? `${formatBytes(host.memory.used ?? 0)} / ${formatBytes(host.memory.total)}` - : '—'} - - - {host.lastSeen ? formatRelativeTime(host.lastSeen) : '—'} - - {renderTags(host)} - - )} + {(host) => { + const [isDeleting, setIsDeleting] = createSignal(false); + + const handleDelete = async () => { + if (!confirm(`Remove host "${host.displayName || host.hostname || host.id}"?\n\nThis will remove the host from Pulse monitoring. The host agent will re-register if it continues to report.`)) { + return; + } + + setIsDeleting(true); + try { + const response = await fetch(`/api/agents/host/${host.id}`, { + method: 'DELETE', + credentials: 'include', + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || 'Failed to delete host'); + } + + notificationStore.success(`Host "${host.displayName || host.hostname}" removed`, 4000); + } catch (err) { + console.error('Failed to delete host:', err); + notificationStore.error( + err instanceof Error ? err.message : 'Failed to delete host. Please try again.', + 6000 + ); + } finally { + setIsDeleting(false); + } + }; + + return ( + + + {host.displayName || host.hostname || host.id} + + + {host.platform || '—'} + + + {host.uptimeSeconds ? formatUptime(host.uptimeSeconds) : '—'} + + + {host.memory?.total + ? `${formatBytes(host.memory.used ?? 0)} / ${formatBytes(host.memory.total)}` + : '—'} + + + {host.lastSeen ? formatRelativeTime(host.lastSeen) : '—'} + + {renderTags(host)} + + + + + ); + }} diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index bc8ed171b..de4f1cacb 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -227,6 +227,8 @@ interface DiscoveryScanStatus { type SettingsTab = | 'agent-hub' + | 'docker' + | 'servers' | 'podman' | 'kubernetes' | 'system-general' @@ -244,12 +246,20 @@ type AgentKey = 'pve' | 'pbs' | 'pmg' | 'docker' | 'host' | 'podman' | 'kubernet const SETTINGS_HEADER_META: Record = { 'agent-hub': { - title: 'Agent Deployments', - description: 'Select a platform to generate tokens, deploy agents, or add Proxmox endpoints.', + title: 'Proxmox', + description: 'Monitor your Proxmox Virtual Environment, Backup Server, and Mail Gateway infrastructure.', + }, + docker: { + title: 'Docker', + description: 'Monitor Docker hosts, containers, images, and volumes across your infrastructure.', + }, + servers: { + title: 'Hosts', + description: 'Monitor Linux, macOS, and Windows machines—servers, desktops, and laptops.', }, podman: { - title: 'Podman (container runtime)', - description: 'Auto-discovery and agent-based monitoring for Podman hosts is on the roadmap.', + title: 'Podman', + description: 'Container monitoring for Podman hosts is coming soon.', }, kubernetes: { title: 'Kubernetes', @@ -328,6 +338,8 @@ const Settings: Component = (props) => { const deriveTabFromPath = (path: string): SettingsTab => { if (path.includes('/settings/agent-hub')) return 'agent-hub'; + if (path.includes('/settings/docker')) return 'docker'; + if (path.includes('/settings/hosts') || path.includes('/settings/host-agents') || path.includes('/settings/servers')) return 'servers'; if (path.includes('/settings/podman')) return 'podman'; if (path.includes('/settings/kubernetes')) return 'kubernetes'; if (path.includes('/settings/system-general')) return 'system-general'; @@ -387,67 +399,29 @@ const Settings: Component = (props) => { agents: Array<{ id: AgentKey; label: string; description: string; icon: JSX.Element; disabled?: boolean }>; }[] = [ { - title: 'Proxmox API integrations', - description: 'Connect Pulse directly to your Proxmox estate using scoped API tokens.', + title: 'Proxmox Products', + description: 'Select which Proxmox product to configure', agents: [ { id: 'pve', - label: 'Proxmox VE', - description: 'Connect VE clusters and manage discovery.', + label: 'Virtual Environment', + description: 'VMs, containers, clusters, and storage', icon: , }, { id: 'pbs', - label: 'Proxmox Backup Server', - description: 'Monitor backup jobs and datastore health.', + label: 'Backup Server', + description: 'Backup jobs, datastores, and snapshots', icon: , }, { id: 'pmg', - label: 'Proxmox Mail Gateway', - description: 'Capture mail flow, spam trends, and queues.', + label: 'Mail Gateway', + description: 'Mail flow, spam filtering, and queues', icon: , }, ], }, - { - title: 'Agent deployments', - description: 'Install lightweight Pulse agents to report telemetry back to this hub.', - agents: [ - { - id: 'docker', - label: 'Docker hosts', - description: 'Deploy the pulse-docker-agent for container telemetry.', - icon: , - }, - { - id: 'host', - label: 'Pulse host agent', - description: 'Install on Linux, macOS, or Windows servers.', - icon: , - }, - ], - }, - { - title: 'Coming soon', - description: 'Roadmap integrations that are currently in development.', - agents: [ - { - id: 'podman', - label: 'Podman hosts (coming soon)', - description: 'Podman agent support is under active development.', - icon: , - disabled: true, - }, - { - id: 'kubernetes', - label: 'Kubernetes (coming soon)', - description: 'Native Kubernetes monitoring is on the roadmap.', - icon: , - disabled: true, - }, - ], - }, ]; const agentPaths: Record = { @@ -884,7 +858,7 @@ const Settings: Component = (props) => { }; const tabGroups: { - id: 'platforms' | 'administration' | 'system' | 'security'; + id: 'platforms' | 'operations' | 'system' | 'security'; label: string; items: { id: SettingsTab; label: string; icon: JSX.Element; disabled?: boolean }[]; }[] = [ @@ -892,16 +866,18 @@ const Settings: Component = (props) => { id: 'platforms', label: 'Platforms', items: [ - { id: 'agent-hub', label: 'Agent deployments', icon: }, - { id: 'podman', label: 'Podman hosts', icon: , disabled: true }, + { id: 'agent-hub', label: 'Proxmox', icon: }, + { id: 'docker', label: 'Docker', icon: }, + { id: 'servers', label: 'Hosts', icon: }, + { id: 'podman', label: 'Podman', icon: , disabled: true }, { id: 'kubernetes', label: 'Kubernetes', icon: , disabled: true }, ], }, { - id: 'administration', - label: 'Administration', + id: 'operations', + label: 'Operations', items: [ - { id: 'api', label: 'API access', icon: }, + { id: 'api', label: 'API Tokens', icon: }, { id: 'diagnostics', label: 'Diagnostics', icon: }, ], }, @@ -1950,15 +1926,16 @@ const Settings: Component = (props) => { return ( <> -
- {/* Header with better styling */} - - - +
+ {/* Page header - no card wrapper for cleaner hierarchy */} +
+

+ {headerMeta().title} +

+

+ {headerMeta().description} +

+
{/* Save notification bar - only show when there are unsaved changes */} = (props) => { activeTab() === 'system-updates' || activeTab() === 'system-backups') } > -
-
-
+
+
+
- - - + - You have unsaved changes +
+

Unsaved changes

+

Your changes will be lost if you navigate away

+
-
+
+
+ + + + +
{(group) => (
@@ -2104,7 +2106,7 @@ const Settings: Component = (props) => {
-
+
= (props) => { + {/* Docker Platform Tab */} + + + + + {/* Servers Platform Tab */} + + + + {/* Podman Tab */} = (props) => ( +export const HostsIcon: Component = (props) => (