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

@ -113,16 +113,16 @@ export const DockerAgents: Component = () => {
};
const modalHostIsOnline = () => modalHostStatus().toLowerCase() === 'online';
const modalHostHidden = () => Boolean(hostToRemove()?.hidden);
const modalCommand = createMemo(() => hostToRemove()?.command ?? null);
const modalCommandStatus = createMemo(() => modalCommand()?.status ?? null);
const modalCommandInProgress = createMemo(() => {
const modalHostHidden = () => Boolean(hostToRemove()?.hidden);
const modalCommand = createMemo(() => hostToRemove()?.command ?? null);
const modalCommandStatus = createMemo(() => modalCommand()?.status ?? null);
const modalCommandInProgress = createMemo(() => {
const status = modalCommandStatus();
return status === 'queued' || status === 'dispatched' || status === 'acknowledged';
});
const modalCommandFailed = createMemo(() => modalCommandStatus() === 'failed');
const modalCommandCompleted = createMemo(() => modalCommandStatus() === 'completed');
const modalCommandProgress = createMemo(() => {
});
const modalCommandFailed = createMemo(() => modalCommandStatus() === 'failed');
const modalCommandCompleted = createMemo(() => modalCommandStatus() === 'completed');
const modalCommandProgress = createMemo(() => {
const cmd = modalCommand();
if (!cmd) return [];
@ -150,47 +150,47 @@ const modalCommandProgress = createMemo(() => {
active: currentIndex === stepIndex,
};
});
});
});
const modalCommandTimedOut = createMemo(() => {
const modalCommandTimedOut = createMemo(() => {
return modalCommandInProgress() && elapsedSeconds() > 120; // 2 minutes
});
});
const modalLastHeartbeat = createMemo(() => {
const modalLastHeartbeat = createMemo(() => {
const host = hostToRemove();
return host?.lastSeen ? formatRelativeTime(host.lastSeen) : null;
});
});
const modalHostPendingUninstall = createMemo(() => Boolean(hostToRemove()?.pendingUninstall));
const modalHasCommand = createMemo(() => Boolean(modalCommand()));
const [hasShownCommandCompletion, setHasShownCommandCompletion] = createSignal(false);
const modalHostPendingUninstall = createMemo(() => Boolean(hostToRemove()?.pendingUninstall));
const modalHasCommand = createMemo(() => Boolean(modalCommand()));
const [hasShownCommandCompletion, setHasShownCommandCompletion] = createSignal(false);
const formatElapsedTime = (seconds: number) => {
const formatElapsedTime = (seconds: number) => {
if (seconds < 60) {
return `${seconds}s`;
}
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}m ${secs}s`;
};
};
type RemovalStatusTone = 'info' | 'success' | 'danger';
type RemovalStatusTone = 'info' | 'success' | 'danger';
const removalBadgeClassMap: Record<RemovalStatusTone, string> = {
const removalBadgeClassMap: Record<RemovalStatusTone, string> = {
info: 'inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-blue-700 dark:bg-blue-900/40 dark:text-blue-200',
success:
'inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200',
danger:
'inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-red-600 dark:bg-red-900/40 dark:text-red-200',
};
};
const removalTextClassMap: Record<RemovalStatusTone, string> = {
const removalTextClassMap: Record<RemovalStatusTone, string> = {
info: 'text-blue-700 dark:text-blue-300',
success: 'text-emerald-700 dark:text-emerald-300',
danger: 'text-red-600 dark:text-red-300',
};
};
const getRemovalStatusInfo = (host: DockerHost): { label: string; tone: RemovalStatusTone } | null => {
const getRemovalStatusInfo = (host: DockerHost): { label: string; tone: RemovalStatusTone } | null => {
const status = host.command?.status ?? null;
switch (status) {
@ -221,7 +221,7 @@ const getRemovalStatusInfo = (host: DockerHost): { label: string; tone: RemovalS
}
return null;
}
};
};
createEffect(() => {
if (!showRemoveModal()) return;
@ -346,24 +346,24 @@ const getRemovalStatusInfo = (host: DockerHost): { label: string; tone: RemovalS
const url = pulseUrl();
const tokenValue = requiresToken() ? TOKEN_PLACEHOLDER : 'disabled';
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 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 token = requiresToken() ? TOKEN_PLACEHOLDER : 'disabled';
return `[Unit]
Description=Pulse Docker Agent
Description=Pulse Unified Agent
After=network-online.target docker.service
Wants=network-online.target
[Service]
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
RestartSec=5s
User=root
@ -686,7 +686,7 @@ WantedBy=multi-user.target`;
<code>{getInstallCommandTemplate().replace(TOKEN_PLACEHOLDER, currentToken() || TOKEN_PLACEHOLDER)}</code>
</pre>
<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>
</div>
</Show>
@ -735,7 +735,7 @@ WantedBy=multi-user.target`;
<code>
cd /opt/pulse
<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>
</div>
<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>
<div class="rounded bg-gray-900 p-3 font-mono text-xs text-gray-100 dark:bg-gray-950">
<code>
scp pulse-docker-agent user@docker-host:/usr/local/bin/
scp pulse-agent user@docker-host:/usr/local/bin/
<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>
</div>
<p class="font-medium text-gray-900 dark:text-gray-100">3. Systemd template</p>
@ -772,7 +772,7 @@ WantedBy=multi-user.target`;
<code>
systemctl daemon-reload
<br />
systemctl enable --now pulse-docker-agent
systemctl enable --now pulse-agent
</code>
</div>
</div>
@ -832,8 +832,7 @@ WantedBy=multi-user.target`;
modalCommandStatus() === 'completed' ||
(modalHostPendingUninstall() && !modalHasCommand())
}
class={`inline-flex items-center justify-center rounded px-4 py-2 text-sm font-medium text-white transition-colors ${
modalCommandStatus() === 'completed'
class={`inline-flex items-center justify-center rounded px-4 py-2 text-sm font-medium text-white transition-colors ${modalCommandStatus() === 'completed'
? 'bg-emerald-600 dark:bg-emerald-500'
: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400'
} 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`}
>
<span
class={`relative h-2 w-2 flex-shrink-0 rounded-full ${
step.done
class={`relative h-2 w-2 flex-shrink-0 rounded-full ${step.done
? 'bg-blue-500'
: step.active
? '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:
</p>
<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> Cleared the host from the dashboard so new reports wont appear unexpectedly</li>
</ul>
<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>
</div>
</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`}
>
<span
class={`relative h-2 w-2 rounded-full ${
step.done
class={`relative h-2 w-2 rounded-full ${step.done
? 'bg-blue-500'
: step.active
? 'bg-blue-400 animate-pulse'
@ -1011,7 +1008,7 @@ WantedBy=multi-user.target`;
</ul>
<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
<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.
</p>
</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>
</Show>
<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>
</div>
<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">
<div class="flex flex-wrap items-center gap-2">
<span
class={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
isOnline
class={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${isOnline
? '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'
}`}

View file

@ -37,14 +37,14 @@ const commandsByPlatform: Record<
linux: {
title: 'Install on Linux',
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: [
{
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: (
<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>
),
},
@ -53,14 +53,14 @@ const commandsByPlatform: Record<
macos: {
title: 'Install on macOS',
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: [
{
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: (
<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>
),
},
@ -69,20 +69,20 @@ const commandsByPlatform: Record<
windows: {
title: 'Install on Windows',
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: [
{
label: 'Install as Windows Service (PowerShell)',
command: `irm ${pulseUrl()}/install-host-agent.ps1 | iex`,
command: `irm ${pulseUrl()}/install.ps1 | iex`,
note: (
<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>
),
},
{
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: (
<span>
Non-interactive installation. Set environment variables before running to skip prompts.
@ -538,26 +538,26 @@ User=root
WantedBy=multi-user.target`;
function getManualUninstallCommand(): string {
return `sudo systemctl stop pulse-host-agent && \\
sudo systemctl disable pulse-host-agent && \\
sudo rm -f /etc/systemd/system/pulse-host-agent.service && \\
sudo rm -f /usr/local/bin/pulse-host-agent && \\
return `sudo systemctl stop pulse-agent && \\
sudo systemctl disable pulse-agent && \\
sudo rm -f /etc/systemd/system/pulse-agent.service && \\
sudo rm -f /usr/local/bin/pulse-agent && \\
sudo systemctl daemon-reload`;
}
function getHostUninstallCommand(host: Host | null): string {
const platform = host?.platform?.toLowerCase();
if (platform === 'macos' || platform === 'darwin' || platform === 'mac') {
return `launchctl unload ~/Library/LaunchAgents/com.pulse.host-agent.plist >/dev/null 2>&1 || true && \\
rm -f ~/Library/LaunchAgents/com.pulse.host-agent.plist && \\
sudo rm -f /usr/local/bin/pulse-host-agent && \\
rm -f ~/Library/Logs/pulse-host-agent.log`;
return `launchctl unload /Library/LaunchDaemons/com.pulse.agent.plist >/dev/null 2>&1 || true && \\
rm -f /Library/LaunchDaemons/com.pulse.agent.plist && \\
sudo rm -f /usr/local/bin/pulse-agent && \\
rm -f /var/log/pulse-agent.log`;
}
if (platform === 'windows' || platform === 'win32' || platform === 'windows_nt') {
return `Stop-Service -Name PulseHostAgent -ErrorAction SilentlyContinue; \\
sc.exe delete PulseHostAgent; \\
Remove-Item 'C:\\\\Program Files\\\\Pulse\\\\pulse-host-agent.exe' -Force -ErrorAction SilentlyContinue; \\
Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-host-agent.log' -Force -ErrorAction SilentlyContinue`;
return `Stop-Service -Name PulseAgent -ErrorAction SilentlyContinue; \\
sc.exe delete PulseAgent; \\
Remove-Item 'C:\\\\Program Files\\\\Pulse\\\\pulse-agent.exe' -Force -ErrorAction SilentlyContinue; \\
Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-agent.log' -Force -ErrorAction SilentlyContinue`;
}
return getManualUninstallCommand();
}
@ -628,8 +628,7 @@ Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-host-agent.log' -Force -ErrorAct
type="button"
onClick={acknowledgeNoToken}
disabled={confirmedNoToken()}
class={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors ${
confirmedNoToken()
class={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors ${confirmedNoToken()
? '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'
}`}
@ -780,15 +779,15 @@ Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-host-agent.log' -Force -ErrorAct
<code>
cd /opt/pulse
<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>
</div>
<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">
<code>
scp pulse-host-agent user@host:/usr/local/bin/
scp pulse-agent user@host:/usr/local/bin/
<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>
</div>
<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">
<div class="flex items-center gap-2">
<span
class={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
isOnline
class={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${isOnline
? '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'
}`}
@ -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">
<span class="font-semibold uppercase tracking-wide text-[11px] text-gray-500 dark:text-gray-400">Host status</span>
<span
class={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold uppercase ${
hostRemovalIsOnline()
class={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold uppercase ${hostRemovalIsOnline()
? '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'
}`}

View file

@ -72,6 +72,14 @@ export default defineConfig({
target: backendUrl,
changeOrigin: true,
},
'/install.sh': {
target: backendUrl,
changeOrigin: true,
},
'/install.ps1': {
target: backendUrl,
changeOrigin: true,
},
'/download': {
target: backendUrl,
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"`
ConfigPath string `envconfig:"CONFIG_PATH" default:"/etc/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)
// Proxmox VE connections
@ -530,6 +531,7 @@ func Load() (*Config, error) {
FrontendPort: 7655,
ConfigPath: dataDir,
DataPath: dataDir,
AppRoot: detectAppRoot(),
ConcurrentPolling: true,
ConnectionTimeout: 60 * time.Second,
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"
chmod 600 "$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
echo "✓ Reusing existing dev encryption key"
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
# Copy nodes configuration - WITH VALIDATION