mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
WIP: Save all pending changes including frontend updates and unified agent scaffolding
This commit is contained in:
8
Makefile
8
Makefile
@@ -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
257
cmd/pulse-agent/main.go
Normal 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
|
||||
}
|
||||
@@ -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 won’t 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/<id>/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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
110
internal/api/unified_agent.go
Normal file
110
internal/api/unified_agent.go
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
37
internal/config/detect_root.go
Normal file
37
internal/config/detect_root.go
Normal 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
BIN
pulse-agent
Executable file
Binary file not shown.
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user