feat: add native Windows service support and expandable host details

Windows Host Agent Enhancements:
- Implement native Windows service support using golang.org/x/sys/windows/svc
- Add Windows Event Log integration for troubleshooting
- Create professional PowerShell installation/uninstallation scripts
- Add process termination and retry logic to handle Windows file locking
- Register uninstall endpoint at /uninstall-host-agent.ps1

Host Agent UI Improvements:
- Add expandable drawer to Hosts page (click row to view details)
- Display system info, network interfaces, disks, and temperatures in cards
- Replace status badges with subtle colored indicators
- Remove redundant master-detail sidebar layout
- Add search filtering for hosts

Technical Details:
- service_windows.go: Windows service lifecycle management with graceful shutdown
- service_stub.go: Cross-platform compatibility for non-Windows builds
- install-host-agent.ps1: Full Windows installation with validation
- uninstall-host-agent.ps1: Clean removal with process termination and retries
- HostsOverview.tsx: Expandable row pattern matching Docker/Proxmox pages

Files Added:
- cmd/pulse-host-agent/service_windows.go
- cmd/pulse-host-agent/service_stub.go
- scripts/install-host-agent.ps1
- scripts/uninstall-host-agent.ps1
- frontend-modern/src/components/Hosts/HostsOverview.tsx
- frontend-modern/src/components/Hosts/HostsFilter.tsx

The Windows service now starts reliably with automatic restart on failure,
and the uninstall script handles file locking gracefully without requiring reboots.
This commit is contained in:
rcourtman 2025-10-23 22:11:56 +00:00
parent 745e6b386b
commit 6333a445e9
27 changed files with 2499 additions and 428 deletions

View file

@ -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")

View file

@ -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
}

View file

@ -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)
}

View file

@ -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 ""

View file

@ -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 ""

View file

