WIP: Save all pending changes including frontend updates and unified agent scaffolding

This commit is contained in:
courtmanr@gmail.com 2025-11-25 11:27:07 +00:00
parent af2caa4b81
commit 26ebd476da
10 changed files with 987 additions and 573 deletions

View file

@ -88,6 +88,12 @@ build-agents:
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-linux-armv7 ./cmd/pulse-host-agent && \ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-linux-armv7 ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-darwin-amd64 ./cmd/pulse-host-agent && \ CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-darwin-amd64 ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-darwin-arm64 ./cmd/pulse-host-agent && \ CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-darwin-arm64 ./cmd/pulse-host-agent && \
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-windows-amd64.exe ./cmd/pulse-host-agent CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-windows-amd64.exe ./cmd/pulse-host-agent && \
echo "Building unified agent binaries..." && \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.Version=v$$VERSION" -trimpath -o bin/pulse-agent-linux-amd64 ./cmd/pulse-agent && \
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.Version=v$$VERSION" -trimpath -o bin/pulse-agent-linux-arm64 ./cmd/pulse-agent && \
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w -X main.Version=v$$VERSION" -trimpath -o bin/pulse-agent-macos-amd64 ./cmd/pulse-agent && \
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w -X main.Version=v$$VERSION" -trimpath -o bin/pulse-agent-macos-arm64 ./cmd/pulse-agent && \
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w -X main.Version=v$$VERSION" -trimpath -o bin/pulse-agent-windows-amd64.exe ./cmd/pulse-agent
@ln -sf pulse-host-agent-windows-amd64.exe bin/pulse-host-agent-windows-amd64 @ln -sf pulse-host-agent-windows-amd64.exe bin/pulse-host-agent-windows-amd64
@echo "✓ All agent binaries built in bin/" @echo "✓ All agent binaries built in bin/"

257
cmd/pulse-agent/main.go Normal file
View file

