WIP: Save all pending changes including frontend updates and unified agent scaffolding
This commit is contained in:
parent
af2caa4b81
commit
26ebd476da
10 changed files with 987 additions and 573 deletions
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=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-linux-armv7 ./cmd/pulse-host-agent && \
|
||||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-darwin-amd64 ./cmd/pulse-host-agent && \
|
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-darwin-amd64 ./cmd/pulse-host-agent && \
|
||||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-darwin-arm64 ./cmd/pulse-host-agent && \
|
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-darwin-arm64 ./cmd/pulse-host-agent && \
|
||||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-windows-amd64.exe ./cmd/pulse-host-agent
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o bin/pulse-host-agent-windows-amd64.exe ./cmd/pulse-host-agent && \
|
||||||
|
echo "Building unified agent binaries..." && \
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.Version=v$$VERSION" -trimpath -o bin/pulse-agent-linux-amd64 ./cmd/pulse-agent && \
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.Version=v$$VERSION" -trimpath -o bin/pulse-agent-linux-arm64 ./cmd/pulse-agent && \
|
||||||
|
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w -X main.Version=v$$VERSION" -trimpath -o bin/pulse-agent-macos-amd64 ./cmd/pulse-agent && \
|
||||||
|
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w -X main.Version=v$$VERSION" -trimpath -o bin/pulse-agent-macos-arm64 ./cmd/pulse-agent && \
|
||||||
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w -X main.Version=v$$VERSION" -trimpath -o bin/pulse-agent-windows-amd64.exe ./cmd/pulse-agent
|
||||||
@ln -sf pulse-host-agent-windows-amd64.exe bin/pulse-host-agent-windows-amd64
|
@ln -sf pulse-host-agent-windows-amd64.exe bin/pulse-host-agent-windows-amd64
|
||||||
@echo "✓ All agent binaries built in bin/"
|
@echo "✓ All agent binaries built in bin/"
|
||||||
|
|
|
||||||
257
cmd/pulse-agent/main.go
Normal file
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 modalHostIsOnline = () => modalHostStatus().toLowerCase() === 'online';
|
||||||
const modalHostHidden = () => Boolean(hostToRemove()?.hidden);
|
const modalHostHidden = () => Boolean(hostToRemove()?.hidden);
|
||||||
const modalCommand = createMemo(() => hostToRemove()?.command ?? null);
|
const modalCommand = createMemo(() => hostToRemove()?.command ?? null);
|
||||||
const modalCommandStatus = createMemo(() => modalCommand()?.status ?? null);
|
const modalCommandStatus = createMemo(() => modalCommand()?.status ?? null);
|
||||||
const modalCommandInProgress = createMemo(() => {
|
const modalCommandInProgress = createMemo(() => {
|
||||||
const status = modalCommandStatus();
|
const status = modalCommandStatus();
|
||||||
return status === 'queued' || status === 'dispatched' || status === 'acknowledged';
|
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 modalCommandFailed = createMemo(() => modalCommandStatus() === 'failed');
|
||||||
|
const modalCommandCompleted = createMemo(() => modalCommandStatus() === 'completed');
|
||||||
|
const modalCommandProgress = createMemo(() => {
|
||||||
|
const cmd = modalCommand();
|
||||||
|
if (!cmd) return [];
|
||||||
|
|
||||||
const modalCommandTimedOut = createMemo(() => {
|
const statusOrder: Record<string, number> = {
|
||||||
return modalCommandInProgress() && elapsedSeconds() > 120; // 2 minutes
|
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(() => {
|
return steps.map((step) => {
|
||||||
const host = hostToRemove();
|
const stepIndex = statusOrder[step.key] ?? 0;
|
||||||
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 {
|
return {
|
||||||
label: host.command?.failureReason || 'Pulse could not stop the agent automatically.',
|
label: step.label,
|
||||||
tone: 'danger',
|
done: currentIndex > stepIndex,
|
||||||
|
active: currentIndex === stepIndex,
|
||||||
};
|
};
|
||||||
case 'expired':
|
});
|
||||||
return {
|
});
|
||||||
label: 'Stop command expired before the agent responded.',
|
|
||||||
tone: 'danger',
|
const modalCommandTimedOut = createMemo(() => {
|
||||||
};
|
return modalCommandInProgress() && elapsedSeconds() > 120; // 2 minutes
|
||||||
case 'completed':
|
});
|
||||||
return {
|
|
||||||
label: 'Agent stopped. Pulse will hide this host after the next missed heartbeat.',
|
const modalLastHeartbeat = createMemo(() => {
|
||||||
tone: 'success',
|
const host = hostToRemove();
|
||||||
};
|
return host?.lastSeen ? formatRelativeTime(host.lastSeen) : null;
|
||||||
case 'acknowledged':
|
});
|
||||||
return { label: 'Agent acknowledged the stop command—waiting for shutdown.', tone: 'info' };
|
|
||||||
case 'dispatched':
|
const modalHostPendingUninstall = createMemo(() => Boolean(hostToRemove()?.pendingUninstall));
|
||||||
return { label: 'Instruction delivered to the agent.', tone: 'info' };
|
const modalHasCommand = createMemo(() => Boolean(modalCommand()));
|
||||||
case 'queued':
|
const [hasShownCommandCompletion, setHasShownCommandCompletion] = createSignal(false);
|
||||||
return { label: 'Stop command queued; waiting to reach the agent.', tone: 'info' };
|
|
||||||
default:
|
const formatElapsedTime = (seconds: number) => {
|
||||||
if (host.pendingUninstall) {
|
if (seconds < 60) {
|
||||||
return { label: 'Marked for uninstall; waiting for agent confirmation.', tone: 'info' };
|
return `${seconds}s`;
|
||||||
}
|
}
|
||||||
return null;
|
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(() => {
|
createEffect(() => {
|
||||||
if (!showRemoveModal()) return;
|
if (!showRemoveModal()) return;
|
||||||
|
|
@ -346,24 +346,24 @@ const getRemovalStatusInfo = (host: DockerHost): { label: string; tone: RemovalS
|
||||||
const url = pulseUrl();
|
const url = pulseUrl();
|
||||||
const tokenValue = requiresToken() ? TOKEN_PLACEHOLDER : 'disabled';
|
const tokenValue = requiresToken() ? TOKEN_PLACEHOLDER : 'disabled';
|
||||||
const tokenSegment = `--token '${tokenValue}'`;
|
const tokenSegment = `--token '${tokenValue}'`;
|
||||||
return `curl -fSL '${url}/install-docker-agent.sh' -o /tmp/pulse-install-docker-agent.sh && sudo bash /tmp/pulse-install-docker-agent.sh --url '${url}' ${tokenSegment} && rm -f /tmp/pulse-install-docker-agent.sh`;
|
return `curl -fsSL '${url}/install.sh' | bash -s -- --url '${url}' ${tokenSegment} --enable-docker`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUninstallCommand = () => {
|
const getUninstallCommand = () => {
|
||||||
const url = pulseUrl();
|
const url = pulseUrl();
|
||||||
return `curl -fSL '${url}/install-docker-agent.sh' -o /tmp/pulse-install-docker-agent.sh && sudo bash /tmp/pulse-install-docker-agent.sh --uninstall && rm -f /tmp/pulse-install-docker-agent.sh`;
|
return `curl -fsSL '${url}/install.sh' | bash -s -- --uninstall`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSystemdService = () => {
|
const getSystemdService = () => {
|
||||||
const token = requiresToken() ? TOKEN_PLACEHOLDER : 'disabled';
|
const token = requiresToken() ? TOKEN_PLACEHOLDER : 'disabled';
|
||||||
return `[Unit]
|
return `[Unit]
|
||||||
Description=Pulse Docker Agent
|
Description=Pulse Unified Agent
|
||||||
After=network-online.target docker.service
|
After=network-online.target docker.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
ExecStart=/usr/local/bin/pulse-docker-agent --url ${pulseUrl()} --token ${token} --interval 30s
|
ExecStart=/usr/local/bin/pulse-agent --url ${pulseUrl()} --token ${token} --interval 30s --enable-docker
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
User=root
|
User=root
|
||||||
|
|
@ -610,176 +610,176 @@ WantedBy=multi-user.target`;
|
||||||
</Card>
|
</Card>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Card padding="lg" class="space-y-5">
|
<Card padding="lg" class="space-y-5">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Enroll a container runtime</h3>
|
<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">
|
<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.
|
Run the command below on any host running Docker or Podman. The installer will automatically detect your container runtime.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<Show when={requiresToken()}>
|
<Show when={requiresToken()}>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="space-y-1">
|
<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 font-semibold text-gray-900 dark:text-gray-100">Generate API token</p>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Create a fresh token scoped to <code>{DOCKER_REPORT_SCOPE}</code>
|
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.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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>
|
</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">
|
<Show when={requiresToken() && !currentToken()}>
|
||||||
<summary class="cursor-pointer text-sm font-medium text-gray-900 dark:text-gray-100">
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Advanced options (uninstall & manual install)
|
Generate a token to see the install command.
|
||||||
</summary>
|
</p>
|
||||||
<div class="mt-3 space-y-4">
|
</Show>
|
||||||
<div>
|
</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">
|
<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">
|
||||||
<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">
|
<summary class="cursor-pointer text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
{getUninstallCommand()}
|
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>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const success = await copyToClipboard(getUninstallCommand());
|
const success = await copyToClipboard(getSystemdService());
|
||||||
if (typeof window !== 'undefined' && window.showToast) {
|
if (typeof window !== 'undefined' && window.showToast) {
|
||||||
window.showToast(success ? 'success' : 'error', success ? 'Copied to clipboard' : 'Failed to copy to clipboard');
|
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
|
Copy
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<p class="font-medium text-gray-900 dark:text-gray-100">4. Enable & start</p>
|
||||||
Stops the agent, removes the binary, the systemd unit, and related files.
|
<div class="rounded bg-gray-900 p-3 font-mono text-xs text-gray-100 dark:bg-gray-950">
|
||||||
</p>
|
<code>
|
||||||
</div>
|
systemctl daemon-reload
|
||||||
|
<br />
|
||||||
<div>
|
systemctl enable --now pulse-agent
|
||||||
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Manual installation</p>
|
</code>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</div>
|
||||||
</Card>
|
</details>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Remove Container Host Modal */}
|
{/* Remove Container Host Modal */}
|
||||||
<Show when={showRemoveModal()}>
|
<Show when={showRemoveModal()}>
|
||||||
|
|
@ -832,11 +832,10 @@ WantedBy=multi-user.target`;
|
||||||
modalCommandStatus() === 'completed' ||
|
modalCommandStatus() === 'completed' ||
|
||||||
(modalHostPendingUninstall() && !modalHasCommand())
|
(modalHostPendingUninstall() && !modalHasCommand())
|
||||||
}
|
}
|
||||||
class={`inline-flex items-center justify-center rounded px-4 py-2 text-sm font-medium text-white transition-colors ${
|
class={`inline-flex items-center justify-center rounded px-4 py-2 text-sm font-medium text-white transition-colors ${modalCommandStatus() === 'completed'
|
||||||
modalCommandStatus() === 'completed'
|
? 'bg-emerald-600 dark:bg-emerald-500'
|
||||||
? 'bg-emerald-600 dark:bg-emerald-500'
|
: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400'
|
||||||
: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400'
|
} disabled:cursor-not-allowed disabled:opacity-60`}
|
||||||
} disabled:cursor-not-allowed disabled:opacity-60`}
|
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
if (removeActionLoading() === 'queue') return 'Sending…';
|
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`}
|
class={`${step.done || step.active ? 'text-blue-700 dark:text-blue-200' : 'text-gray-500 dark:text-gray-400'} flex items-center gap-2 text-xs`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class={`relative h-2 w-2 flex-shrink-0 rounded-full ${
|
class={`relative h-2 w-2 flex-shrink-0 rounded-full ${step.done
|
||||||
step.done
|
? 'bg-blue-500'
|
||||||
? 'bg-blue-500'
|
: step.active
|
||||||
: step.active
|
? 'bg-blue-400 animate-pulse'
|
||||||
? 'bg-blue-400 animate-pulse'
|
: 'bg-gray-300 dark:bg-gray-600'
|
||||||
: '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' : ''}`}
|
||||||
} ${modalCommandCompleted() && step.done ? 'after:absolute after:-inset-1 after:rounded-full after:border after:border-emerald-400/40 after:animate-pulse' : ''}`}
|
|
||||||
/>
|
/>
|
||||||
{step.label}
|
{step.label}
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -966,12 +964,12 @@ WantedBy=multi-user.target`;
|
||||||
Agent confirmed the stop. Pulse has already cleaned up everything it controls:
|
Agent confirmed the stop. Pulse has already cleaned up everything it controls:
|
||||||
</p>
|
</p>
|
||||||
<ul class="mt-2 space-y-1 leading-snug">
|
<ul class="mt-2 space-y-1 leading-snug">
|
||||||
<li>• Terminated the running <code class="font-mono text-[11px]">pulse-docker-agent</code> process</li>
|
<li>• Terminated the running <code class="font-mono text-[11px]">pulse-agent</code> process</li>
|
||||||
<li>• Disabled future auto-start (stops the systemd unit or removes the Unraid autostart script if one exists)</li>
|
<li>• Disabled future auto-start (stops the systemd unit or removes the Unraid autostart script if one exists)</li>
|
||||||
<li>• Cleared the host from the dashboard so new reports won’t appear unexpectedly</li>
|
<li>• Cleared the host from the dashboard so new reports won’t appear unexpectedly</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="mt-2">
|
<p class="mt-2">
|
||||||
The binary remains at <code class="font-mono text-[11px]">/usr/local/bin/pulse-docker-agent</code> for quick reinstalls. Use the uninstall command below if you prefer to remove it too.
|
The binary remains at <code class="font-mono text-[11px]">/usr/local/bin/pulse-agent</code> for quick reinstalls. Use the uninstall command below if you prefer to remove it too.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -996,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`}
|
class={`${step.done || step.active ? 'text-blue-700 dark:text-blue-200' : 'text-gray-500 dark:text-gray-400'} flex items-center gap-2`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class={`relative h-2 w-2 rounded-full ${
|
class={`relative h-2 w-2 rounded-full ${step.done
|
||||||
step.done
|
? 'bg-blue-500'
|
||||||
? 'bg-blue-500'
|
: step.active
|
||||||
: step.active
|
? 'bg-blue-400 animate-pulse'
|
||||||
? 'bg-blue-400 animate-pulse'
|
: 'bg-gray-300 dark:bg-gray-600'
|
||||||
: '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' : ''}`}
|
||||||
} ${modalCommandCompleted() && step.done ? 'after:absolute after:-inset-1 after:rounded-full after:border after:border-emerald-400/40 after:animate-pulse' : ''}`}
|
|
||||||
/>
|
/>
|
||||||
{step.label}
|
{step.label}
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -1011,7 +1008,7 @@ WantedBy=multi-user.target`;
|
||||||
</ul>
|
</ul>
|
||||||
<p class="leading-snug">
|
<p class="leading-snug">
|
||||||
Pulse responds to the agent's <code class="font-mono text-[11px]">/api/agents/docker/report</code> call with a stop command. The agent disables its service, removes
|
Pulse responds to the agent's <code class="font-mono text-[11px]">/api/agents/docker/report</code> call with a stop command. The agent disables its service, removes
|
||||||
<code class="font-mono text-[11px]">/boot/config/go.d/pulse-docker-agent.sh</code>, and posts back to
|
<code class="font-mono text-[11px]">/boot/config/go.d/pulse-agent.sh</code>, and posts back to
|
||||||
<code class="font-mono text-[11px]">/api/agents/docker/commands/<id>/ack</code> so Pulse knows it can remove the row.
|
<code class="font-mono text-[11px]">/api/agents/docker/commands/<id>/ack</code> so Pulse knows it can remove the row.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1113,7 +1110,7 @@ WantedBy=multi-user.target`;
|
||||||
<p class="text-[11px] font-medium text-gray-600 dark:text-gray-300">Command copied to clipboard.</p>
|
<p class="text-[11px] font-medium text-gray-600 dark:text-gray-300">Command copied to clipboard.</p>
|
||||||
</Show>
|
</Show>
|
||||||
<p class="text-[11px] text-gray-500 dark:text-gray-400">
|
<p class="text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
This command stops the agent, removes the systemd service (or Unraid autostart hook), deletes <code class="font-mono text-[11px]">/var/log/pulse-docker-agent.log</code>, and uninstalls the binary. Pulse will notice the host is gone after the next heartbeat (≈2 minutes) and clean up the row automatically.
|
This command stops the agent, removes the systemd service (or Unraid autostart hook), deletes <code class="font-mono text-[11px]">/var/log/pulse-agent.log</code>, and uninstalls the binary. Pulse will notice the host is gone after the next heartbeat (≈2 minutes) and clean up the row automatically.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2 rounded border border-gray-200 p-3 dark:border-gray-700">
|
<div class="flex flex-col gap-2 rounded border border-gray-200 p-3 dark:border-gray-700">
|
||||||
|
|
@ -1443,11 +1440,10 @@ WantedBy=multi-user.target`;
|
||||||
<td class="py-3 px-4 align-top">
|
<td class="py-3 px-4 align-top">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span
|
<span
|
||||||
class={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
class={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${isOnline
|
||||||
isOnline
|
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
||||||
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{host.status || 'unknown'}
|
{host.status || 'unknown'}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -1486,7 +1482,7 @@ WantedBy=multi-user.target`;
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3 px-4 align-top">
|
<td class="py-3 px-4 align-top">
|
||||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
{describeRuntime(host)}
|
{describeRuntime(host)}
|
||||||
|
|
|
||||||
|
|
@ -37,14 +37,14 @@ const commandsByPlatform: Record<
|
||||||
linux: {
|
linux: {
|
||||||
title: 'Install on Linux',
|
title: 'Install on Linux',
|
||||||
description:
|
description:
|
||||||
'The installer downloads the agent binary and configures it as a systemd service.',
|
'The unified installer downloads the agent binary and configures it as a systemd service.',
|
||||||
snippets: [
|
snippets: [
|
||||||
{
|
{
|
||||||
label: 'Install with systemd',
|
label: 'Install with systemd',
|
||||||
command: `curl -fsSL ${pulseUrl()}/install-host-agent.sh | bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`,
|
command: `curl -fsSL ${pulseUrl()}/install.sh | bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`,
|
||||||
note: (
|
note: (
|
||||||
<span>
|
<span>
|
||||||
Automatically installs to <code>/usr/local/bin/pulse-host-agent</code> and creates <code>/etc/systemd/system/pulse-host-agent.service</code>.
|
Automatically installs to <code>/usr/local/bin/pulse-agent</code> and creates <code>/etc/systemd/system/pulse-agent.service</code>.
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -53,14 +53,14 @@ const commandsByPlatform: Record<
|
||||||
macos: {
|
macos: {
|
||||||
title: 'Install on macOS',
|
title: 'Install on macOS',
|
||||||
description:
|
description:
|
||||||
'The installer downloads the universal binary and sets up a launchd service for background monitoring.',
|
'The unified installer downloads the universal binary and sets up a launchd service for background monitoring.',
|
||||||
snippets: [
|
snippets: [
|
||||||
{
|
{
|
||||||
label: 'Install with launchd',
|
label: 'Install with launchd',
|
||||||
command: `curl -fsSL ${pulseUrl()}/install-host-agent.sh | bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`,
|
command: `curl -fsSL ${pulseUrl()}/install.sh | bash -s -- --url ${pulseUrl()} --token ${TOKEN_PLACEHOLDER} --interval 30s`,
|
||||||
note: (
|
note: (
|
||||||
<span>
|
<span>
|
||||||
Creates <code>~/Library/LaunchAgents/com.pulse.host-agent.plist</code> and starts the agent automatically.
|
Creates <code>/Library/LaunchDaemons/com.pulse.agent.plist</code> and starts the agent automatically.
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -69,20 +69,20 @@ const commandsByPlatform: Record<
|
||||||
windows: {
|
windows: {
|
||||||
title: 'Install on Windows',
|
title: 'Install on Windows',
|
||||||
description:
|
description:
|
||||||
'Run the PowerShell script to install and configure the host agent as a Windows service with automatic startup.',
|
'Run the PowerShell script to install and configure the unified agent as a Windows service with automatic startup.',
|
||||||
snippets: [
|
snippets: [
|
||||||
{
|
{
|
||||||
label: 'Install as Windows Service (PowerShell)',
|
label: 'Install as Windows Service (PowerShell)',
|
||||||
command: `irm ${pulseUrl()}/install-host-agent.ps1 | iex`,
|
command: `irm ${pulseUrl()}/install.ps1 | iex`,
|
||||||
note: (
|
note: (
|
||||||
<span>
|
<span>
|
||||||
Run in PowerShell as Administrator. The script will prompt for the Pulse URL and API token, download the agent binary, and install it as a Windows service with automatic startup. The agent runs natively and can access all Windows performance counters.
|
Run in PowerShell as Administrator. The script will prompt for the Pulse URL and API token, download the agent binary, and install it as a Windows service with automatic startup.
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Install with parameters (PowerShell)',
|
label: 'Install with parameters (PowerShell)',
|
||||||
command: `$env:PULSE_URL="${pulseUrl()}"; $env:PULSE_TOKEN="${TOKEN_PLACEHOLDER}"; irm ${pulseUrl()}/install-host-agent.ps1 | iex`,
|
command: `$env:PULSE_URL="${pulseUrl()}"; $env:PULSE_TOKEN="${TOKEN_PLACEHOLDER}"; irm ${pulseUrl()}/install.ps1 | iex`,
|
||||||
note: (
|
note: (
|
||||||
<span>
|
<span>
|
||||||
Non-interactive installation. Set environment variables before running to skip prompts.
|
Non-interactive installation. Set environment variables before running to skip prompts.
|
||||||
|
|
@ -436,20 +436,20 @@ export const HostAgents: Component = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const requiresToken = () => {
|
const requiresToken = () => {
|
||||||
const status = securityStatus();
|
const status = securityStatus();
|
||||||
if (status) {
|
if (status) {
|
||||||
return status.requiresAuth || status.apiTokenConfigured;
|
return status.requiresAuth || status.apiTokenConfigured;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (highlightTimer) {
|
if (highlightTimer) {
|
||||||
clearTimeout(highlightTimer);
|
clearTimeout(highlightTimer);
|
||||||
highlightTimer = null;
|
highlightTimer = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasToken = () => Boolean(currentToken());
|
const hasToken = () => Boolean(currentToken());
|
||||||
const commandsUnlocked = () => (requiresToken() ? hasToken() : hasToken() || confirmedNoToken());
|
const commandsUnlocked = () => (requiresToken() ? hasToken() : hasToken() || confirmedNoToken());
|
||||||
|
|
@ -538,26 +538,26 @@ User=root
|
||||||
WantedBy=multi-user.target`;
|
WantedBy=multi-user.target`;
|
||||||
|
|
||||||
function getManualUninstallCommand(): string {
|
function getManualUninstallCommand(): string {
|
||||||
return `sudo systemctl stop pulse-host-agent && \\
|
return `sudo systemctl stop pulse-agent && \\
|
||||||
sudo systemctl disable pulse-host-agent && \\
|
sudo systemctl disable pulse-agent && \\
|
||||||
sudo rm -f /etc/systemd/system/pulse-host-agent.service && \\
|
sudo rm -f /etc/systemd/system/pulse-agent.service && \\
|
||||||
sudo rm -f /usr/local/bin/pulse-host-agent && \\
|
sudo rm -f /usr/local/bin/pulse-agent && \\
|
||||||
sudo systemctl daemon-reload`;
|
sudo systemctl daemon-reload`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHostUninstallCommand(host: Host | null): string {
|
function getHostUninstallCommand(host: Host | null): string {
|
||||||
const platform = host?.platform?.toLowerCase();
|
const platform = host?.platform?.toLowerCase();
|
||||||
if (platform === 'macos' || platform === 'darwin' || platform === 'mac') {
|
if (platform === 'macos' || platform === 'darwin' || platform === 'mac') {
|
||||||
return `launchctl unload ~/Library/LaunchAgents/com.pulse.host-agent.plist >/dev/null 2>&1 || true && \\
|
return `launchctl unload /Library/LaunchDaemons/com.pulse.agent.plist >/dev/null 2>&1 || true && \\
|
||||||
rm -f ~/Library/LaunchAgents/com.pulse.host-agent.plist && \\
|
rm -f /Library/LaunchDaemons/com.pulse.agent.plist && \\
|
||||||
sudo rm -f /usr/local/bin/pulse-host-agent && \\
|
sudo rm -f /usr/local/bin/pulse-agent && \\
|
||||||
rm -f ~/Library/Logs/pulse-host-agent.log`;
|
rm -f /var/log/pulse-agent.log`;
|
||||||
}
|
}
|
||||||
if (platform === 'windows' || platform === 'win32' || platform === 'windows_nt') {
|
if (platform === 'windows' || platform === 'win32' || platform === 'windows_nt') {
|
||||||
return `Stop-Service -Name PulseHostAgent -ErrorAction SilentlyContinue; \\
|
return `Stop-Service -Name PulseAgent -ErrorAction SilentlyContinue; \\
|
||||||
sc.exe delete PulseHostAgent; \\
|
sc.exe delete PulseAgent; \\
|
||||||
Remove-Item 'C:\\\\Program Files\\\\Pulse\\\\pulse-host-agent.exe' -Force -ErrorAction SilentlyContinue; \\
|
Remove-Item 'C:\\\\Program Files\\\\Pulse\\\\pulse-agent.exe' -Force -ErrorAction SilentlyContinue; \\
|
||||||
Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-host-agent.log' -Force -ErrorAction SilentlyContinue`;
|
Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-agent.log' -Force -ErrorAction SilentlyContinue`;
|
||||||
}
|
}
|
||||||
return getManualUninstallCommand();
|
return getManualUninstallCommand();
|
||||||
}
|
}
|
||||||
|
|
@ -619,241 +619,240 @@ Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-host-agent.log' -Force -ErrorAct
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={!requiresToken()}>
|
<Show when={!requiresToken()}>
|
||||||
<div class="space-y-3">
|
<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">
|
<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.
|
Tokens are optional on this Pulse instance. Confirm to generate commands without embedding a token.
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={acknowledgeNoToken}
|
onClick={acknowledgeNoToken}
|
||||||
disabled={confirmedNoToken()}
|
disabled={confirmedNoToken()}
|
||||||
class={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors ${
|
class={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors ${confirmedNoToken()
|
||||||
confirmedNoToken()
|
? 'bg-green-600 text-white cursor-default'
|
||||||
? 'bg-green-600 text-white cursor-default'
|
: 'bg-gray-900 text-white hover:bg-black dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white'
|
||||||
: 'bg-gray-900 text-white hover:bg-black dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{confirmedNoToken() ? 'No token confirmed' : 'Confirm without token'}
|
{confirmedNoToken() ? 'No token confirmed' : 'Confirm without token'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={commandsUnlocked()}>
|
<Show when={commandsUnlocked()}>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Installation commands</h4>
|
<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()}>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<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>
|
</p>
|
||||||
</Show>
|
<div class="space-y-4">
|
||||||
<Show when={!requiresToken() && !confirmedNoToken() && !hasToken()}>
|
<For each={commandSections()}>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">Confirm the no-token setup to continue.</p>
|
{(section) => (
|
||||||
</Show>
|
<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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -1010,11 +1009,10 @@ Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-host-agent.log' -Force -ErrorAct
|
||||||
<td class="py-3 px-4">
|
<td class="py-3 px-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
class={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
class={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${isOnline
|
||||||
isOnline
|
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
||||||
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{host.status || 'unknown'}
|
{host.status || 'unknown'}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -1098,10 +1096,10 @@ Remove-Item '$env:ProgramData\\\\Pulse\\\\pulse-host-agent.log' -Force -ErrorAct
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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">
|
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-300">
|
||||||
<span class="font-semibold uppercase tracking-wide text-[11px] text-gray-500 dark:text-gray-400">Host status</span>
|
<span class="font-semibold uppercase tracking-wide text-[11px] text-gray-500 dark:text-gray-400">Host status</span>
|
||||||
<span
|
<span
|
||||||
class={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold uppercase ${
|
class={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold uppercase ${hostRemovalIsOnline()
|
||||||
hostRemovalIsOnline()
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
|
||||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
|
: 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200'
|
||||||
: 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{hostRemovalStatusLabel()}
|
{hostRemovalStatusLabel()}
|
||||||
</span>
|
</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 backendHost = process.env.PULSE_DEV_API_HOST ?? '127.0.0.1';
|
||||||
const backendPort = Number(
|
const backendPort = Number(
|
||||||
process.env.PULSE_DEV_API_PORT ??
|
process.env.PULSE_DEV_API_PORT ??
|
||||||
process.env.FRONTEND_PORT ??
|
process.env.FRONTEND_PORT ??
|
||||||
process.env.PORT ??
|
process.env.PORT ??
|
||||||
7655,
|
7655,
|
||||||
);
|
);
|
||||||
|
|
||||||
const backendUrl =
|
const backendUrl =
|
||||||
|
|
@ -72,6 +72,14 @@ export default defineConfig({
|
||||||
target: backendUrl,
|
target: backendUrl,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
'/install.sh': {
|
||||||
|
target: backendUrl,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/install.ps1': {
|
||||||
|
target: backendUrl,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
'/download': {
|
'/download': {
|
||||||
target: backendUrl,
|
target: backendUrl,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|
|
||||||
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"`
|
FrontendPort int `envconfig:"FRONTEND_PORT" default:"7655"`
|
||||||
ConfigPath string `envconfig:"CONFIG_PATH" default:"/etc/pulse"`
|
ConfigPath string `envconfig:"CONFIG_PATH" default:"/etc/pulse"`
|
||||||
DataPath string `envconfig:"DATA_PATH" default:"/var/lib/pulse"`
|
DataPath string `envconfig:"DATA_PATH" default:"/var/lib/pulse"`
|
||||||
|
AppRoot string `json:"-"` // Root directory of the application (where binary lives)
|
||||||
PublicURL string `envconfig:"PULSE_PUBLIC_URL" default:""` // Full URL to access Pulse (e.g., http://192.168.1.100:7655)
|
PublicURL string `envconfig:"PULSE_PUBLIC_URL" default:""` // Full URL to access Pulse (e.g., http://192.168.1.100:7655)
|
||||||
|
|
||||||
// Proxmox VE connections
|
// Proxmox VE connections
|
||||||
|
|
@ -530,6 +531,7 @@ func Load() (*Config, error) {
|
||||||
FrontendPort: 7655,
|
FrontendPort: 7655,
|
||||||
ConfigPath: dataDir,
|
ConfigPath: dataDir,
|
||||||
DataPath: dataDir,
|
DataPath: dataDir,
|
||||||
|
AppRoot: detectAppRoot(),
|
||||||
ConcurrentPolling: true,
|
ConcurrentPolling: true,
|
||||||
ConnectionTimeout: 60 * time.Second,
|
ConnectionTimeout: 60 * time.Second,
|
||||||
MetricsRetentionDays: 7,
|
MetricsRetentionDays: 7,
|
||||||
|
|
|
||||||
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"
|
openssl rand -base64 32 > "$DEV_DIR/.encryption.key"
|
||||||
chmod 600 "$DEV_DIR/.encryption.key"
|
chmod 600 "$DEV_DIR/.encryption.key"
|
||||||
echo "✓ Generated dev encryption key at $DEV_DIR/.encryption.key"
|
echo "✓ Generated dev encryption key at $DEV_DIR/.encryption.key"
|
||||||
|
|
||||||
|
# Remove encrypted artifacts that rely on the missing/old key
|
||||||
|
find "$DEV_DIR" -maxdepth 1 -type f -name 'nodes.enc*' -exec rm -f {} \;
|
||||||
|
rm -f "$DEV_DIR/email.enc" "$DEV_DIR/webhooks.enc"
|
||||||
|
echo "✓ Cleared encrypted artifacts (new key generated)"
|
||||||
else
|
else
|
||||||
echo "✓ Reusing existing dev encryption key"
|
echo "✓ Reusing existing dev encryption key"
|
||||||
fi
|
fi
|
||||||
# Remove encrypted artifacts that rely on the missing production key
|
|
||||||
find "$DEV_DIR" -maxdepth 1 -type f -name 'nodes.enc*' -exec rm -f {} \;
|
|
||||||
rm -f "$DEV_DIR/email.enc" "$DEV_DIR/webhooks.enc"
|
|
||||||
echo "✓ Cleared encrypted production artifacts from dev config"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Copy nodes configuration - WITH VALIDATION
|
# Copy nodes configuration - WITH VALIDATION
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue