mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
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:
parent
745e6b386b
commit
6333a445e9
27 changed files with 2499 additions and 428 deletions
|
|
@ -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")
|
||||
|
|
|
|||
18
cmd/pulse-host-agent/service_stub.go
Normal file
18
cmd/pulse-host-agent/service_stub.go
Normal 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
|
||||
}
|
||||
167
cmd/pulse-host-agent/service_windows.go
Normal file
167
cmd/pulse-host-agent/service_windows.go
Normal 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)
|
||||
}
|
||||
BIN
frontend-modern/public/download/pulse-host-agent-windows-amd64
Executable file
BIN
frontend-modern/public/download/pulse-host-agent-windows-amd64
Executable file
Binary file not shown.
228
frontend-modern/public/install-host-agent.ps1
Normal file
228
frontend-modern/public/install-host-agent.ps1
Normal 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 ""
|
||||
135
frontend-modern/public/uninstall-host-agent.ps1
Normal file
135
frontend-modern/public/uninstall-host-agent.ps1
Normal 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 ""
|
||||
|
|
@ -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" />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
|
|||
333
frontend-modern/src/components/Hosts/HostsFilter.tsx
Normal file
333
frontend-modern/src/components/Hosts/HostsFilter.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
460
frontend-modern/src/components/Hosts/HostsOverview.tsx
Normal file
460
frontend-modern/src/components/Hosts/HostsOverview.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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
10
go.mod
|
|
@ -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
24
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = ¤t
|
||||
if len(percentages) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
prev := a.prevCPUTimes
|
||||
a.prevCPUTimes = ¤t
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
228
scripts/install-host-agent.ps1
Normal file
228
scripts/install-host-agent.ps1
Normal 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
251
scripts/install-host-agent.sh
Executable 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"
|
||||
135
scripts/uninstall-host-agent.ps1
Normal file
135
scripts/uninstall-host-agent.ps1
Normal 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 ""
|
||||
Loading…
Add table
Add a link
Reference in a new issue