@ -0,0 +1,257 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/dockeragent"
"github.com/rcourtman/pulse-go-rewrite/internal/hostagent"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rs/zerolog"
"golang.org/x/sync/errgroup"
)
var (
Version = "dev"
)
type multiValue []string
func (m *multiValue) String() string {
return strings.Join(*m, ",")
}
func (m *multiValue) Set(value string) error {
*m = append(*m, value)
return nil
}
func main() {
// 1. Parse Configuration
cfg := loadConfig()
// 2. Setup Logging
zerolog.SetGlobalLevel(cfg.LogLevel)
logger := zerolog.New(os.Stdout).Level(cfg.LogLevel).With().Timestamp().Logger()
cfg.Logger = &logger
// 3. Setup Context & Signal Handling
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
g, ctx := errgroup.WithContext(ctx)
logger.Info().
Str("version", Version).
Str("pulse_url", cfg.PulseURL).
Bool("host_agent", cfg.EnableHost).
Bool("docker_agent", cfg.EnableDocker).
Msg("Starting Pulse Unified Agent")
// 4. Start Host Agent (if enabled)
if cfg.EnableHost {
hostCfg := hostagent.Config{
PulseURL: cfg.PulseURL,
APIToken: cfg.APIToken,
Interval: cfg.Interval,
HostnameOverride: cfg.HostnameOverride,
AgentID: cfg.AgentID, // Shared ID? Or separate? Usually separate for now.
Tags: cfg.Tags,
InsecureSkipVerify: cfg.InsecureSkipVerify,
LogLevel: cfg.LogLevel,
Logger: &logger,
}
// If AgentID is set globally, we might want to suffix it or let the agents derive their own.
// For now, let's pass it through. If it's empty, agents derive their own.
agent, err := hostagent.New(hostCfg)
if err != nil {
logger.Fatal().Err(err).Msg("Failed to initialize host agent")
}
g.Go(func() error {
logger.Info().Msg("Host agent module started")
return agent.Run(ctx)
})
}
// 5. Start Docker Agent (if enabled)
if cfg.EnableDocker {
dockerCfg := dockeragent.Config{
PulseURL: cfg.PulseURL,
APIToken: cfg.APIToken,
Interval: cfg.Interval,
HostnameOverride: cfg.HostnameOverride,
AgentID: cfg.AgentID,
InsecureSkipVerify: cfg.InsecureSkipVerify,
DisableAutoUpdate: true, // Unified agent handles updates (future)
LogLevel: cfg.LogLevel,
Logger: &logger,
// Docker specific defaults
SwarmScope: "node",
IncludeContainers: true,
IncludeServices: true,
IncludeTasks: true,
CollectDiskMetrics: true,
}
agent, err := dockeragent.New(dockerCfg)
if err != nil {
logger.Fatal().Err(err).Msg("Failed to initialize docker agent")
}
// Docker agent has a Close method we should call, but errgroup doesn't make defer easy here.
// Ideally we wrap this. For now, we rely on OS cleanup or context cancellation.
g.Go(func() error {
logger.Info().Msg("Docker agent module started")
return agent.Run(ctx)
})
}
// 6. Wait for all agents to exit
if err := g.Wait(); err != nil && err != context.Canceled {
logger.Error().Err(err).Msg("Agent terminated with error")
os.Exit(1)
}
logger.Info().Msg("Pulse Unified Agent stopped")
}
type Config struct {
PulseURL string
APIToken string
Interval time.Duration
HostnameOverride string
AgentID string
Tags []string
InsecureSkipVerify bool
LogLevel zerolog.Level
Logger *zerolog.Logger
// Module flags
EnableHost bool
EnableDocker bool
}
func loadConfig() Config {
// Environment Variables
envURL := utils.GetenvTrim("PULSE_URL")
envToken := utils.GetenvTrim("PULSE_TOKEN")
envInterval := utils.GetenvTrim("PULSE_INTERVAL")
envHostname := utils.GetenvTrim("PULSE_HOSTNAME")
envAgentID := utils.GetenvTrim("PULSE_AGENT_ID")
envInsecure := utils.GetenvTrim("PULSE_INSECURE_SKIP_VERIFY")
envTags := utils.GetenvTrim("PULSE_TAGS")
envLogLevel := utils.GetenvTrim("LOG_LEVEL")
envEnableHost := utils.GetenvTrim("PULSE_ENABLE_HOST")
envEnableDocker := utils.GetenvTrim("PULSE_ENABLE_DOCKER")
// Defaults
defaultInterval := 30 * time.Second
if envInterval != "" {
if parsed, err := time.ParseDuration(envInterval); err == nil {
defaultInterval = parsed
}
}
defaultEnableHost := true
if envEnableHost != "" {
defaultEnableHost = utils.ParseBool(envEnableHost)
}
defaultEnableDocker := false
if envEnableDocker != "" {
defaultEnableDocker = utils.ParseBool(envEnableDocker)
}
// Flags
urlFlag := flag.String("url", envURL, "Pulse server URL")
tokenFlag := flag.String("token", envToken, "Pulse API token")
intervalFlag := flag.Duration("interval", defaultInterval, "Reporting interval")
hostnameFlag := flag.String("hostname", envHostname, "Override hostname")
agentIDFlag := flag.String("agent-id", envAgentID, "Override agent identifier")
insecureFlag := flag.Bool("insecure", utils.ParseBool(envInsecure), "Skip TLS verification")
logLevelFlag := flag.String("log-level", defaultLogLevel(envLogLevel), "Log level")
enableHostFlag := flag.Bool("enable-host", defaultEnableHost, "Enable Host Agent module")
enableDockerFlag := flag.Bool("enable-docker", defaultEnableDocker, "Enable Docker Agent module")
var tagFlags multiValue
flag.Var(&tagFlags, "tag", "Tag to apply (repeatable)")
flag.Parse()
// Validation
pulseURL := strings.TrimSpace(*urlFlag)
if pulseURL == "" {
pulseURL = "http://localhost:7655"
}
token := strings.TrimSpace(*tokenFlag)
if token == "" {
fmt.Fprintln(os.Stderr, "error: Pulse API token is required")
os.Exit(1)
}
logLevel, err := parseLogLevel(*logLevelFlag)
if err != nil {
logLevel = zerolog.InfoLevel
}
tags := gatherTags(envTags, tagFlags)
return Config{
PulseURL: pulseURL,
APIToken: token,
Interval: *intervalFlag,
HostnameOverride: strings.TrimSpace(*hostnameFlag),
AgentID: strings.TrimSpace(*agentIDFlag),
Tags: tags,
InsecureSkipVerify: *insecureFlag,
LogLevel: logLevel,
EnableHost: *enableHostFlag,
EnableDocker: *enableDockerFlag,
}
}
// Helpers (duplicated from existing agents for now, to be moved to shared pkg later)
func gatherTags(env string, flags []string) []string {
tags := make([]string, 0)
if env != "" {
for _, tag := range strings.Split(env, ",") {
tag = strings.TrimSpace(tag)
if tag != "" {
tags = append(tags, tag)
}
}
}
for _, tag := range flags {
tag = strings.TrimSpace(tag)
if tag != "" {
tags = append(tags, tag)
}
}
return tags
}
func parseLogLevel(value string) (zerolog.Level, error) {
normalized := strings.ToLower(strings.TrimSpace(value))
if normalized == "" {
return zerolog.InfoLevel, nil
}
return zerolog.ParseLevel(normalized)
}
func defaultLogLevel(envValue string) string {
if strings.TrimSpace(envValue) == "" {
return "info"
}
return envValue
}

