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 9466db4868
commit 930c086556
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,115 +113,115 @@ 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 status = modalCommandStatus();
return status === 'queued' || status === 'dispatched' || status === 'acknowledged';
});
const modalCommandFailed = createMemo(() => modalCommandStatus() === 'failed');
const modalCommandCompleted = createMemo(() => modalCommandStatus() === 'completed');
const modalCommandProgress = createMemo(() => {
const cmd = modalCommand();
if (!cmd) return [];
const statusOrder: Record<string, number> = {
queued: 0,
dispatched: 1,
acknowledged: 2,
completed: 3,
failed: 4,
expired: 5,
};
const currentIndex = statusOrder[cmd.status] ?? 0;
const steps = [
{ key: 'queued', label: 'Stop command queued' },
{ key: 'dispatched', label: 'Instruction delivered to the agent' },
{ key: 'acknowledged', label: 'Agent acknowledged the stop request' },
{ key: 'completed', label: 'Agent disabled the service and removed autostart' },
];
return steps.map((step) => {
const stepIndex = statusOrder[step.key] ?? 0;
return {
label: step.label,
done: currentIndex > stepIndex,
active: currentIndex === stepIndex,
};
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 cmd = modalCommand();
if (!cmd) return [];
const modalCommandTimedOut = createMemo(() => {
return modalCommandInProgress() && elapsedSeconds() > 120; // 2 minutes
});
const statusOrder: Record<string, number> = {
queued: 0,
dispatched: 1,
acknowledged: 2,
completed: 3,
failed: 4,
expired: 5,
};
const currentIndex = statusOrder[cmd.status] ?? 0;
const steps = [
{ key: 'queued', label: 'Stop command queued' },
{ key: 'dispatched', label: 'Instruction delivered to the agent' },
{ key: 'acknowledged', label: 'Agent acknowledged the stop request' },
{ key: 'completed', label: 'Agent disabled the service and removed autostart' },
];
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 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';
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> = {
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 status = host.command?.status ?? null;
switch (status) {
case 'failed':
return steps.map((step) => {
const stepIndex = statusOrder[step.key] ?? 0;
return {
label: host.command?.failureReason || 'Pulse could not stop the agent automatically.',
tone: 'danger',
label: step.label,
done: currentIndex > stepIndex,
active: currentIndex === stepIndex,
};
case 'expired':
return {
label: 'Stop command expired before the agent responded.',
tone: 'danger',
};
case 'completed':
return {
label: 'Agent stopped. Pulse will hide this host after the next missed heartbeat.',
tone: 'success',
};
case 'acknowledged':
return { label: 'Agent acknowledged the stop command—waiting for shutdown.', tone: 'info' };
case 'dispatched':
return { label: 'Instruction delivered to the agent.', tone: 'info' };
case 'queued':
return { label: 'Stop command queued; waiting to reach the agent.', tone: 'info' };
default:
if (host.pendingUninstall) {
return { label: 'Marked for uninstall; waiting for agent confirmation.', tone: 'info' };
}
return null;
}
};
});
});
const modalCommandTimedOut = createMemo(() => {
return modalCommandInProgress() && elapsedSeconds() > 120; // 2 minutes
});
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 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';
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> = {
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 status = host.command?.status ?? null;
switch (status) {
case 'failed':
return {
label: host.command?.failureReason || 'Pulse could not stop the agent automatically.',
tone: 'danger',
};
case 'expired':
return {
label: 'Stop command expired before the agent responded.',
tone: 'danger',
};
case 'completed':
return {
label: 'Agent stopped. Pulse will hide this host after the next missed heartbeat.',
tone: 'success',
};
case 'acknowledged':
return { label: 'Agent acknowledged the stop command—waiting for shutdown.', tone: 'info' };
case 'dispatched':
return { label: 'Instruction delivered to the agent.', tone: 'info' };
case 'queued':
return { label: 'Stop command queued; waiting to reach the agent.', tone: 'info' };
default:
if (host.pendingUninstall) {
return { label: 'Marked for uninstall; waiting for agent confirmation.', tone: 'info' };
}
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
@@ -610,176 +610,176 @@ WantedBy=multi-user.target`;
</Card>
</Show>
<Card padding="lg" class="space-y-5">
<div class="space-y-2">
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Enroll a container runtime</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Run the command below on any host running Docker or Podman. The installer will automatically detect your container runtime.
</p>
</div>
<Card padding="lg" class="space-y-5">
<div class="space-y-2">
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Enroll a container runtime</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Run the command below on any host running Docker or Podman. The installer will automatically detect your container runtime.
</p>
</div>
<div class="space-y-5">
<Show when={requiresToken()}>
<div class="space-y-3">
<div class="space-y-1">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">Generate API token</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
Create a fresh token scoped to <code>{DOCKER_REPORT_SCOPE}</code>
</p>
</div>
<div class="flex gap-2">
<input
type="text"
value={tokenName()}
onInput={(e) => setTokenName(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !isGeneratingToken()) {
handleGenerateToken();
}
}}
placeholder="Token name (optional)"
class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-900/60"
/>
<button
type="button"
onClick={handleGenerateToken}
disabled={isGeneratingToken()}
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…' : currentToken() ? 'Generate another' : 'Generate token'}
</button>
</div>
<Show when={latestRecord()}>
<div class="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-2 text-xs text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span>
Token <strong>{latestRecord()?.name}</strong> created and inserted into the command below.
</span>
</div>
</Show>
</div>
</Show>
<Show when={showInstallCommand()}>
<div class="space-y-3">
<div class="flex items-center justify-between">
<label class="text-sm font-semibold text-gray-900 dark:text-gray-100">Install command</label>
<button
type="button"
onClick={async () => {
const command = getInstallCommandTemplate().replace(TOKEN_PLACEHOLDER, currentToken() || TOKEN_PLACEHOLDER);
const success = await copyToClipboard(command);
if (typeof window !== 'undefined' && window.showToast) {
window.showToast(success ? 'success' : 'error', success ? 'Copied!' : 'Failed to copy');
}
}}
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors bg-blue-600 text-white hover:bg-blue-700"
>
Copy command
</button>
</div>
<pre class="overflow-x-auto rounded-md bg-gray-900/90 p-3 text-xs text-gray-100">
<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.
<div class="space-y-5">
<Show when={requiresToken()}>
<div class="space-y-3">
<div class="space-y-1">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">Generate API token</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
Create a fresh token scoped to <code>{DOCKER_REPORT_SCOPE}</code>
</p>
</div>
</Show>
<Show when={requiresToken() && !currentToken()}>
<div class="flex gap-2">
<input
type="text"
value={tokenName()}
onInput={(e) => setTokenName(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !isGeneratingToken()) {
handleGenerateToken();
}
}}
placeholder="Token name (optional)"
class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-900/60"
/>
<button
type="button"
onClick={handleGenerateToken}
disabled={isGeneratingToken()}
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…' : currentToken() ? 'Generate another' : 'Generate token'}
</button>
</div>
<Show when={latestRecord()}>
<div class="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-2 text-xs text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span>
Token <strong>{latestRecord()?.name}</strong> created and inserted into the command below.
</span>
</div>
</Show>
</div>
</Show>
<Show when={showInstallCommand()}>
<div class="space-y-3">
<div class="flex items-center justify-between">
<label class="text-sm font-semibold text-gray-900 dark:text-gray-100">Install command</label>
<button
type="button"
onClick={async () => {
const command = getInstallCommandTemplate().replace(TOKEN_PLACEHOLDER, currentToken() || TOKEN_PLACEHOLDER);
const success = await copyToClipboard(command);
if (typeof window !== 'undefined' && window.showToast) {
window.showToast(success ? 'success' : 'error', success ? 'Copied!' : 'Failed to copy');
}
}}
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors bg-blue-600 text-white hover:bg-blue-700"
>
Copy command
</button>
</div>
<pre class="overflow-x-auto rounded-md bg-gray-900/90 p-3 text-xs text-gray-100">
<code>{getInstallCommandTemplate().replace(TOKEN_PLACEHOLDER, currentToken() || TOKEN_PLACEHOLDER)}</code>
</pre>
<p class="text-xs text-gray-500 dark:text-gray-400">
Generate a token to see the install command.
The unified installer downloads the agent, detects your container runtime, configures a systemd service, and starts reporting automatically.
</p>
</Show>
</div>
</div>
</Show>
<details class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-300">
<summary class="cursor-pointer text-sm font-medium text-gray-900 dark:text-gray-100">
Advanced options (uninstall & manual install)
</summary>
<div class="mt-3 space-y-4">
<div>
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Uninstall</p>
<div class="mt-2 flex items-center gap-2">
<code class="flex-1 break-all rounded bg-gray-900 px-3 py-2 font-mono text-xs text-red-400 dark:bg-gray-950">
{getUninstallCommand()}
<Show when={requiresToken() && !currentToken()}>
<p class="text-xs text-gray-500 dark:text-gray-400">
Generate a token to see the install command.
</p>
</Show>
</div>
<details class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-300">
<summary class="cursor-pointer text-sm font-medium text-gray-900 dark:text-gray-100">
Advanced options (uninstall & manual install)
</summary>
<div class="mt-3 space-y-4">
<div>
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Uninstall</p>
<div class="mt-2 flex items-center gap-2">
<code class="flex-1 break-all rounded bg-gray-900 px-3 py-2 font-mono text-xs text-red-400 dark:bg-gray-950">
{getUninstallCommand()}
</code>
<button
type="button"
onClick={async () => {
const success = await copyToClipboard(getUninstallCommand());
if (typeof window !== 'undefined' && window.showToast) {
window.showToast(success ? 'success' : 'error', success ? 'Copied to clipboard' : 'Failed to copy to clipboard');
}
}}
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>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Stops the agent, removes the binary, the systemd unit, and related files.
</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Manual installation</p>
<div class="mt-2 space-y-3 rounded-lg border border-gray-200 bg-white p-3 text-xs dark:border-gray-700 dark:bg-gray-900">
<p class="font-medium text-gray-900 dark:text-gray-100">1. Build the binary</p>
<div class="rounded bg-gray-900 p-3 font-mono text-xs text-gray-100 dark:bg-gray-950">
<code>
cd /opt/pulse
<br />
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">
Building with <code class="font-mono text-[11px]">CGO_ENABLED=0</code> keeps the binary fully static so it runs on hosts with older glibc (e.g. Debian 11).
</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">
<code>
scp pulse-agent user@docker-host:/usr/local/bin/
<br />
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>
<div class="relative">
<button
type="button"
onClick={async () => {
const success = await copyToClipboard(getUninstallCommand());
const success = await copyToClipboard(getSystemdService());
if (typeof window !== 'undefined' && window.showToast) {
window.showToast(success ? 'success' : 'error', success ? 'Copied to clipboard' : 'Failed to copy to clipboard');
}
}}
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"
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>
<div class="rounded bg-gray-900 p-3 font-mono text-xs text-gray-100 dark:bg-gray-950">
<pre>{getSystemdService()}</pre>
</div>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Stops the agent, removes the binary, the systemd unit, and related files.
</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Manual installation</p>
<div class="mt-2 space-y-3 rounded-lg border border-gray-200 bg-white p-3 text-xs dark:border-gray-700 dark:bg-gray-900">
<p class="font-medium text-gray-900 dark:text-gray-100">1. Build the binary</p>
<div class="rounded bg-gray-900 p-3 font-mono text-xs text-gray-100 dark:bg-gray-950">
<code>
cd /opt/pulse
<br />
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o pulse-docker-agent ./cmd/pulse-docker-agent
</code>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Building with <code class="font-mono text-[11px]">CGO_ENABLED=0</code> keeps the binary fully static so it runs on hosts with older glibc (e.g. Debian 11).
</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">
<code>
scp pulse-docker-agent user@docker-host:/usr/local/bin/
<br />
ssh user@docker-host chmod +x /usr/local/bin/pulse-docker-agent
</code>
</div>
<p class="font-medium text-gray-900 dark:text-gray-100">3. Systemd template</p>
<div class="relative">
<button
type="button"
onClick={async () => {
const success = await copyToClipboard(getSystemdService());
if (typeof window !== 'undefined' && window.showToast) {
window.showToast(success ? 'success' : 'error', success ? 'Copied to clipboard' : 'Failed to copy to clipboard');
}
}}
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>
<div class="rounded bg-gray-900 p-3 font-mono text-xs text-gray-100 dark:bg-gray-950">
<pre>{getSystemdService()}</pre>
</div>
</div>
<p class="font-medium text-gray-900 dark:text-gray-100">4. Enable & start</p>
<div class="rounded bg-gray-900 p-3 font-mono text-xs text-gray-100 dark:bg-gray-950">
<code>
systemctl daemon-reload
<br />
systemctl enable --now pulse-docker-agent
</code>
</div>
<p class="font-medium text-gray-900 dark:text-gray-100">4. Enable & start</p>
<div class="rounded bg-gray-900 p-3 font-mono text-xs text-gray-100 dark:bg-gray-950">
<code>
systemctl daemon-reload
<br />
systemctl enable --now pulse-agent
</code>
</div>
</div>
</div>
</details>
</Card>
</div>
</details>
</Card>
{/* Remove Container Host Modal */}
<Show when={showRemoveModal()}>
@@ -832,11 +832,10 @@ 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'
? '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`}
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`}
>
{(() => {
if (removeActionLoading() === 'queue') return 'Sending…';
@@ -895,13 +894,12 @@ 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
? 'bg-blue-500'
: step.active
? 'bg-blue-400 animate-pulse'
: 'bg-gray-300 dark:bg-gray-600'
} ${modalCommandCompleted() && step.done ? 'after:absolute after:-inset-1 after:rounded-full after:border after:border-emerald-400/40 after:animate-pulse' : ''}`}
class={`relative h-2 w-2 flex-shrink-0 rounded-full ${step.done
? 'bg-blue-500'
: step.active
? 'bg-blue-400 animate-pulse'
: 'bg-gray-300 dark:bg-gray-600'
} ${modalCommandCompleted() && step.done ? 'after:absolute after:-inset-1 after:rounded-full after:border after:border-emerald-400/40 after:animate-pulse' : ''}`}
/>
{step.label}
</li>
@@ -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,13 +994,12 @@ 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
? 'bg-blue-500'
: step.active
? 'bg-blue-400 animate-pulse'
: 'bg-gray-300 dark:bg-gray-600'
} ${modalCommandCompleted() && step.done ? 'after:absolute after:-inset-1 after:rounded-full after:border after:border-emerald-400/40 after:animate-pulse' : ''}`}
class={`relative h-2 w-2 rounded-full ${step.done
? 'bg-blue-500'
: step.active
? 'bg-blue-400 animate-pulse'
: 'bg-gray-300 dark:bg-gray-600'
} ${modalCommandCompleted() && step.done ? 'after:absolute after:-inset-1 after:rounded-full after:border after:border-emerald-400/40 after:animate-pulse' : ''}`}
/>
{step.label}
</li>
@@ -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,11 +1440,10 @@ 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
? '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'
}`}
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'
}`}
>
{host.status || 'unknown'}
</span>
@@ -1486,7 +1482,7 @@ WantedBy=multi-user.target`;
);
}}
</Show>
</td>
</td>
<td class="py-3 px-4 align-top">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{describeRuntime(host)}

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.
@@ -436,20 +436,20 @@ export const HostAgents: Component = () => {
});
const requiresToken = () => {
const status = securityStatus();
if (status) {
return status.requiresAuth || status.apiTokenConfigured;
}
return true;
};
const requiresToken = () => {
const status = securityStatus();
if (status) {
return status.requiresAuth || status.apiTokenConfigured;
}
return true;
};
onCleanup(() => {
if (highlightTimer) {
clearTimeout(highlightTimer);
highlightTimer = null;
}
});
onCleanup(() => {
if (highlightTimer) {
clearTimeout(highlightTimer);
highlightTimer = null;
}
});
const hasToken = () => Boolean(currentToken());
const commandsUnlocked = () => (requiresToken() ? hasToken() : hasToken() || confirmedNoToken());
@@ -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();
}
@@ -619,241 +619,240 @@ Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-host-agent.log' -Force -ErrorAct
</div>
</Show>
<Show when={!requiresToken()}>
<div class="space-y-3">
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-900/30 dark:text-amber-200">
Tokens are optional on this Pulse instance. Confirm to generate commands without embedding a token.
</div>
<button
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()
? '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'
<Show when={!requiresToken()}>
<div class="space-y-3">
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-900/30 dark:text-amber-200">
Tokens are optional on this Pulse instance. Confirm to generate commands without embedding a token.
</div>
<button
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()
? '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'
}`}
>
{confirmedNoToken() ? 'No token confirmed' : 'Confirm without token'}
</button>
</div>
</Show>
>
{confirmedNoToken() ? 'No token confirmed' : 'Confirm without token'}
</button>
</div>
</Show>
<Show when={commandsUnlocked()}>
<div class="space-y-3">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Installation commands</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">
Copy the command for the platform you are deploying.
</p>
<div class="space-y-4">
<For each={commandSections()}>
{(section) => (
<div class="space-y-3 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div class="space-y-1">
<h5 class="text-sm font-semibold text-gray-900 dark:text-gray-100">{section.title}</h5>
<p class="text-xs text-gray-500 dark:text-gray-400">{section.description}</p>
</div>
<div class="space-y-3">
<For each={section.snippets}>
{(snippet) => {
const copyCommand = () =>
snippet.command.replace(TOKEN_PLACEHOLDER, resolvedToken());
return (
<div class="space-y-2">
<div class="flex items-center justify-between gap-3">
<h6 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{snippet.label}
</h6>
<button
type="button"
onClick={async () => {
const success = await copyToClipboard(copyCommand());
if (typeof window !== 'undefined' && window.showToast) {
window.showToast(success ? 'success' : 'error', success ? 'Copied!' : 'Failed to copy');
}
}}
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors bg-blue-600 text-white hover:bg-blue-700"
>
Copy command
</button>
</div>
<pre class="overflow-x-auto rounded-md bg-gray-900/90 p-3 text-xs text-gray-100">
<code>{copyCommand()}</code>
</pre>
<Show when={snippet.note}>
<p class="text-xs text-gray-500 dark:text-gray-400">{snippet.note}</p>
</Show>
</div>
);
}}
</For>
</div>
</div>
)}
</For>
</div>
<div class="space-y-3 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-900 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-100">
<div class="flex items-center justify-between gap-3">
<h5 class="text-sm font-semibold">Check installation status</h5>
<button
type="button"
onClick={handleLookup}
disabled={lookupLoading()}
class="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{lookupLoading() ? 'Checking…' : 'Check status'}
</button>
</div>
<p class="text-xs text-blue-800 dark:text-blue-200">
Enter the hostname (or host ID) from the machine you just installed. Pulse returns the latest status instantly.
</p>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<input
type="text"
value={lookupValue()}
onInput={(event) => {
setLookupValue(event.currentTarget.value);
setLookupError(null);
setLookupResult(null);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void handleLookup();
}
}}
placeholder="Hostname or host ID"
class="flex-1 rounded-lg border border-blue-200 bg-white px-3 py-2 text-sm text-blue-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-100 dark:focus:border-blue-300 dark:focus:ring-blue-800/60"
/>
</div>
<Show when={lookupError()}>
<p class="text-xs font-medium text-red-600 dark:text-red-300">{lookupError()}</p>
</Show>
<Show when={lookupResult()}>
{(result) => {
const host = () => result().host;
const statusBadgeClasses = () =>
host().connected
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200';
return (
<div class="space-y-1 rounded-lg border border-blue-200 bg-white px-3 py-2 text-xs text-blue-900 dark:border-blue-700 dark:bg-blue-900/40 dark:text-blue-100">
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div class="text-sm font-semibold">
{host().displayName || host().hostname}
</div>
<div class="flex items-center gap-2">
<span class={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold ${statusBadgeClasses()}`}>
{host().connected ? 'Connected' : 'Not reporting yet'}
</span>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium text-blue-700 dark:bg-blue-900/60 dark:text-blue-200">
{host().status || 'unknown'}
</span>
</div>
</div>
<div>
Last seen {formatRelativeTime(host().lastSeen)} ({formatAbsoluteTime(host().lastSeen)})
</div>
<Show when={host().agentVersion}>
<div class="text-xs text-blue-700 dark:text-blue-200">
Agent version {host().agentVersion}
</div>
</Show>
</div>
);
}}
</Show>
</div>
<details class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-300">
<summary class="cursor-pointer text-sm font-medium text-gray-900 dark:text-gray-100">
Advanced options (manual install & uninstall)
</summary>
<div class="mt-3 space-y-4">
<div class="space-y-3 rounded-lg border border-gray-200 bg-white p-3 text-xs dark:border-gray-700 dark:bg-gray-900">
<p class="font-medium text-gray-900 dark:text-gray-100">Manual Linux install</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Build the agent from source and manage the service yourself instead of using the helper script.
</p>
<p class="font-medium text-gray-900 dark:text-gray-100">1. Build the binary</p>
<div class="rounded bg-gray-900 p-3 font-mono text-xs text-gray-100 dark:bg-gray-950">
<code>
cd /opt/pulse
<br />
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o pulse-host-agent ./cmd/pulse-host-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/
<br />
ssh user@host sudo chmod +x /usr/local/bin/pulse-host-agent
</code>
</div>
<p class="font-medium text-gray-900 dark:text-gray-100">3. Systemd service template</p>
<div class="relative">
<button
type="button"
onClick={async () => {
const success = await copyToClipboard(getSystemdServiceUnit());
if (typeof window !== 'undefined' && window.showToast) {
window.showToast(success ? 'success' : 'error', success ? 'Copied to clipboard' : 'Failed to copy to clipboard');
}
}}
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>
<div class="rounded bg-gray-900 p-3 font-mono text-xs text-gray-100 dark:bg-gray-950 overflow-x-auto">
<pre>{getSystemdServiceUnit()}</pre>
</div>
</div>
<p class="font-medium text-gray-900 dark:text-gray-100">4. Enable & start</p>
<div class="rounded bg-gray-900 p-3 font-mono text-xs text-gray-100 dark:bg-gray-950">
<code>
sudo systemctl daemon-reload
<br />
sudo systemctl enable --now pulse-host-agent
</code>
</div>
</div>
<div>
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Manual uninstall</p>
<div class="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<code class="flex-1 break-all rounded bg-gray-900 px-3 py-2 font-mono text-xs text-gray-100 dark:bg-gray-950">
{getManualUninstallCommand()}
</code>
<button
type="button"
onClick={async () => {
const success = await copyToClipboard(getManualUninstallCommand());
if (typeof window !== 'undefined' && window.showToast) {
window.showToast(success ? 'success' : 'error', success ? 'Copied to clipboard' : 'Failed to copy to clipboard');
}
}}
class="self-start 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>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Stops the agent, removes the systemd unit, and deletes the binary.
</p>
</div>
</div>
</details>
</div>
</Show>
<Show when={requiresToken() && !hasToken()}>
<Show when={commandsUnlocked()}>
<div class="space-y-3">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Installation commands</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">
Generate a new token to unlock the install commands.
Copy the command for the platform you are deploying.
</p>
</Show>
<Show when={!requiresToken() && !confirmedNoToken() && !hasToken()}>
<p class="text-xs text-gray-500 dark:text-gray-400">Confirm the no-token setup to continue.</p>
</Show>
<div class="space-y-4">
<For each={commandSections()}>
{(section) => (
<div class="space-y-3 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div class="space-y-1">
<h5 class="text-sm font-semibold text-gray-900 dark:text-gray-100">{section.title}</h5>
<p class="text-xs text-gray-500 dark:text-gray-400">{section.description}</p>
</div>
<div class="space-y-3">
<For each={section.snippets}>
{(snippet) => {
const copyCommand = () =>
snippet.command.replace(TOKEN_PLACEHOLDER, resolvedToken());
return (
<div class="space-y-2">
<div class="flex items-center justify-between gap-3">
<h6 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{snippet.label}
</h6>
<button
type="button"
onClick={async () => {
const success = await copyToClipboard(copyCommand());
if (typeof window !== 'undefined' && window.showToast) {
window.showToast(success ? 'success' : 'error', success ? 'Copied!' : 'Failed to copy');
}
}}
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors bg-blue-600 text-white hover:bg-blue-700"
>
Copy command
</button>
</div>
<pre class="overflow-x-auto rounded-md bg-gray-900/90 p-3 text-xs text-gray-100">
<code>{copyCommand()}</code>
</pre>
<Show when={snippet.note}>
<p class="text-xs text-gray-500 dark:text-gray-400">{snippet.note}</p>
</Show>
</div>
);
}}
</For>
</div>
</div>
)}
</For>
</div>
<div class="space-y-3 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-900 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-100">
<div class="flex items-center justify-between gap-3">
<h5 class="text-sm font-semibold">Check installation status</h5>
<button
type="button"
onClick={handleLookup}
disabled={lookupLoading()}
class="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{lookupLoading() ? 'Checking…' : 'Check status'}
</button>
</div>
<p class="text-xs text-blue-800 dark:text-blue-200">
Enter the hostname (or host ID) from the machine you just installed. Pulse returns the latest status instantly.
</p>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<input
type="text"
value={lookupValue()}
onInput={(event) => {
setLookupValue(event.currentTarget.value);
setLookupError(null);
setLookupResult(null);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void handleLookup();
}
}}
placeholder="Hostname or host ID"
class="flex-1 rounded-lg border border-blue-200 bg-white px-3 py-2 text-sm text-blue-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-100 dark:focus:border-blue-300 dark:focus:ring-blue-800/60"
/>
</div>
<Show when={lookupError()}>
<p class="text-xs font-medium text-red-600 dark:text-red-300">{lookupError()}</p>
</Show>
<Show when={lookupResult()}>
{(result) => {
const host = () => result().host;
const statusBadgeClasses = () =>
host().connected
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200';
return (
<div class="space-y-1 rounded-lg border border-blue-200 bg-white px-3 py-2 text-xs text-blue-900 dark:border-blue-700 dark:bg-blue-900/40 dark:text-blue-100">
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div class="text-sm font-semibold">
{host().displayName || host().hostname}
</div>
<div class="flex items-center gap-2">
<span class={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold ${statusBadgeClasses()}`}>
{host().connected ? 'Connected' : 'Not reporting yet'}
</span>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium text-blue-700 dark:bg-blue-900/60 dark:text-blue-200">
{host().status || 'unknown'}
</span>
</div>
</div>
<div>
Last seen {formatRelativeTime(host().lastSeen)} ({formatAbsoluteTime(host().lastSeen)})
</div>
<Show when={host().agentVersion}>
<div class="text-xs text-blue-700 dark:text-blue-200">
Agent version {host().agentVersion}
</div>
</Show>
</div>
);
}}
</Show>
</div>
<details class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-300">
<summary class="cursor-pointer text-sm font-medium text-gray-900 dark:text-gray-100">
Advanced options (manual install & uninstall)
</summary>
<div class="mt-3 space-y-4">
<div class="space-y-3 rounded-lg border border-gray-200 bg-white p-3 text-xs dark:border-gray-700 dark:bg-gray-900">
<p class="font-medium text-gray-900 dark:text-gray-100">Manual Linux install</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Build the agent from source and manage the service yourself instead of using the helper script.
</p>
<p class="font-medium text-gray-900 dark:text-gray-100">1. Build the binary</p>
<div class="rounded bg-gray-900 p-3 font-mono text-xs text-gray-100 dark:bg-gray-950">
<code>
cd /opt/pulse
<br />
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-agent user@host:/usr/local/bin/
<br />
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>
<div class="relative">
<button
type="button"
onClick={async () => {
const success = await copyToClipboard(getSystemdServiceUnit());
if (typeof window !== 'undefined' && window.showToast) {
window.showToast(success ? 'success' : 'error', success ? 'Copied to clipboard' : 'Failed to copy to clipboard');
}
}}
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>
<div class="rounded bg-gray-900 p-3 font-mono text-xs text-gray-100 dark:bg-gray-950 overflow-x-auto">
<pre>{getSystemdServiceUnit()}</pre>
</div>
</div>
<p class="font-medium text-gray-900 dark:text-gray-100">4. Enable & start</p>
<div class="rounded bg-gray-900 p-3 font-mono text-xs text-gray-100 dark:bg-gray-950">
<code>
sudo systemctl daemon-reload
<br />
sudo systemctl enable --now pulse-host-agent
</code>
</div>
</div>
<div>
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Manual uninstall</p>
<div class="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<code class="flex-1 break-all rounded bg-gray-900 px-3 py-2 font-mono text-xs text-gray-100 dark:bg-gray-950">
{getManualUninstallCommand()}
</code>
<button
type="button"
onClick={async () => {
const success = await copyToClipboard(getManualUninstallCommand());
if (typeof window !== 'undefined' && window.showToast) {
window.showToast(success ? 'success' : 'error', success ? 'Copied to clipboard' : 'Failed to copy to clipboard');
}
}}
class="self-start 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>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Stops the agent, removes the systemd unit, and deletes the binary.
</p>
</div>
</div>
</details>
</div>
</Show>
<Show when={requiresToken() && !hasToken()}>
<p class="text-xs text-gray-500 dark:text-gray-400">
Generate a new token to unlock the install commands.
</p>
</Show>
<Show when={!requiresToken() && !confirmedNoToken() && !hasToken()}>
<p class="text-xs text-gray-500 dark:text-gray-400">Confirm the no-token setup to continue.</p>
</Show>
</div>
</Card>
@@ -1010,11 +1009,10 @@ 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
? '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'
}`}
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'
}`}
>
{host.status || 'unknown'}
</span>
@@ -1098,10 +1096,10 @@ Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-host-agent.log' -Force -ErrorAct
);
}}
</For>
</tbody>
</table>
</div>
</Show>
</tbody>
</table>
</div>
</Show>
</div>
</Card>
@@ -1191,11 +1189,10 @@ 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()
? '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'
}`}
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'
}`}
>
{hostRemovalStatusLabel()}
</span>

View File

@@ -12,9 +12,9 @@ const backendProtocol = process.env.PULSE_DEV_API_PROTOCOL ?? 'http';
const backendHost = process.env.PULSE_DEV_API_HOST ?? '127.0.0.1';
const backendPort = Number(
process.env.PULSE_DEV_API_PORT ??
process.env.FRONTEND_PORT ??
process.env.PORT ??
7655,
process.env.FRONTEND_PORT ??
process.env.PORT ??
7655,
);
const backendUrl =
@@ -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