@ -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 (
<ServersOverview hosts={state.hosts ?? []} connectionHealth={state.connectionHealth ?? {}} />
<HostsOverview hosts={state.hosts ?? []} connectionHealth={state.connectionHealth ?? {}} />
);
}
@ -629,7 +629,8 @@ function App() {
<Route path="/storage" component={() => <Navigate href="/proxmox/storage" />} />
<Route path="/backups" component={() => <Navigate href="/proxmox/backups" />} />
<Route path="/docker" component={DockerRoute} />
<Route path="/servers" component={HostsRoute} />
<Route path="/hosts" component={HostsRoute} />
<Route path="/servers" component={() => <Navigate href="/hosts" />} />
<Route path="/alerts/*" component={Alerts} />
<Route
path="/settings/*"
@ -699,13 +700,14 @@ function AppLayout(props: {
const path = location.pathname;
if (path.startsWith('/proxmox')) return 'proxmox';
if (path.startsWith('/docker')) return 'docker';
if (path.startsWith('/servers')) return 'servers';
if (path.startsWith('/hosts')) return 'hosts';
if (path.startsWith('/servers')) return 'hosts'; // Legacy redirect
if (path.startsWith('/alerts')) return 'alerts';
if (path.startsWith('/settings')) return 'settings';
return 'proxmox';
};
const hasDockerHosts = createMemo(() => (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: (
<ServersIcon class="w-4 h-4 shrink-0" />
<HostsIcon class="w-4 h-4 shrink-0" />
),
},
];

View file

@ -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<HostsFilterProps> = (props) => {
const historyManager = createSearchHistoryManager(STORAGE_KEYS.HOSTS_SEARCH_HISTORY);
const [searchHistory, setSearchHistory] = createSignal<string[]>([]);
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 (
<Card class="hosts-filter mb-3" padding="sm">
<div class="flex flex-col lg:flex-row gap-3">
<div class="flex gap-2 flex-1 items-center">
<div class="relative flex-1">
<input
ref={(el) => {
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"
/>
<svg
class="absolute left-3 top-2 h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<Show when={props.search()}>
<button
type="button"
class="absolute right-9 top-1/2 -translate-y-1/2 transform text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
onClick={() => props.setSearch('')}
onMouseDown={markSuppressCommit}
aria-label="Clear search"
title="Clear search"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</Show>
<div class="absolute inset-y-0 right-2 flex items-center gap-1">
<button
ref={(el) => (historyToggleRef = el)}
type="button"
class="flex h-6 w-6 items-center justify-center rounded-lg border border-transparent text-gray-400 transition-colors hover:border-gray-200 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:border-gray-700 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
onClick={() =>
setIsHistoryOpen((prev) => {
const next = !prev;
if (!next) {
queueMicrotask(() => historyToggleRef?.blur());
}
return next;
})
}
onMouseDown={markSuppressCommit}
aria-haspopup="listbox"
aria-expanded={isHistoryOpen()}
title={
searchHistory().length > 0
? 'Show recent searches'
: 'No recent searches yet'
}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l2.5 1.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="sr-only">Show search history</span>
</button>
<SearchTipsPopover
popoverId="hosts-search-help"
intro="Filter hosts quickly"
tips={[
{ code: 'hostname', description: 'Match hosts by hostname' },
{ code: 'linux', description: 'Find Linux hosts' },
{ code: 'darwin', description: 'Find macOS hosts' },
{ code: 'windows', description: 'Find Windows hosts' },
]}
triggerVariant="icon"
buttonLabel="Search tips"
openOnHover
/>
</div>
<Show when={isHistoryOpen()}>
<div
ref={(el) => (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"
>
<Show
when={searchHistory().length > 0}
fallback={
<div class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
Searches you run will appear here.
</div>
}
>
<div class="max-h-52 overflow-y-auto py-1">
<For each={searchHistory()}>
{(entry) => (
<div class="flex items-center justify-between px-2 py-1.5 hover:bg-blue-50 dark:hover:bg-blue-900/20">
<button
type="button"
class="flex-1 truncate pr-2 text-left text-sm text-gray-700 transition-colors hover:text-blue-600 focus:outline-none dark:text-gray-200 dark:hover:text-blue-300"
onClick={() => {
props.setSearch(entry);
commitSearchToHistory(entry);
setIsHistoryOpen(false);
focusSearchInput();
}}
onMouseDown={markSuppressCommit}
>
{entry}
</button>
<button
type="button"
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-1 focus:ring-offset-white dark:text-gray-500 dark:hover:bg-gray-700/70 dark:hover:text-gray-200 dark:focus:ring-blue-400/40 dark:focus:ring-offset-gray-900"
title="Remove from history"
onClick={() => deleteHistoryEntry(entry)}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<span class="sr-only">Remove from history</span>
</button>
</div>
)}
</For>
</div>
<button
type="button"
class="flex w-full items-center justify-center gap-2 border-t border-gray-200 px-3 py-2 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 focus:outline-none dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700/80 dark:hover:text-gray-200"
onClick={clearHistory}
onMouseDown={markSuppressCommit}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3m-9 0h12"
/>
</svg>
Clear history
</button>
</Show>
</div>
</Show>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<Show when={props.activeHostName}>
<div class="flex items-center gap-1 rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
<span>Host: {props.activeHostName}</span>
<button
type="button"
class="text-blue-500 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-100"
onClick={() => props.onClearHost?.()}
title="Clear host filter"
>
×
</button>
</div>
</Show>
<Show when={hasActiveFilters()}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" aria-hidden="true"></div>
<button
type="button"
onClick={handleReset}
class="flex items-center justify-center gap-1 px-2.5 py-1 text-xs font-medium rounded-lg text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900/70 transition-colors"
title="Reset filters"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</svg>
<span class="hidden sm:inline">Reset</span>
</button>
</Show>
</div>
</div>
</Card>
);
};

View file

@ -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<string, boolean>();
interface HostsOverviewProps {
hosts: Host[];
connectionHealth: Record<string, boolean>;
}
const renderStatusIndicator = (status: string | undefined) => {
const normalized = (status || 'offline').toLowerCase();
const indicatorStyles: Record<string, string> = {
online: 'bg-green-500',
degraded: 'bg-amber-500',
offline: 'bg-red-500',
};
const style = indicatorStyles[normalized] || indicatorStyles.offline;
return (
<div class={`h-2 w-2 rounded-full ${style}`} title={normalized.charAt(0).toUpperCase() + normalized.slice(1)} />
);
};
export const HostsOverview: Component<HostsOverviewProps> = (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 (
<div class="space-y-0">
<Show when={isLoading()}>
<Card padding="lg">
<EmptyState
icon={
<svg
class="h-12 w-12 animate-spin text-blue-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
}
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.'
}
/>
</Card>
</Show>
<Show when={!isLoading()}>
<Show
when={sortedHosts().length === 0}
fallback={
<>
{/* Filters */}
<HostsFilter
search={search}
setSearch={setSearch}
onReset={() => setSearch('')}
/>
{/* Host Table */}
<Show
when={filteredHosts().length > 0}
fallback={
<Card padding="lg">
<EmptyState
title="No hosts found"
description={
search().trim()
? 'No hosts match your search.'
: 'No hosts available'
}
/>
</Card>
}
>
<Card padding="none" class="overflow-hidden">
<ScrollableTable>
<table class="w-full border-collapse">
<thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
<th class="pl-4 pr-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[30%]">
Host
</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[15%]">
Platform
</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[20%]">
CPU
</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[20%]">
Memory
</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[15%]">
Uptime
</th>
</tr>
</thead>
<tbody>
<For each={filteredHosts()}>
{(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 (
<>
<tr class={rowClass()} onClick={toggleDrawer} aria-expanded={drawerOpen()}>
<td class="pl-4 pr-2 py-2">
<div>
<div class="flex items-center gap-2">
{renderStatusIndicator(host.status)}
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
{host.displayName || host.hostname || host.id}
</p>
</div>
<Show when={host.displayName && host.displayName !== host.hostname}>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
{host.hostname}
</p>
</Show>
<Show when={host.lastSeen}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5">
Updated {formatRelativeTime(host.lastSeen!)}
</p>
</Show>
</div>
</td>
<td class="px-2 py-2">
<div class="text-xs text-gray-700 dark:text-gray-300">
<p class="font-medium capitalize">{host.platform || '—'}</p>
<Show when={host.osName}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5">
{host.osName} {host.osVersion}
</p>
</Show>
</div>
</td>
<td class="px-2 py-2">
<Show
when={cpuPercent() > 0}
fallback={<span class="text-xs text-gray-500 dark:text-gray-400"></span>}
>
<MetricBar
label={`${cpuPercent().toFixed(1)}%`}
value={cpuPercent()}
type={cpuPercent() > 80 ? 'danger' : 'primary'}
/>
</Show>
</td>
<td class="px-2 py-2">
<Show
when={memPercent() > 0}
fallback={<span class="text-xs text-gray-500 dark:text-gray-400"></span>}
>
<MetricBar
label={`${memUsed()} / ${memTotal()}`}
value={memPercent()}
type={memPercent() > 80 ? 'danger' : 'primary'}
/>
</Show>
</td>
<td class="px-2 py-2">
<Show
when={host.uptimeSeconds}
fallback={<span class="text-xs text-gray-500 dark:text-gray-400"></span>}
>
<span class="text-xs text-gray-700 dark:text-gray-300">
{formatUptime(host.uptimeSeconds!)}
</span>
</Show>
</td>
</tr>
{/* Drawer - Additional Info */}
<Show when={drawerOpen() && hasDrawerContent()}>
<tr class="text-[11px] bg-gray-50/60 text-gray-600 dark:bg-gray-800/40 dark:text-gray-300">
<td class="px-4 py-2" colSpan={5}>
<div class="grid w-full gap-3 sm:grid-cols-2 lg:grid-cols-3">
{/* System Info */}
<div class="rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">System</div>
<div class="mt-1 space-y-1 text-gray-600 dark:text-gray-300">
<Show when={host.cpuCount}>
<div class="flex items-start gap-1">
<span class="text-gray-500 dark:text-gray-400 min-w-[60px]">CPUs:</span>
<span>{host.cpuCount}</span>
</div>
</Show>
<Show when={host.loadAverage && host.loadAverage.length > 0}>
<div class="flex items-start gap-1">
<span class="text-gray-500 dark:text-gray-400 min-w-[60px]">Load Avg:</span>
<span>{host.loadAverage!.map(l => l.toFixed(2)).join(', ')}</span>
</div>
</Show>
<Show when={host.architecture}>
<div class="flex items-start gap-1">
<span class="text-gray-500 dark:text-gray-400 min-w-[60px]">Arch:</span>
<span>{host.architecture}</span>
</div>
</Show>
<Show when={host.kernelVersion}>
<div class="flex items-start gap-1">
<span class="text-gray-500 dark:text-gray-400 min-w-[60px]">Kernel:</span>
<span class="break-all">{host.kernelVersion}</span>
</div>
</Show>
<Show when={host.agentVersion}>
<div class="flex items-start gap-1">
<span class="text-gray-500 dark:text-gray-400 min-w-[60px]">Agent:</span>
<span>{host.agentVersion}</span>
</div>
</Show>
</div>
</div>
{/* Network Interfaces */}
<Show when={host.networkInterfaces && host.networkInterfaces.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Network</div>
<div class="mt-1 space-y-1">
<For each={host.networkInterfaces?.slice(0, 4)}>
{(iface) => (
<div class="text-gray-600 dark:text-gray-300">
<div class="font-medium">{iface.name}</div>
<Show when={iface.addresses && iface.addresses.length > 0}>
<div class="flex flex-wrap gap-1 mt-0.5">
<For each={iface.addresses}>
{(addr) => (
<span class="rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
{addr}
</span>
)}
</For>
</div>
</Show>
</div>
)}
</For>
</div>
</div>
</Show>
{/* Disk Info */}
<Show when={host.disks && host.disks.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Disks</div>
<div class="mt-1 space-y-1">
<For each={host.disks?.slice(0, 3)}>
{(disk) => {
const diskPercent = () => disk.usage ?? 0;
return (
<div class="text-gray-600 dark:text-gray-300">
<div class="flex items-center justify-between">
<span class="font-medium truncate">{disk.mountpoint || disk.device}</span>
<span class="text-[10px] text-gray-500 dark:text-gray-400">
{formatBytes(disk.used ?? 0)} / {formatBytes(disk.total ?? 0)}
</span>
</div>
<Show when={diskPercent() > 0}>
<div class="mt-0.5">
<MetricBar
value={diskPercent()}
label={`${diskPercent().toFixed(1)}%`}
type={diskPercent() > 80 ? 'danger' : 'primary'}
/>
</div>
</Show>
</div>
);
}}
</For>
</div>
</div>
</Show>
{/* Temperature Sensors */}
<Show when={host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Temperatures</div>
<div class="mt-1 space-y-1 text-gray-600 dark:text-gray-300">
<For each={Object.entries(host.sensors!.temperatureCelsius!).slice(0, 5)}>
{([name, temp]) => (
<div class="flex items-center justify-between">
<span class="truncate text-[10px]">{name}</span>
<span class={temp > 80 ? 'text-red-600 dark:text-red-400 font-semibold' : ''}>
{temp.toFixed(1)}°C
</span>
</div>
)}
</For>
</div>
</div>
</Show>
</div>
</td>
</tr>
</Show>
</>
);
}}
</For>
</tbody>
</table>
</ScrollableTable>
</Card>
</Show>
</>
}
>
<Card padding="lg">
<EmptyState
icon={
<svg class="h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
/>
</svg>
}
title="No hosts reporting"
description="Install the Pulse host agent on Linux, macOS, or Windows machines to begin monitoring."
actions={
<button
type="button"
onClick={() => navigate('/settings/hosts')}
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
>
<span>Set up host agent</span>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
}
/>
</Card>
</Show>
</Show>
</div>
);
};

View file

@ -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<string, boolean>;
}
const statusClass: Record<string, string> = {
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<ServersOverviewProps> = (props) => {
const sortedHosts = createMemo(() =>
[...props.hosts].sort((a, b) => a.hostname.localeCompare(b.hostname)),
);
return (
<div class="space-y-6">
<header class="flex flex-col gap-2">
<h1 class="text-2xl font-semibold text-slate-900 dark:text-slate-100">Servers</h1>
<p class="text-sm text-slate-600 dark:text-slate-400">
Unified view of standalone hosts reporting via the Pulse host agent.
</p>
</header>
<Show
when={sortedHosts().length > 0}
fallback={
<EmptyState
title="No servers reporting yet"
description="Install the pulse-host-agent on a Linux, Windows, or macOS machine to have it appear here."
/>
}
>
<div class="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
<For each={sortedHosts()}>
{(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 (
<Card class="flex flex-col gap-4 p-4">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-slate-500 uppercase tracking-wide">
{host.platform ?? 'unknown'}
</p>
<h2 class="text-lg font-semibold text-slate-900 dark:text-slate-100">
{host.displayName || host.hostname}
</h2>
<p class="text-sm text-slate-500 dark:text-slate-400">
{host.osName}
{host.osVersion ? ` ${host.osVersion}` : ''}
</p>
</div>
<span class={`rounded-full px-3 py-1 text-xs font-medium ${statusClasses}`}>
{status}
</span>
</div>
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
<dt class="text-slate-500 dark:text-slate-400">CPU Usage</dt>
<dd class="font-semibold text-slate-900 dark:text-slate-100">
{typeof host.cpuUsage === 'number' ? `${host.cpuUsage.toFixed(1)}%` : '—'}
</dd>
</div>
<div>
<dt class="text-slate-500 dark:text-slate-400">Memory</dt>
<dd class="font-semibold text-slate-900 dark:text-slate-100">
{host.memory?.total
? `${formatBytes(host.memory.used ?? 0)} / ${formatBytes(host.memory.total)}${
memoryUsage !== undefined ? ` (${memoryUsage.toFixed(1)}%)` : ''
}`
: '—'}
</dd>
</div>
<div>
<dt class="text-slate-500 dark:text-slate-400">Architecture</dt>
<dd class="font-semibold text-slate-900 dark:text-slate-100">
{host.architecture ?? '—'}
</dd>
</div>
<div>
<dt class="text-slate-500 dark:text-slate-400">Last Seen</dt>
<dd class="font-semibold text-slate-900 dark:text-slate-100">
{lastSeen.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</dd>
</div>
<div class="col-span-2">
<dt class="text-slate-500 dark:text-slate-400">Connection</dt>
<dd class="font-semibold text-slate-900 dark:text-slate-100">
{isHealthy ? 'Healthy' : 'Unreachable'}
</dd>
</div>
</dl>
<Show when={host.disks && host.disks.length > 0}>
<div class="rounded-md border border-slate-200 bg-slate-50 p-3 dark:border-slate-700/70 dark:bg-slate-900/70">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Storage
</p>
<ul class="mt-2 space-y-1 text-xs text-slate-600 dark:text-slate-300">
<For each={host.disks}>
{(disk) => (
<li class="flex items-center justify-between">
<span class="truncate">
{disk.mountpoint || disk.device || 'disk'} {' '}
{disk.type ? disk.type.toUpperCase() : '—'}
</span>
<span>
{formatBytes(disk.used ?? 0)} / {formatBytes(disk.total ?? 0)}
{typeof disk.usage === 'number'
? ` (${disk.usage.toFixed(1)}%)`
: ''}
</span>
</li>
)}
</For>
</ul>
</div>
</Show>
</Card>
);
}}
</For>
</div>
</Show>
</div>
);
};

View file

@ -10,23 +10,23 @@ interface AgentStepSectionProps {
export const AgentStepSection: Component<AgentStepSectionProps> = (props) => {
return (
<section class="space-y-3">
<header class="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-300">
{props.step}
</p>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">
<section class="space-y-5">
<header class="space-y-2">
<div class="flex items-center gap-2">
<span class="inline-flex items-center justify-center w-7 h-7 rounded-full bg-blue-100 dark:bg-blue-900/30 text-xs font-bold text-blue-700 dark:text-blue-300">
{props.step.replace('Step ', '')}
</span>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{props.title}
</h3>
</div>
<Show when={props.description}>
<p class="text-sm text-gray-600 dark:text-gray-400 sm:text-right">
<p class="text-sm text-gray-600 dark:text-gray-400 leading-relaxed ml-9">
{props.description}
</p>
</Show>
</header>
<div>{props.children}</div>
<div class="ml-9">{props.children}</div>
</section>
);
};

View file

@ -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 (
<div class="space-y-6">
<div class="space-y-8">
{/* Summary Stats */}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div class="bg-gradient-to-br from-blue-50 to-blue-100/50 dark:from-blue-900/20 dark:to-blue-900/10 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-600 dark:bg-blue-500 rounded-lg">
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
</div>
<div>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{dockerHosts().length}</p>
<p class="text-xs font-medium text-gray-600 dark:text-gray-400">Docker Hosts</p>
</div>
</div>
</div>
<div class="bg-gradient-to-br from-green-50 to-green-100/50 dark:from-green-900/20 dark:to-green-900/10 rounded-lg p-4 border border-green-200 dark:border-green-800">
<div class="flex items-center gap-3">
<div class="p-2 bg-green-600 dark:bg-green-500 rounded-lg">
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{dockerHosts().filter(h => h.status?.toLowerCase() === 'online').length}</p>
<p class="text-xs font-medium text-gray-600 dark:text-gray-400">Online Now</p>
</div>
</div>
</div>
<div class="bg-gradient-to-br from-purple-50 to-purple-100/50 dark:from-purple-900/20 dark:to-purple-900/10 rounded-lg p-4 border border-purple-200 dark:border-purple-800">
<div class="flex items-center gap-3">
<div class="p-2 bg-purple-600 dark:bg-purple-500 rounded-lg">
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{dockerHosts().reduce((sum, h) => sum + (h.containers?.length || 0), 0)}</p>
<p class="text-xs font-medium text-gray-600 dark:text-gray-400">Total Containers</p>
</div>
</div>
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<SectionHeader title="Docker agent monitoring" size="md" class="flex-1" />
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Setup & Management</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-0.5">Deploy agents or manage existing Docker hosts</p>
</div>
<button
type="button"
onClick={() => setShowInstructions(!showInstructions())}
@ -490,7 +535,7 @@ WantedBy=multi-user.target`;
type="button"
onClick={openGenerateTokenModal}
disabled={isGeneratingToken()}
class="inline-flex items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
class="inline-flex items-center justify-center gap-2 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-60"
>
{isGeneratingToken() ? 'Generating…' : 'Generate token'}
</button>
@ -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
</button>
</div>
<div class="relative rounded-lg border-2 border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 p-3 overflow-x-auto">
<div class="relative rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 p-3 overflow-x-auto">
<code class="text-sm text-gray-900 dark:text-gray-100 font-mono break-all">
{getInstallCommandTemplate().replace(TOKEN_PLACEHOLDER, apiToken() || TOKEN_PLACEHOLDER)}
</code>
@ -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
</button>
@ -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
</button>
@ -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
</button>
@ -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'}
</button>
@ -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
</button>
@ -952,7 +997,7 @@ WantedBy=multi-user.target`;
<button
type="button"
onClick={closeRemoveModal}
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"
>
Close
</button>

View file

@ -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 = '<api-token>';
const pulseUrl = () => {
if (typeof window === 'undefined') return 'http://localhost:7655';
@ -50,53 +48,32 @@ const pulseUrl = () => {
const commandsByVariant: Record<HostAgentVariant, { title: string; description: string; snippets: { label: string; command: string; note?: string | JSX.Element }[] }> = {
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: (
<span>
Create <code>~/Library/LaunchAgents/com.pulse.host-agent.plist</code> 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.
</span>
),
},
{
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: (
<span>
For persistence, create <code>/etc/systemd/system/pulse-host-agent.service</code> and enable it with{' '}
<code>systemctl enable --now pulse-host-agent</code>.
Automatically installs to <code>/usr/local/bin/pulse-host-agent</code> and creates <code>/etc/systemd/system/pulse-host-agent.service</code>.
</span>
),
},
@ -105,22 +82,14 @@ const commandsByVariant: Record<HostAgentVariant, { title: string; description:
macos: {
title: 'Install on macOS',
description:
'Use the universal macOS build (arm64) with an API token that grants the host agent reporting scope, then register it via launchd for continuous reporting.',
'The installer downloads the universal binary and sets up a launchd service for background monitoring.',
snippets: [
{
label: 'Install binary',
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',
].join(' && '),
},
{
label: 'Launchd service',
command: `launchctl load ~/Library/LaunchAgents/com.pulse.host-agent.plist`,
label: 'Install with launchd',
command: `curl -fsSL ${pulseUrl()}/install-host-agent.sh | bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`,
note: (
<span>
Create a plist pointing to{' '}
<code>/usr/local/bin/pulse-host-agent --url {pulseUrl()} --token {TOKEN_PLACEHOLDER} --interval 30s</code> to run at login.
Creates <code>~/Library/LaunchAgents/com.pulse.host-agent.plist</code> and starts the agent automatically.
</span>
),
},
@ -129,19 +98,23 @@ const commandsByVariant: Record<HostAgentVariant, { title: string; description:
windows: {
title: 'Install on Windows',
description:
'Native Windows builds are coming soon. In the interim you can run the Linux binary under WSL or compile from source using an API token scoped for host agent reporting.',
'Run the PowerShell script to install and configure the host agent as a Windows service with automatic startup.',
snippets: [
{
label: 'Compile from source (PowerShell)',
command: [
'git clone https://github.com/rcourtman/Pulse.git',
'cd Pulse',
'go build -o pulse-host-agent.exe ./cmd/pulse-host-agent',
`./pulse-host-agent.exe --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`,
].join(' && '),
label: 'Install as Windows Service (PowerShell)',
command: `irm ${pulseUrl()}/install-host-agent.ps1 | iex`,
note: (
<span>
Consider registering the executable as a Windows Service via <code>sc.exe</code> 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.
</span>
),
},
{
label: 'Install with parameters (PowerShell)',
command: `$env:PULSE_URL="${pulseUrl()}"; $env:PULSE_TOKEN="${TOKEN_PLACEHOLDER}"; irm ${pulseUrl()}/install-host-agent.ps1 | iex`,
note: (
<span>
Non-interactive installation. Set environment variables before running to skip prompts.
</span>
),
},
@ -327,34 +300,34 @@ export const HostAgents: Component<HostAgentsProps> = (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<HostAgentsProps> = (props) => {
return (
<button
type="button"
class={`flex flex-col items-start gap-2 rounded-xl border transition-colors p-4 text-left shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-900 ${
class={`flex flex-col items-start gap-2 rounded-lg border transition-colors p-4 text-left shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-900 ${
isActive()
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 hover:border-blue-300 dark:hover:border-blue-500'
@ -450,7 +423,7 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
type="button"
onClick={openGenerateTokenModal}
disabled={isGeneratingToken()}
class="inline-flex items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
class="inline-flex items-center justify-center gap-2 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-60"
>
{isGeneratingToken() ? 'Generating…' : 'Generate token'}
</button>
@ -470,10 +443,10 @@ export const HostAgents: Component<HostAgentsProps> = (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<HostAgentsProps> = (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
</button>
@ -601,7 +574,7 @@ export const HostAgents: Component<HostAgentsProps> = (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
</button>
@ -609,7 +582,7 @@ export const HostAgents: Component<HostAgentsProps> = (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'}
</button>
@ -618,7 +591,7 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
</div>
</Show>
<Card padding="lg" class="space-y-4">
<Card padding="lg" class="space-y-5">
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Reporting hosts</h3>
<span class="text-sm text-gray-500 dark:text-gray-400">{hosts().length} connected</span>
@ -629,7 +602,7 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
fallback={
<p class="text-sm text-gray-600 dark:text-gray-400">
{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.'}
</p>
}
@ -644,32 +617,92 @@ export const HostAgents: Component<HostAgentsProps> = (props) => {
<th class="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Memory</th>
<th class="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Last seen</th>
<th class="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Tags</th>
<th class="px-3 py-2 text-right font-semibold text-gray-700 dark:text-gray-300">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
<For each={hosts()}>
{(host) => (
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/60">
<td class="px-3 py-2 font-medium text-gray-900 dark:text-gray-100">
{host.displayName || host.hostname || host.id}
</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-300 capitalize">
{host.platform || '—'}
</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
{host.uptimeSeconds ? formatUptime(host.uptimeSeconds) : '—'}
</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
{host.memory?.total
? `${formatBytes(host.memory.used ?? 0)} / ${formatBytes(host.memory.total)}`
: '—'}
</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
{host.lastSeen ? formatRelativeTime(host.lastSeen) : '—'}
</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">{renderTags(host)}</td>
</tr>
)}
{(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 (
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/60">
<td class="px-3 py-2 font-medium text-gray-900 dark:text-gray-100">
{host.displayName || host.hostname || host.id}
</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-300 capitalize">
{host.platform || '—'}
</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
{host.uptimeSeconds ? formatUptime(host.uptimeSeconds) : '—'}
</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
{host.memory?.total
? `${formatBytes(host.memory.used ?? 0)} / ${formatBytes(host.memory.total)}`
: '—'}
</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">
{host.lastSeen ? formatRelativeTime(host.lastSeen) : '—'}
</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-300">{renderTags(host)}</td>
<td class="px-3 py-2 text-right">
<button
type="button"
onClick={handleDelete}
disabled={isDeleting()}
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Remove host from monitoring"
>
{isDeleting() ? (
<>
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span>Removing...</span>
</>
) : (
<>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span>Remove</span>
</>
)}
</button>
</td>
</tr>
);
}}
</For>
</tbody>
</table>

View file

@ -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<SettingsTab, { title: string; description: string }> = {
'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<SettingsProps> = (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<SettingsProps> = (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: <Server class="w-6 h-6" strokeWidth={2} />,
},
{
id: 'pbs',
label: 'Proxmox Backup Server',
description: 'Monitor backup jobs and datastore health.',
label: 'Backup Server',
description: 'Backup jobs, datastores, and snapshots',
icon: <HardDrive class="w-6 h-6" strokeWidth={2} />,
},
{
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: <Mail class="w-6 h-6" strokeWidth={2} />,
},
],
},
{
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: <Container class="w-6 h-6" strokeWidth={2} />,
},
{
id: 'host',
label: 'Pulse host agent',
description: 'Install on Linux, macOS, or Windows servers.',
icon: <Monitor class="w-6 h-6" strokeWidth={2} />,
},
],
},
{
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: <Boxes class="w-6 h-6" strokeWidth={2} />,
disabled: true,
},
{
id: 'kubernetes',
label: 'Kubernetes (coming soon)',
description: 'Native Kubernetes monitoring is on the roadmap.',
icon: <Network class="w-6 h-6" strokeWidth={2} />,
disabled: true,
},
],
},
];
const agentPaths: Record<AgentKey, string> = {
@ -884,7 +858,7 @@ const Settings: Component<SettingsProps> = (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<SettingsProps> = (props) => {
id: 'platforms',
label: 'Platforms',
items: [
{ id: 'agent-hub', label: 'Agent deployments', icon: <Server class="w-4 h-4" strokeWidth={2} /> },
{ id: 'podman', label: 'Podman hosts', icon: <Boxes class="w-4 h-4" strokeWidth={2} />, disabled: true },
{ id: 'agent-hub', label: 'Proxmox', icon: <Server class="w-4 h-4" strokeWidth={2} /> },
{ id: 'docker', label: 'Docker', icon: <Container class="w-4 h-4" strokeWidth={2} /> },
{ id: 'servers', label: 'Hosts', icon: <Monitor class="w-4 h-4" strokeWidth={2} /> },
{ id: 'podman', label: 'Podman', icon: <Boxes class="w-4 h-4" strokeWidth={2} />, disabled: true },
{ id: 'kubernetes', label: 'Kubernetes', icon: <Network class="w-4 h-4" strokeWidth={2} />, disabled: true },
],
},
{
id: 'administration',
label: 'Administration',
id: 'operations',
label: 'Operations',
items: [
{ id: 'api', label: 'API access', icon: <ApiIcon class="w-4 h-4" /> },
{ id: 'api', label: 'API Tokens', icon: <ApiIcon class="w-4 h-4" /> },
{ id: 'diagnostics', label: 'Diagnostics', icon: <Activity class="w-4 h-4" strokeWidth={2} /> },
],
},
@ -1950,15 +1926,16 @@ const Settings: Component<SettingsProps> = (props) => {
return (
<>
<div class="space-y-4">
{/* Header with better styling */}
<Card padding="md">
<SectionHeader
title={headerMeta().title}
description={headerMeta().description}
size="lg"
/>
</Card>
<div class="space-y-6">
{/* Page header - no card wrapper for cleaner hierarchy */}
<div class="px-1">
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
{headerMeta().title}
</h1>
<p class="text-base text-gray-600 dark:text-gray-400">
{headerMeta().description}
</p>
</div>
{/* Save notification bar - only show when there are unsaved changes */}
<Show
@ -1969,34 +1946,34 @@ const Settings: Component<SettingsProps> = (props) => {
activeTab() === 'system-updates' || activeTab() === 'system-backups')
}
>
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 sm:p-4">
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div class="flex items-center gap-2 text-yellow-800 dark:text-yellow-200">
<div class="bg-amber-50 dark:bg-amber-900/30 border-l-4 border-amber-500 dark:border-amber-400 rounded-r-lg shadow-sm p-4">
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div class="flex items-start gap-3">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
class="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span class="text-sm font-medium">You have unsaved changes</span>
<div>
<p class="font-semibold text-amber-900 dark:text-amber-100">Unsaved changes</p>
<p class="text-sm text-amber-700 dark:text-amber-200 mt-0.5">Your changes will be lost if you navigate away</p>
</div>
</div>
<div class="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<div class="flex w-full sm:w-auto gap-3">
<button
type="button"
class="flex-1 sm:flex-initial px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
class="flex-1 sm:flex-initial px-5 py-2.5 text-sm font-medium bg-amber-600 text-white rounded-lg hover:bg-amber-700 shadow-sm transition-colors"
onClick={saveSettings}
>
Save Changes
</button>
<button
type="button"
class="flex-1 sm:flex-initial px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
class="px-4 py-2.5 text-sm font-medium text-amber-700 dark:text-amber-200 hover:underline transition-colors"
onClick={() => {
window.location.reload();
}}
@ -2010,14 +1987,39 @@ const Settings: Component<SettingsProps> = (props) => {
<Card padding="none" class="relative lg:flex overflow-hidden">
<div
class={`hidden lg:flex lg:flex-col ${sidebarCollapsed() ? 'w-16' : 'w-72'} ${sidebarCollapsed() ? 'lg:min-w-[4rem] lg:max-w-[4rem] lg:basis-[4rem]' : 'lg:min-w-[18rem] lg:max-w-[18rem] lg:basis-[18rem]'} relative border-b border-gray-200 dark:border-gray-700 lg:border-b-0 lg:border-r lg:border-gray-200 dark:lg:border-gray-700 lg:align-top flex-shrink-0 transition-all duration-300`}
onMouseEnter={() => setSidebarCollapsed(false)}
onMouseLeave={() => setSidebarCollapsed(true)}
class={`hidden lg:flex lg:flex-col ${sidebarCollapsed() ? 'w-16' : 'w-64'} ${sidebarCollapsed() ? 'lg:min-w-[4rem] lg:max-w-[4rem] lg:basis-[4rem]' : 'lg:min-w-[16rem] lg:max-w-[16rem] lg:basis-[16rem]'} relative border-b border-gray-200 dark:border-gray-700 lg:border-b-0 lg:border-r lg:border-gray-200 dark:lg:border-gray-700 lg:align-top flex-shrink-0 transition-all duration-200`}
aria-label="Settings navigation"
aria-expanded={!sidebarCollapsed()}
>
<div class={`sticky top-0 ${sidebarCollapsed() ? 'px-2' : 'px-5'} py-6 space-y-6 transition-all duration-300`}>
<div id="settings-sidebar-menu" class="space-y-6">
<div class={`sticky top-0 ${sidebarCollapsed() ? 'px-2' : 'px-4'} py-5 space-y-5 transition-all duration-200`}>
<Show when={!sidebarCollapsed()}>
<div class="flex items-center justify-between pb-2 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Settings</h2>
<button
type="button"
onClick={() => setSidebarCollapsed(true)}
class="p-1 rounded-md text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Collapse sidebar"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
</button>
</div>
</Show>
<Show when={sidebarCollapsed()}>
<button
type="button"
onClick={() => setSidebarCollapsed(false)}
class="w-full p-2 rounded-md text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Expand sidebar"
>
<svg class="w-5 h-5 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
</Show>
<div id="settings-sidebar-menu" class="space-y-5">
<For each={tabGroups}>
{(group) => (
<div class="space-y-2">
@ -2104,7 +2106,7 @@ const Settings: Component<SettingsProps> = (props) => {
</div>
</Show>
<div class="p-3 sm:p-6 overflow-x-auto">
<div class="p-6 lg:p-8">
<Show when={activeTab() === 'agent-hub'}>
<AgentStepSection
step="Step 1"
@ -3537,6 +3539,16 @@ const Settings: Component<SettingsProps> = (props) => {
</AgentStepSection>
</Show>
{/* Docker Platform Tab */}
<Show when={activeTab() === 'docker'}>
<DockerAgents />
</Show>
{/* Servers Platform Tab */}
<Show when={activeTab() === 'servers'}>
<HostAgents variant="all" />
</Show>
{/* Podman Tab */}
<Show when={activeTab() === 'podman'}>
<PlatformComingSoon

View file

@ -1,10 +1,10 @@
import type { Component } from 'solid-js';
interface ServersIconProps {
interface HostsIconProps {
class?: string;
}
export const ServersIcon: Component<ServersIconProps> = (props) => (
export const HostsIcon: Component<HostsIconProps> = (props) => (
<svg
class={props.class}
xmlns="http://www.w3.org/2000/svg"

View file

@ -100,6 +100,9 @@ export const STORAGE_KEYS = {
// Docker search
DOCKER_SEARCH_HISTORY: 'dockerSearchHistory',
// Hosts search
HOSTS_SEARCH_HISTORY: 'hostsSearchHistory',
// Alerts search
ALERTS_SEARCH_HISTORY: 'alertsSearchHistory',

View file

@ -68,6 +68,14 @@ export default defineConfig({
target: backendUrl,
changeOrigin: true,
},
'/install-host-agent.sh': {
target: backendUrl,
changeOrigin: true,
},
'/install-host-agent.ps1': {
target: backendUrl,
changeOrigin: true,
},
'/download': {
target: backendUrl,
changeOrigin: true,

10
go.mod
View file

@ -15,7 +15,7 @@ require (
github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.23.2
github.com/rs/zerolog v1.34.0
github.com/shirou/gopsutil/v3 v3.24.5
github.com/shirou/gopsutil/v4 v4.25.9
github.com/spf13/cobra v1.9.1
golang.org/x/crypto v0.42.0
golang.org/x/oauth2 v0.31.0
@ -35,6 +35,7 @@ require (
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.9.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-logr/logr v1.4.3 // indirect
@ -51,14 +52,13 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/spf13/pflag v1.0.7 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect

24
go.sum
View file

@ -28,6 +28,8 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
@ -95,8 +97,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
@ -111,12 +113,8 @@ github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/shirou/gopsutil/v4 v4.25.9 h1:JImNpf6gCVhKgZhtaAHJ0serfFGtlfIlSC08eaKdTrU=
github.com/shirou/gopsutil/v4 v4.25.9/go.mod h1:gxIxoC+7nQRwUl/xNhutXlD8lq+jxTgpIkEf3rADHL8=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
@ -128,10 +126,10 @@ github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
@ -170,8 +168,6 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=

View file

@ -3,6 +3,7 @@ package api
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
@ -76,3 +77,37 @@ func (h *HostAgentHandlers) HandleReport(w http.ResponseWriter, r *http.Request)
log.Error().Err(err).Msg("Failed to serialize host agent response")
}
}
// HandleDeleteHost removes a host from the shared state.
func (h *HostAgentHandlers) HandleDeleteHost(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only DELETE is allowed", nil)
return
}
// Extract host ID from URL path
// Expected format: /api/agents/host/{hostId}
trimmedPath := strings.TrimPrefix(r.URL.Path, "/api/agents/host/")
hostID := strings.TrimSpace(trimmedPath)
if hostID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_host_id", "Host ID is required", nil)
return
}
// Remove the host from state
host, err := h.monitor.RemoveHostAgent(hostID)
if err != nil {
writeErrorResponse(w, http.StatusNotFound, "host_not_found", err.Error(), nil)
return
}
go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend())
if err := utils.WriteJSONResponse(w, map[string]any{
"success": true,
"hostId": host.ID,
"message": "Host removed",
}); err != nil {
log.Error().Err(err).Msg("Failed to serialize host removal response")
}
}

View file

@ -129,6 +129,7 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/state", r.handleState)
r.mux.HandleFunc("/api/agents/docker/report", RequireAuth(r.config, RequireScope(config.ScopeDockerReport, r.dockerAgentHandlers.HandleReport)))
r.mux.HandleFunc("/api/agents/host/report", RequireAuth(r.config, RequireScope(config.ScopeHostReport, r.hostAgentHandlers.HandleReport)))
r.mux.HandleFunc("/api/agents/host/", RequireAdmin(r.config, RequireScope(config.ScopeHostManage, r.hostAgentHandlers.HandleDeleteHost)))
r.mux.HandleFunc("/api/agents/docker/commands/", RequireAuth(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleCommandAck)))
r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleDockerHostActions)))
r.mux.HandleFunc("/api/version", r.handleVersion)
@ -876,6 +877,13 @@ func (r *Router) setupRoutes() {
// Docker agent download endpoints
r.mux.HandleFunc("/install-docker-agent.sh", r.handleDownloadInstallScript)
r.mux.HandleFunc("/download/pulse-docker-agent", r.handleDownloadAgent)
// Host agent download endpoints
r.mux.HandleFunc("/install-host-agent.sh", r.handleDownloadHostAgentInstallScript)
r.mux.HandleFunc("/install-host-agent.ps1", r.handleDownloadHostAgentInstallScriptPS)
r.mux.HandleFunc("/uninstall-host-agent.ps1", r.handleDownloadHostAgentUninstallScriptPS)
r.mux.HandleFunc("/download/pulse-host-agent", r.handleDownloadHostAgent)
r.mux.HandleFunc("/api/agent/version", r.handleAgentVersion)
r.mux.HandleFunc("/api/server/info", r.handleServerInfo)
@ -1213,6 +1221,10 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
config.DefaultOIDCCallbackPath,
"/install-docker-agent.sh", // Docker agent bootstrap script must be public
"/download/pulse-docker-agent", // Agent binary download should not require auth
"/install-host-agent.sh", // Host agent bootstrap script must be public
"/install-host-agent.ps1", // Host agent PowerShell script must be public
"/uninstall-host-agent.ps1", // Host agent uninstall script must be public
"/download/pulse-host-agent", // Host agent binary download should not require auth
"/api/agent/version", // Agent update checks need to work before auth
"/api/server/info", // Server info for installer script
"/api/install/install-sensor-proxy.sh", // Temperature proxy installer fallback
@ -1340,7 +1352,10 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
strings.HasPrefix(req.URL.Path, "/socket.io/") ||
strings.HasPrefix(req.URL.Path, "/download/") ||
req.URL.Path == "/simple-stats" ||
req.URL.Path == "/install-docker-agent.sh" {
req.URL.Path == "/install-docker-agent.sh" ||
req.URL.Path == "/install-host-agent.sh" ||
req.URL.Path == "/install-host-agent.ps1" ||
req.URL.Path == "/uninstall-host-agent.ps1" {
// Use the mux for API and special routes
r.mux.ServeHTTP(w, req)
} else {
@ -3211,6 +3226,110 @@ func (r *Router) handleDownloadAgent(w http.ResponseWriter, req *http.Request) {
http.Error(w, "Agent binary not found", http.StatusNotFound)
}
// handleDownloadHostAgentInstallScript serves the Host agent installation script
func (r *Router) handleDownloadHostAgentInstallScript(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Prevent caching - always serve the latest version
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
scriptPath := "/opt/pulse/scripts/install-host-agent.sh"
http.ServeFile(w, req, scriptPath)
}
// handleDownloadHostAgentInstallScriptPS serves the PowerShell installation script for Windows
func (r *Router) handleDownloadHostAgentInstallScriptPS(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Prevent caching - always serve the latest version
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
scriptPath := "/opt/pulse/scripts/install-host-agent.ps1"
http.ServeFile(w, req, scriptPath)
}
// handleDownloadHostAgentUninstallScriptPS serves the PowerShell uninstallation script for Windows
func (r *Router) handleDownloadHostAgentUninstallScriptPS(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Prevent caching - always serve the latest version
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
scriptPath := "/opt/pulse/scripts/uninstall-host-agent.ps1"
http.ServeFile(w, req, scriptPath)
}
// handleDownloadHostAgent serves the Host agent binary
func (r *Router) handleDownloadHostAgent(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Prevent caching - always serve the latest version
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
platformParam := strings.TrimSpace(req.URL.Query().Get("platform"))
archParam := strings.TrimSpace(req.URL.Query().Get("arch"))
searchPaths := make([]string, 0, 12)
// Try platform-specific binary first
if platformParam != "" && archParam != "" {
searchPaths = append(searchPaths,
filepath.Join("/opt/pulse/bin", fmt.Sprintf("pulse-host-agent-%s-%s", platformParam, archParam)),
filepath.Join("/opt/pulse", fmt.Sprintf("pulse-host-agent-%s-%s", platformParam, archParam)),
filepath.Join("/app", fmt.Sprintf("pulse-host-agent-%s-%s", platformParam, archParam)),
)
}
if platformParam != "" {
searchPaths = append(searchPaths,
filepath.Join("/opt/pulse/bin", "pulse-host-agent-"+platformParam),
filepath.Join("/opt/pulse", "pulse-host-agent-"+platformParam),
filepath.Join("/app", "pulse-host-agent-"+platformParam),
)
}
// Default locations (host architecture)
searchPaths = append(searchPaths,
filepath.Join("/opt/pulse/bin", "pulse-host-agent"),
"/opt/pulse/pulse-host-agent",
filepath.Join("/app", "pulse-host-agent"),
)
for _, candidate := range searchPaths {
if candidate == "" {
continue
}
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
http.ServeFile(w, req, candidate)
return
}
}
http.Error(w, "Host agent binary not found. Please build from source: go build ./cmd/pulse-host-agent", http.StatusNotFound)
}
func (r *Router) handleDiagnosticsRegisterProxyNodes(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil)

View file

@ -17,6 +17,7 @@ const (
ScopeDockerReport = "docker:report"
ScopeDockerManage = "docker:manage"
ScopeHostReport = "host-agent:report"
ScopeHostManage = "host-agent:manage"
ScopeSettingsRead = "settings:read"
ScopeSettingsWrite = "settings:write"
)
@ -28,6 +29,7 @@ var AllKnownScopes = []string{
ScopeDockerReport,
ScopeDockerManage,
ScopeHostReport,
ScopeHostManage,
ScopeSettingsRead,
ScopeSettingsWrite,
}

View file

@ -15,12 +15,12 @@ import (
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
"github.com/rs/zerolog"
gocpu "github.com/shirou/gopsutil/v3/cpu"
godisk "github.com/shirou/gopsutil/v3/disk"
gohost "github.com/shirou/gopsutil/v3/host"
goload "github.com/shirou/gopsutil/v3/load"
gomem "github.com/shirou/gopsutil/v3/mem"
gonet "github.com/shirou/gopsutil/v3/net"
gocpu "github.com/shirou/gopsutil/v4/cpu"
godisk "github.com/shirou/gopsutil/v4/disk"
gohost "github.com/shirou/gopsutil/v4/host"
goload "github.com/shirou/gopsutil/v4/load"
gomem "github.com/shirou/gopsutil/v4/mem"
gonet "github.com/shirou/gopsutil/v4/net"
)
// Config controls the behaviour of the host agent.
@ -54,8 +54,6 @@ type Agent struct {
agentID string
interval time.Duration
trimmedPulseURL string
prevCPUTimes *gocpu.TimesStat
}
const defaultInterval = 30 * time.Second
@ -291,34 +289,17 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) {
}
func (a *Agent) calculateCPUUsage(ctx context.Context) (float64, error) {
times, err := gocpu.TimesWithContext(ctx, false)
// Use Percent() with a 1 second measurement interval for cross-platform compatibility
// This works reliably on macOS ARM64 where Times() is not implemented
percentages, err := gocpu.PercentWithContext(ctx, time.Second, false)
if err != nil {
return 0, err
}
if len(times) == 0 {
return 0, nil
}
current := times[0]
if a.prevCPUTimes == nil {
a.prevCPUTimes = &current
if len(percentages) == 0 {
return 0, nil
}
prev := a.prevCPUTimes
a.prevCPUTimes = &current
deltaTotal := current.Total() - prev.Total()
if deltaTotal <= 0 {
return 0, nil
}
deltaIdle := current.Idle - prev.Idle
if deltaIdle < 0 {
deltaIdle = 0
}
usage := (1 - (deltaIdle / deltaTotal)) * 100
usage := percentages[0]
if usage < 0 {
usage = 0
}

View file

@ -702,6 +702,35 @@ func (m *Monitor) RemoveDockerHost(hostID string) (models.DockerHost, error) {
return host, nil
}
// RemoveHostAgent removes a host agent from monitoring state and clears related data.
func (m *Monitor) RemoveHostAgent(hostID string) (models.Host, error) {
hostID = strings.TrimSpace(hostID)
if hostID == "" {
return models.Host{}, fmt.Errorf("host id is required")
}
host, removed := m.state.RemoveHost(hostID)
if !removed {
if logging.IsLevelEnabled(zerolog.DebugLevel) {
log.Debug().Str("hostID", hostID).Msg("Host not present in state during removal")
}
host = models.Host{
ID: hostID,
Hostname: hostID,
}
}
m.state.RemoveConnectionHealth(hostConnectionPrefix + hostID)
log.Info().
Str("host", host.Hostname).
Str("hostID", hostID).
Bool("removed", removed).
Msg("Host agent removed from monitoring")
return host, nil
}
// HideDockerHost marks a docker host as hidden without removing it from state.
// Hidden hosts will not be shown in the frontend but will continue to accept updates.
func (m *Monitor) HideDockerHost(hostID string) (models.DockerHost, error) {

View file

@ -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 ""

251
scripts/install-host-agent.sh Executable file
View file

@ -0,0 +1,251 @@
#!/bin/bash
set -e
# Pulse Host Agent Installer
# Downloads and installs the Pulse host agent for Linux, macOS, or Windows (WSL)
trim() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
log_info() {
printf '[INFO] %s\n' "$1"
}
log_success() {
printf '[ OK ] %s\n' "$1"
}
log_warn() {
printf '[WARN] %s\n' "$1" >&2
}
log_error() {
printf '[ERROR] %s\n' "$1" >&2
}
# Parse arguments
PULSE_URL=""
PULSE_TOKEN=""
INTERVAL="30s"
UNINSTALL="false"
PLATFORM=""
while [[ $# -gt 0 ]]; do
case "$1" in
--url)
PULSE_URL="$2"
shift 2
;;
--token)
PULSE_TOKEN="$2"
shift 2
;;
--interval)
INTERVAL="$2"
shift 2
;;
--platform)
PLATFORM="$2"
shift 2
;;
--uninstall)
UNINSTALL="true"
shift
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
AGENT_PATH="/usr/local/bin/pulse-host-agent"
SYSTEMD_SERVICE="/etc/systemd/system/pulse-host-agent.service"
LAUNCHD_PLIST="$HOME/Library/LaunchAgents/com.pulse.host-agent.plist"
# Uninstall function
if [[ "$UNINSTALL" == "true" ]]; then
log_info "Uninstalling Pulse host agent..."
# Stop and disable systemd service (Linux)
if [[ -f "$SYSTEMD_SERVICE" ]] && command -v systemctl &> /dev/null; then
sudo systemctl stop pulse-host-agent 2>/dev/null || true
sudo systemctl disable pulse-host-agent 2>/dev/null || true
sudo rm -f "$SYSTEMD_SERVICE"
sudo systemctl daemon-reload
log_success "Removed systemd service"
fi
# Stop and remove launchd service (macOS)
if [[ -f "$LAUNCHD_PLIST" ]] && command -v launchctl &> /dev/null; then
launchctl unload "$LAUNCHD_PLIST" 2>/dev/null || true
rm -f "$LAUNCHD_PLIST"
log_success "Removed launchd service"
fi
# Remove binary
if [[ -f "$AGENT_PATH" ]]; then
sudo rm -f "$AGENT_PATH"
log_success "Removed agent binary"
fi
log_success "Pulse host agent uninstalled"
exit 0
fi
# Validate required parameters for install
if [[ -z "$PULSE_URL" ]]; then
log_error "Missing required parameter: --url"
echo "Usage: $0 --url <pulse-url> --token <api-token> [--interval 30s] [--platform linux|darwin|windows]"
exit 1
fi
if [[ -z "$PULSE_TOKEN" ]] && [[ "$PULSE_TOKEN" != "disabled" ]]; then
log_error "Missing required parameter: --token"
echo "Usage: $0 --url <pulse-url> --token <api-token> [--interval 30s] [--platform linux|darwin|windows]"
exit 1
fi
# Detect platform if not specified
if [[ -z "$PLATFORM" ]]; then
case "$(uname -s)" in
Linux*)
PLATFORM="linux"
;;
Darwin*)
PLATFORM="darwin"
;;
MINGW*|MSYS*|CYGWIN*)
PLATFORM="windows"
;;
*)
log_error "Unsupported platform: $(uname -s)"
exit 1
;;
esac
fi
# Detect architecture
ARCH="$(uname -m)"
case "$ARCH" in
x86_64|amd64)
ARCH="amd64"
;;
aarch64|arm64)
ARCH="arm64"
;;
armv7l|armhf)
ARCH="armv7"
;;
*)
log_warn "Unknown architecture $ARCH, defaulting to amd64"
ARCH="amd64"
;;
esac
log_info "Installing Pulse host agent for $PLATFORM/$ARCH..."
# Download agent binary from Pulse server
DOWNLOAD_URL="$PULSE_URL/download/pulse-host-agent?platform=$PLATFORM&arch=$ARCH"
TEMP_BINARY="/tmp/pulse-host-agent-$$.tmp"
log_info "Downloading agent binary from $PULSE_URL..."
if command -v curl &> /dev/null; then
if ! curl -fL --progress-bar -o "$TEMP_BINARY" "$DOWNLOAD_URL"; then
log_error "Failed to download agent binary from $DOWNLOAD_URL"
log_info "The server may not have prebuilt binaries yet. You can build from source:"
log_info " git clone https://github.com/rcourtman/Pulse.git && cd Pulse"
log_info " go build -o pulse-host-agent ./cmd/pulse-host-agent"
log_info " sudo mv pulse-host-agent /usr/local/bin/"
rm -f "$TEMP_BINARY"
exit 1
fi
elif command -v wget &> /dev/null; then
if ! wget -q --show-progress -O "$TEMP_BINARY" "$DOWNLOAD_URL"; then
log_error "Failed to download agent binary from $DOWNLOAD_URL"
rm -f "$TEMP_BINARY"
exit 1
fi
else
log_error "Neither curl nor wget found. Please install one of them."
exit 1
fi
sudo mv "$TEMP_BINARY" "$AGENT_PATH"
sudo chmod +x "$AGENT_PATH"
log_success "Agent binary installed to $AGENT_PATH"
# Set up service based on platform
if [[ "$PLATFORM" == "linux" ]] && command -v systemctl &> /dev/null; then
log_info "Setting up systemd service..."
sudo tee "$SYSTEMD_SERVICE" > /dev/null <<EOF
[Unit]
Description=Pulse Host Agent
After=network.target
[Service]
Type=simple
ExecStart=$AGENT_PATH --url $PULSE_URL --token $PULSE_TOKEN --interval $INTERVAL
Restart=always
RestartSec=5s
User=root
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable pulse-host-agent
sudo systemctl start pulse-host-agent
log_success "Systemd service enabled and started"
elif [[ "$PLATFORM" == "darwin" ]] && command -v launchctl &> /dev/null; then
log_info "Setting up launchd service..."
mkdir -p "$HOME/Library/LaunchAgents"
cat > "$LAUNCHD_PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.pulse.host-agent</string>
<key>ProgramArguments</key>
<array>
<string>$AGENT_PATH</string>
<string>--url</string>
<string>$PULSE_URL</string>
<string>--token</string>
<string>$PULSE_TOKEN</string>
<string>--interval</string>
<string>$INTERVAL</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/pulse-host-agent.log</string>
<key>StandardErrorPath</key>
<string>/tmp/pulse-host-agent.log</string>
</dict>
</plist>
EOF
launchctl load "$LAUNCHD_PLIST"
log_success "Launchd service enabled and started"
else
log_warn "Automatic service setup not available for this platform"
log_info "To run the agent manually:"
log_info " $AGENT_PATH --url $PULSE_URL --token $PULSE_TOKEN --interval $INTERVAL"
fi
log_success "Pulse host agent installation complete!"
log_info "The agent is now reporting to $PULSE_URL"

View file

@ -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 ""