View file

@ -346,24 +346,24 @@ const getRemovalStatusInfo = (host: DockerHost): { label: string; tone: RemovalS
const url = pulseUrl(); const url = pulseUrl();
const tokenValue = requiresToken() ? TOKEN_PLACEHOLDER : 'disabled'; const tokenValue = requiresToken() ? TOKEN_PLACEHOLDER : 'disabled';
const tokenSegment = `--token '${tokenValue}'`; const tokenSegment = `--token '${tokenValue}'`;
return `curl -fSL '${url}/install-docker-agent.sh' -o /tmp/pulse-install-docker-agent.sh && sudo bash /tmp/pulse-install-docker-agent.sh --url '${url}' ${tokenSegment} && rm -f /tmp/pulse-install-docker-agent.sh`; return `curl -fsSL '${url}/install.sh' | bash -s -- --url '${url}' ${tokenSegment} --enable-docker`;
}; };
const getUninstallCommand = () => { const getUninstallCommand = () => {
const url = pulseUrl(); const url = pulseUrl();
return `curl -fSL '${url}/install-docker-agent.sh' -o /tmp/pulse-install-docker-agent.sh && sudo bash /tmp/pulse-install-docker-agent.sh --uninstall && rm -f /tmp/pulse-install-docker-agent.sh`; return `curl -fsSL '${url}/install.sh' | bash -s -- --uninstall`;
}; };
const getSystemdService = () => { const getSystemdService = () => {
const token = requiresToken() ? TOKEN_PLACEHOLDER : 'disabled'; const token = requiresToken() ? TOKEN_PLACEHOLDER : 'disabled';
return `[Unit] return `[Unit]
Description=Pulse Docker Agent Description=Pulse Unified Agent
After=network-online.target docker.service After=network-online.target docker.service
Wants=network-online.target Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
ExecStart=/usr/local/bin/pulse-docker-agent --url ${pulseUrl()} --token ${token} --interval 30s ExecStart=/usr/local/bin/pulse-agent --url ${pulseUrl()} --token ${token} --interval 30s --enable-docker
Restart=always Restart=always
RestartSec=5s RestartSec=5s
User=root User=root
@ -686,7 +686,7 @@ WantedBy=multi-user.target`;
<code>{getInstallCommandTemplate().replace(TOKEN_PLACEHOLDER, currentToken() || TOKEN_PLACEHOLDER)}</code> <code>{getInstallCommandTemplate().replace(TOKEN_PLACEHOLDER, currentToken() || TOKEN_PLACEHOLDER)}</code>
</pre> </pre>
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="text-xs text-gray-500 dark:text-gray-400">
The installer downloads the agent, detects your container runtime, configures a systemd service, and starts reporting automatically. The unified installer downloads the agent, detects your container runtime, configures a systemd service, and starts reporting automatically.
</p> </p>
</div> </div>
</Show> </Show>
@ -735,7 +735,7 @@ WantedBy=multi-user.target`;
<code> <code>
cd /opt/pulse cd /opt/pulse
<br /> <br />
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o pulse-docker-agent ./cmd/pulse-docker-agent CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o pulse-agent ./cmd/pulse-agent
</code> </code>
</div> </div>
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="text-xs text-gray-500 dark:text-gray-400">
@ -744,9 +744,9 @@ WantedBy=multi-user.target`;
<p class="font-medium text-gray-900 dark:text-gray-100">2. Copy to host</p> <p class="font-medium text-gray-900 dark:text-gray-100">2. Copy to host</p>
<div class="rounded bg-gray-900 p-3 font-mono text-xs text-gray-100 dark:bg-gray-950"> <div class="rounded bg-gray-900 p-3 font-mono text-xs text-gray-100 dark:bg-gray-950">
<code> <code>
scp pulse-docker-agent user@docker-host:/usr/local/bin/ scp pulse-agent user@docker-host:/usr/local/bin/
<br /> <br />
ssh user@docker-host chmod +x /usr/local/bin/pulse-docker-agent ssh user@docker-host chmod +x /usr/local/bin/pulse-agent
</code> </code>
</div> </div>
<p class="font-medium text-gray-900 dark:text-gray-100">3. Systemd template</p> <p class="font-medium text-gray-900 dark:text-gray-100">3. Systemd template</p>
@ -772,7 +772,7 @@ WantedBy=multi-user.target`;
<code> <code>
systemctl daemon-reload systemctl daemon-reload
<br /> <br />
systemctl enable --now pulse-docker-agent systemctl enable --now pulse-agent
</code> </code>
</div> </div>
</div> </div>
@ -832,8 +832,7 @@ WantedBy=multi-user.target`;
modalCommandStatus() === 'completed' || modalCommandStatus() === 'completed' ||
(modalHostPendingUninstall() && !modalHasCommand()) (modalHostPendingUninstall() && !modalHasCommand())
} }
class={`inline-flex items-center justify-center rounded px-4 py-2 text-sm font-medium text-white transition-colors ${ class={`inline-flex items-center justify-center rounded px-4 py-2 text-sm font-medium text-white transition-colors ${modalCommandStatus() === 'completed'
modalCommandStatus() === 'completed'
? 'bg-emerald-600 dark:bg-emerald-500' ? 'bg-emerald-600 dark:bg-emerald-500'
: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400' : 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400'
} disabled:cursor-not-allowed disabled:opacity-60`} } disabled:cursor-not-allowed disabled:opacity-60`}
@ -895,8 +894,7 @@ WantedBy=multi-user.target`;
class={`${step.done || step.active ? 'text-blue-700 dark:text-blue-200' : 'text-gray-500 dark:text-gray-400'} flex items-center gap-2 text-xs`} class={`${step.done || step.active ? 'text-blue-700 dark:text-blue-200' : 'text-gray-500 dark:text-gray-400'} flex items-center gap-2 text-xs`}
> >
<span <span
class={`relative h-2 w-2 flex-shrink-0 rounded-full ${ class={`relative h-2 w-2 flex-shrink-0 rounded-full ${step.done
step.done
? 'bg-blue-500' ? 'bg-blue-500'
: step.active : step.active
? 'bg-blue-400 animate-pulse' ? 'bg-blue-400 animate-pulse'
@ -966,12 +964,12 @@ WantedBy=multi-user.target`;
Agent confirmed the stop. Pulse has already cleaned up everything it controls: Agent confirmed the stop. Pulse has already cleaned up everything it controls:
</p> </p>
<ul class="mt-2 space-y-1 leading-snug"> <ul class="mt-2 space-y-1 leading-snug">
<li> Terminated the running <code class="font-mono text-[11px]">pulse-docker-agent</code> process</li> <li> Terminated the running <code class="font-mono text-[11px]">pulse-agent</code> process</li>
<li> Disabled future auto-start (stops the systemd unit or removes the Unraid autostart script if one exists)</li> <li> Disabled future auto-start (stops the systemd unit or removes the Unraid autostart script if one exists)</li>
<li> Cleared the host from the dashboard so new reports wont appear unexpectedly</li> <li> Cleared the host from the dashboard so new reports wont appear unexpectedly</li>
</ul> </ul>
<p class="mt-2"> <p class="mt-2">
The binary remains at <code class="font-mono text-[11px]">/usr/local/bin/pulse-docker-agent</code> for quick reinstalls. Use the uninstall command below if you prefer to remove it too. The binary remains at <code class="font-mono text-[11px]">/usr/local/bin/pulse-agent</code> for quick reinstalls. Use the uninstall command below if you prefer to remove it too.
</p> </p>
</div> </div>
</Show> </Show>
@ -996,8 +994,7 @@ WantedBy=multi-user.target`;
class={`${step.done || step.active ? 'text-blue-700 dark:text-blue-200' : 'text-gray-500 dark:text-gray-400'} flex items-center gap-2`} class={`${step.done || step.active ? 'text-blue-700 dark:text-blue-200' : 'text-gray-500 dark:text-gray-400'} flex items-center gap-2`}
> >
<span <span
class={`relative h-2 w-2 rounded-full ${ class={`relative h-2 w-2 rounded-full ${step.done
step.done
? 'bg-blue-500' ? 'bg-blue-500'
: step.active : step.active
? 'bg-blue-400 animate-pulse' ? 'bg-blue-400 animate-pulse'
@ -1011,7 +1008,7 @@ WantedBy=multi-user.target`;
</ul> </ul>
<p class="leading-snug"> <p class="leading-snug">
Pulse responds to the agent's <code class="font-mono text-[11px]">/api/agents/docker/report</code> call with a stop command. The agent disables its service, removes Pulse responds to the agent's <code class="font-mono text-[11px]">/api/agents/docker/report</code> call with a stop command. The agent disables its service, removes
<code class="font-mono text-[11px]">/boot/config/go.d/pulse-docker-agent.sh</code>, and posts back to <code class="font-mono text-[11px]">/boot/config/go.d/pulse-agent.sh</code>, and posts back to
<code class="font-mono text-[11px]">/api/agents/docker/commands/&lt;id&gt;/ack</code> so Pulse knows it can remove the row. <code class="font-mono text-[11px]">/api/agents/docker/commands/&lt;id&gt;/ack</code> so Pulse knows it can remove the row.
</p> </p>
</div> </div>
@ -1113,7 +1110,7 @@ WantedBy=multi-user.target`;
<p class="text-[11px] font-medium text-gray-600 dark:text-gray-300">Command copied to clipboard.</p> <p class="text-[11px] font-medium text-gray-600 dark:text-gray-300">Command copied to clipboard.</p>
</Show> </Show>
<p class="text-[11px] text-gray-500 dark:text-gray-400"> <p class="text-[11px] text-gray-500 dark:text-gray-400">
This command stops the agent, removes the systemd service (or Unraid autostart hook), deletes <code class="font-mono text-[11px]">/var/log/pulse-docker-agent.log</code>, and uninstalls the binary. Pulse will notice the host is gone after the next heartbeat (2 minutes) and clean up the row automatically. This command stops the agent, removes the systemd service (or Unraid autostart hook), deletes <code class="font-mono text-[11px]">/var/log/pulse-agent.log</code>, and uninstalls the binary. Pulse will notice the host is gone after the next heartbeat (2 minutes) and clean up the row automatically.
</p> </p>
</div> </div>
<div class="flex flex-col gap-2 rounded border border-gray-200 p-3 dark:border-gray-700"> <div class="flex flex-col gap-2 rounded border border-gray-200 p-3 dark:border-gray-700">
@ -1443,8 +1440,7 @@ WantedBy=multi-user.target`;
<td class="py-3 px-4 align-top"> <td class="py-3 px-4 align-top">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span <span
class={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${ class={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${isOnline
isOnline
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
}`} }`}

View file

@ -37,14 +37,14 @@ const commandsByPlatform: Record<
linux: { linux: {
title: 'Install on Linux', title: 'Install on Linux',
description: description:
'The installer downloads the agent binary and configures it as a systemd service.', 'The unified installer downloads the agent binary and configures it as a systemd service.',
snippets: [ snippets: [
{ {
label: 'Install with systemd', label: 'Install with systemd',
command: `curl -fsSL ${pulseUrl()}/install-host-agent.sh | bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`, command: `curl -fsSL ${pulseUrl()}/install.sh | bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`,
note: ( note: (
<span> <span>
Automatically installs to <code>/usr/local/bin/pulse-host-agent</code> and creates <code>/etc/systemd/system/pulse-host-agent.service</code>. Automatically installs to <code>/usr/local/bin/pulse-agent</code> and creates <code>/etc/systemd/system/pulse-agent.service</code>.
</span> </span>
), ),
}, },
@ -53,14 +53,14 @@ const commandsByPlatform: Record<
macos: { macos: {
title: 'Install on macOS', title: 'Install on macOS',
description: description:
'The installer downloads the universal binary and sets up a launchd service for background monitoring.', 'The unified installer downloads the universal binary and sets up a launchd service for background monitoring.',
snippets: [ snippets: [
{ {
label: 'Install with launchd', label: 'Install with launchd',
command: `curl -fsSL ${pulseUrl()}/install-host-agent.sh | bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`, command: `curl -fsSL ${pulseUrl()}/install.sh | bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`,
note: ( note: (
<span> <span>
Creates <code>~/Library/LaunchAgents/com.pulse.host-agent.plist</code> and starts the agent automatically. Creates <code>/Library/LaunchDaemons/com.pulse.agent.plist</code> and starts the agent automatically.
</span> </span>
), ),
}, },
@ -69,20 +69,20 @@ const commandsByPlatform: Record<
windows: { windows: {
title: 'Install on Windows', title: 'Install on Windows',
description: description:
'Run the PowerShell script to install and configure the host agent as a Windows service with automatic startup.', 'Run the PowerShell script to install and configure the unified agent as a Windows service with automatic startup.',
snippets: [ snippets: [
{ {
label: 'Install as Windows Service (PowerShell)', label: 'Install as Windows Service (PowerShell)',
command: `irm ${pulseUrl()}/install-host-agent.ps1 | iex`, command: `irm ${pulseUrl()}/install.ps1 | iex`,
note: ( note: (
<span> <span>
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. 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.
</span> </span>
), ),
}, },
{ {
label: 'Install with parameters (PowerShell)', label: 'Install with parameters (PowerShell)',
command: `$env:PULSE_URL="${pulseUrl()}"; $env:PULSE_TOKEN="${TOKEN_PLACEHOLDER}"; irm ${pulseUrl()}/install-host-agent.ps1 | iex`, command: `$env:PULSE_URL="${pulseUrl()}"; $env:PULSE_TOKEN="${TOKEN_PLACEHOLDER}"; irm ${pulseUrl()}/install.ps1 | iex`,
note: ( note: (
<span> <span>
Non-interactive installation. Set environment variables before running to skip prompts. Non-interactive installation. Set environment variables before running to skip prompts.
@ -538,26 +538,26 @@ User=root
WantedBy=multi-user.target`; WantedBy=multi-user.target`;
function getManualUninstallCommand(): string { function getManualUninstallCommand(): string {
return `sudo systemctl stop pulse-host-agent && \\ return `sudo systemctl stop pulse-agent && \\
sudo systemctl disable pulse-host-agent && \\ sudo systemctl disable pulse-agent && \\
sudo rm -f /etc/systemd/system/pulse-host-agent.service && \\ sudo rm -f /etc/systemd/system/pulse-agent.service && \\
sudo rm -f /usr/local/bin/pulse-host-agent && \\ sudo rm -f /usr/local/bin/pulse-agent && \\
sudo systemctl daemon-reload`; sudo systemctl daemon-reload`;
} }
function getHostUninstallCommand(host: Host | null): string { function getHostUninstallCommand(host: Host | null): string {
const platform = host?.platform?.toLowerCase(); const platform = host?.platform?.toLowerCase();
if (platform === 'macos' || platform === 'darwin' || platform === 'mac') { if (platform === 'macos' || platform === 'darwin' || platform === 'mac') {
return `launchctl unload ~/Library/LaunchAgents/com.pulse.host-agent.plist >/dev/null 2>&1 || true && \\ return `launchctl unload /Library/LaunchDaemons/com.pulse.agent.plist >/dev/null 2>&1 || true && \\
rm -f ~/Library/LaunchAgents/com.pulse.host-agent.plist && \\ rm -f /Library/LaunchDaemons/com.pulse.agent.plist && \\
sudo rm -f /usr/local/bin/pulse-host-agent && \\ sudo rm -f /usr/local/bin/pulse-agent && \\
rm -f ~/Library/Logs/pulse-host-agent.log`; rm -f /var/log/pulse-agent.log`;
} }
if (platform === 'windows' || platform === 'win32' || platform === 'windows_nt') { if (platform === 'windows' || platform === 'win32' || platform === 'windows_nt') {
return `Stop-Service -Name PulseHostAgent -ErrorAction SilentlyContinue; \\ return `Stop-Service -Name PulseAgent -ErrorAction SilentlyContinue; \\
sc.exe delete PulseHostAgent; \\ sc.exe delete PulseAgent; \\
Remove-Item 'C:\\\\Program Files\\\\Pulse\\\\pulse-host-agent.exe' -Force -ErrorAction SilentlyContinue; \\ Remove-Item 'C:\\\\Program Files\\\\Pulse\\\\pulse-agent.exe' -Force -ErrorAction SilentlyContinue; \\
Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-host-agent.log' -Force -ErrorAction SilentlyContinue`; Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-agent.log' -Force -ErrorAction SilentlyContinue`;
} }
return getManualUninstallCommand(); return getManualUninstallCommand();
} }
@ -628,8 +628,7 @@ Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-host-agent.log' -Force -ErrorAct
type="button" type="button"
onClick={acknowledgeNoToken} onClick={acknowledgeNoToken}
disabled={confirmedNoToken()} disabled={confirmedNoToken()}
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-md px-4 py-2 text-sm font-medium transition-colors ${confirmedNoToken()
confirmedNoToken()
? 'bg-green-600 text-white cursor-default' ? '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-gray-900 text-white hover:bg-black dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white'
}`} }`}
@ -780,15 +779,15 @@ Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-host-agent.log' -Force -ErrorAct
<code> <code>
cd /opt/pulse cd /opt/pulse
<br /> <br />
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o pulse-host-agent ./cmd/pulse-host-agent CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o pulse-agent ./cmd/pulse-agent
</code> </code>
</div> </div>
<p class="font-medium text-gray-900 dark:text-gray-100">2. Copy to host</p> <p class="font-medium text-gray-900 dark:text-gray-100">2. Copy to host</p>
<div class="rounded bg-gray-900 p-3 font-mono text-xs text-gray-100 dark:bg-gray-950"> <div class="rounded bg-gray-900 p-3 font-mono text-xs text-gray-100 dark:bg-gray-950">
<code> <code>
scp pulse-host-agent user@host:/usr/local/bin/ scp pulse-agent user@host:/usr/local/bin/
<br /> <br />
ssh user@host sudo chmod +x /usr/local/bin/pulse-host-agent ssh user@host sudo chmod +x /usr/local/bin/pulse-agent
</code> </code>
</div> </div>
<p class="font-medium text-gray-900 dark:text-gray-100">3. Systemd service template</p> <p class="font-medium text-gray-900 dark:text-gray-100">3. Systemd service template</p>
@ -1010,8 +1009,7 @@ Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-host-agent.log' -Force -ErrorAct
<td class="py-3 px-4"> <td class="py-3 px-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span <span
class={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${ class={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${isOnline
isOnline
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
}`} }`}
@ -1191,8 +1189,7 @@ Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-host-agent.log' -Force -ErrorAct
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-300"> <div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-300">
<span class="font-semibold uppercase tracking-wide text-[11px] text-gray-500 dark:text-gray-400">Host status</span> <span class="font-semibold uppercase tracking-wide text-[11px] text-gray-500 dark:text-gray-400">Host status</span>
<span <span
class={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold uppercase ${ class={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold uppercase ${hostRemovalIsOnline()
hostRemovalIsOnline()
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
: 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200'
}`} }`}

View file

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

View file

@ -0,0 +1,110 @@
package api
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/rs/zerolog/log"
)
// handleDownloadUnifiedInstallScript serves the universal install.sh script
func (r *Router) handleDownloadUnifiedInstallScript(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
scriptPath := filepath.Join(r.config.AppRoot, "scripts", "install.sh")
// Check if file exists
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
log.Error().Str("path", scriptPath).Msg("Unified install script not found")
http.Error(w, "Install script not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/x-shellscript")
w.Header().Set("Content-Disposition", "inline; filename=\"install.sh\"")
http.ServeFile(w, req, scriptPath)
}
// handleDownloadUnifiedInstallScriptPS serves the universal install.ps1 script
func (r *Router) handleDownloadUnifiedInstallScriptPS(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
scriptPath := filepath.Join(r.config.AppRoot, "scripts", "install.ps1")
// Check if file exists
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
log.Error().Str("path", scriptPath).Msg("Unified PowerShell install script not found")
http.Error(w, "Install script not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Disposition", "inline; filename=\"install.ps1\"")
http.ServeFile(w, req, scriptPath)
}
// handleDownloadUnifiedAgent serves the pulse-agent binary
func (r *Router) handleDownloadUnifiedAgent(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// For now, we only have the locally built binary.
// In production, this would look up the correct binary for the requested OS/Arch.
// Query params: ?os=linux&arch=amd64
osName := req.URL.Query().Get("os")
arch := req.URL.Query().Get("arch")
if osName == "" {
osName = "linux" // Default
}
if arch == "" {
arch = "amd64" // Default
}
// Normalize OS
osName = strings.ToLower(osName)
if osName == "darwin" {
osName = "macos"
}
// In dev mode, we just serve the binary we built in the root
// In prod, we'd look in a dist folder
binaryName := "pulse-agent"
if osName == "windows" {
binaryName = "pulse-agent.exe"
}
// Try to find the binary
// 1. Check root (dev)
binaryPath := filepath.Join(r.config.AppRoot, binaryName)
if _, err := os.Stat(binaryPath); os.IsNotExist(err) {
// 2. Check dist folder (prod/build)
binaryPath = filepath.Join(r.config.AppRoot, "dist", fmt.Sprintf("%s-%s", osName, arch), binaryName)
if _, err := os.Stat(binaryPath); os.IsNotExist(err) {
// Fallback for dev: just serve the root binary regardless of requested OS/Arch
// This allows testing the flow even if cross-compilation hasn't happened
binaryPath = filepath.Join(r.config.AppRoot, "pulse-agent")
if _, err := os.Stat(binaryPath); os.IsNotExist(err) {
log.Error().Str("path", binaryPath).Msg("Unified agent binary not found")
http.Error(w, "Agent binary not found", http.StatusNotFound)
return
}
}
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", binaryName))
http.ServeFile(w, req, binaryPath)
}

View file

@ -77,6 +77,7 @@ type Config struct {
FrontendPort int `envconfig:"FRONTEND_PORT" default:"7655"` FrontendPort int `envconfig:"FRONTEND_PORT" default:"7655"`
ConfigPath string `envconfig:"CONFIG_PATH" default:"/etc/pulse"` ConfigPath string `envconfig:"CONFIG_PATH" default:"/etc/pulse"`
DataPath string `envconfig:"DATA_PATH" default:"/var/lib/pulse"` DataPath string `envconfig:"DATA_PATH" default:"/var/lib/pulse"`
AppRoot string `json:"-"` // Root directory of the application (where binary lives)
PublicURL string `envconfig:"PULSE_PUBLIC_URL" default:""` // Full URL to access Pulse (e.g., http://192.168.1.100:7655) PublicURL string `envconfig:"PULSE_PUBLIC_URL" default:""` // Full URL to access Pulse (e.g., http://192.168.1.100:7655)
// Proxmox VE connections // Proxmox VE connections
@ -530,6 +531,7 @@ func Load() (*Config, error) {
FrontendPort: 7655, FrontendPort: 7655,
ConfigPath: dataDir, ConfigPath: dataDir,
DataPath: dataDir, DataPath: dataDir,
AppRoot: detectAppRoot(),
ConcurrentPolling: true, ConcurrentPolling: true,
ConnectionTimeout: 60 * time.Second, ConnectionTimeout: 60 * time.Second,
MetricsRetentionDays: 7, MetricsRetentionDays: 7,

View file

@ -0,0 +1,37 @@
package config
import (
"os"
"path/filepath"
"strings"
)
// detectAppRoot attempts to find the application root directory
func detectAppRoot() string {
// 1. Check environment variable
if root := os.Getenv("PULSE_APP_ROOT"); root != "" {
return root
}
// 2. Get executable path
exe, err := os.Executable()
if err == nil {
// If running via "go run", executable is in /tmp, which isn't helpful for finding source files
// But in production, it's correct.
// Check if we are in a temp dir (go run)
if strings.Contains(exe, os.TempDir()) || strings.Contains(exe, "/var/folders/") {
// Fallback to current working directory
if cwd, err := os.Getwd(); err == nil {
return cwd
}
}
return filepath.Dir(exe)
}
// 3. Fallback to current working directory
if cwd, err := os.Getwd(); err == nil {
return cwd
}
return "."
}

BIN
pulse-agent Executable file

Binary file not shown.

View file

@ -47,13 +47,14 @@ else
openssl rand -base64 32 > "$DEV_DIR/.encryption.key" openssl rand -base64 32 > "$DEV_DIR/.encryption.key"
chmod 600 "$DEV_DIR/.encryption.key" chmod 600 "$DEV_DIR/.encryption.key"
echo "✓ Generated dev encryption key at $DEV_DIR/.encryption.key" echo "✓ Generated dev encryption key at $DEV_DIR/.encryption.key"
# Remove encrypted artifacts that rely on the missing/old key
find "$DEV_DIR" -maxdepth 1 -type f -name 'nodes.enc*' -exec rm -f {} \;
rm -f "$DEV_DIR/email.enc" "$DEV_DIR/webhooks.enc"
echo "✓ Cleared encrypted artifacts (new key generated)"
else else
echo "✓ Reusing existing dev encryption key" echo "✓ Reusing existing dev encryption key"
fi fi
# Remove encrypted artifacts that rely on the missing production key
find "$DEV_DIR" -maxdepth 1 -type f -name 'nodes.enc*' -exec rm -f {} \;
rm -f "$DEV_DIR/email.enc" "$DEV_DIR/webhooks.enc"
echo "✓ Cleared encrypted production artifacts from dev config"
fi fi
# Copy nodes configuration - WITH VALIDATION # Copy nodes configuration - WITH VALIDATION