diff --git a/frontend-modern/src/components/Settings/DockerAgents.tsx b/frontend-modern/src/components/Settings/DockerAgents.tsx index 1c8da1d..0aa23b7 100644 --- a/frontend-modern/src/components/Settings/DockerAgents.tsx +++ b/frontend-modern/src/components/Settings/DockerAgents.tsx @@ -42,6 +42,8 @@ export const DockerAgents: Component = () => { const [currentToken, setCurrentToken] = createSignal(null); const [latestRecord, setLatestRecord] = createSignal(null); const [tokenName, setTokenName] = createSignal(''); + const [commandQueuedTime, setCommandQueuedTime] = createSignal(null); + const [elapsedSeconds, setElapsedSeconds] = createSignal(0); const pulseUrl = () => { if (typeof window !== 'undefined') { @@ -120,6 +122,24 @@ const modalCommandProgress = createMemo(() => { }); }); +const modalCommandTimedOut = createMemo(() => { + return modalCommandInProgress() && elapsedSeconds() > 120; // 2 minutes +}); + +const modalLastHeartbeat = createMemo(() => { + const host = hostToRemove(); + return host?.lastReportTime ? formatRelativeTime(new Date(host.lastReportTime)) : null; +}); + +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`; +}; + createEffect(() => { if (!showRemoveModal()) return; const id = hostToRemoveId(); @@ -129,6 +149,35 @@ const modalCommandProgress = createMemo(() => { } }); + // Track elapsed time for command execution + createEffect(() => { + const cmd = modalCommand(); + if (!cmd) { + setCommandQueuedTime(null); + setElapsedSeconds(0); + return; + } + + // Set queued time when command first appears + if (cmd.createdAt && !commandQueuedTime()) { + setCommandQueuedTime(new Date(cmd.createdAt)); + } + + // Update elapsed time every second while command is in progress + if (modalCommandInProgress()) { + const interval = setInterval(() => { + const queuedTime = commandQueuedTime(); + if (queuedTime) { + const now = new Date(); + const elapsed = Math.floor((now.getTime() - queuedTime.getTime()) / 1000); + setElapsedSeconds(elapsed); + } + }, 1000); + + return () => clearInterval(interval); + } + }); + onMount(() => { if (typeof window === 'undefined') { return; @@ -598,7 +647,69 @@ WantedBy=multi-user.target`; })()} -

Hang tight—Pulse is waiting for the agent to acknowledge the stop command.

+
+ {/* Progress steps */} +
+
+ Progress + {formatElapsedTime(elapsedSeconds())} elapsed +
+
    + + {(step) => ( +
  • + + {step.label} +
  • + )} +
    +
+
+ + {/* Expected time and last heartbeat */} +
+ + + +
+

+ + This usually takes 30-60 seconds. + + + {' '}Last heartbeat: {modalLastHeartbeat()}. + +

+
+
+ + {/* Timeout warning */} + +
+
+ + + +
+

Command taking longer than expected

+

+ The agent may be offline or experiencing issues. Consider using "Force remove now" below to skip the agent stop and remove the host immediately. +

+
+
+
+
+
@@ -661,6 +772,33 @@ WantedBy=multi-user.target`;
+ {/* Force remove option when command times out */} + +
+
+
+ + + +
+

Skip waiting and remove now

+

+ Still waiting after {formatElapsedTime(elapsedSeconds())}. Remove the host entry immediately without waiting for the agent. +

+
+
+ +
+
+
+
diff --git a/internal/dockeragent/agent.go b/internal/dockeragent/agent.go index f89efb9..73f1fbc 100644 --- a/internal/dockeragent/agent.go +++ b/internal/dockeragent/agent.go @@ -702,6 +702,19 @@ func (a *Agent) handleStopCommand(ctx context.Context, target TargetConfig, comm } a.logger.Info().Msg("Stop command acknowledged; terminating agent") + + // After sending the acknowledgement, stop the systemd service to prevent restart. + // This is done after the ack to ensure the acknowledgement is sent before the + // process is terminated by systemctl stop. + go func() { + // Small delay to ensure the ack response completes + time.Sleep(1 * time.Second) + stopServiceCtx := context.Background() + if err := stopSystemdService(stopServiceCtx, "pulse-docker-agent"); err != nil { + a.logger.Warn().Err(err).Msg("Failed to stop systemd service, agent will exit normally") + } + }() + return ErrStopRequested } @@ -743,6 +756,32 @@ func disableSystemdService(ctx context.Context, service string) error { return nil } +func stopSystemdService(ctx context.Context, service string) error { + if _, err := exec.LookPath("systemctl"); err != nil { + // Not a systemd environment; nothing to do. + return nil + } + + // Stop the service to terminate the current running instance. + // This prevents systemd from restarting the service (services stopped via + // systemctl stop are not restarted even with Restart=always). + cmd := exec.CommandContext(ctx, "systemctl", "stop", service) + output, err := cmd.CombinedOutput() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode := exitErr.ExitCode() + lowerOutput := strings.ToLower(string(output)) + // Ignore "not found" errors since the service might already be stopped + if exitCode == 5 || strings.Contains(lowerOutput, "could not be found") || strings.Contains(lowerOutput, "not-found") { + return nil + } + } + return fmt.Errorf("systemctl stop %s: %w (%s)", service, err, strings.TrimSpace(string(output))) + } + + return nil +} + func removeFileIfExists(path string) error { if err := os.Remove(path); err != nil { if errors.Is(err, os.ErrNotExist) { diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 058892e..bb8c582 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -834,6 +834,23 @@ func (m *Monitor) RemoveDockerHost(hostID string) (models.DockerHost, error) { } } + // Revoke the API token associated with this Docker host + if host.TokenID != "" { + tokenRemoved := m.config.RemoveAPIToken(host.TokenID) + if tokenRemoved { + m.config.SortAPITokens() + m.config.APITokenEnabled = m.config.HasAPITokens() + + if m.persistence != nil { + if err := m.persistence.SaveAPITokens(m.config.APITokens); err != nil { + log.Warn().Err(err).Str("tokenID", host.TokenID).Msg("Failed to persist API token revocation after Docker host removal") + } else { + log.Info().Str("tokenID", host.TokenID).Str("tokenName", host.TokenName).Msg("API token revoked for removed Docker host") + } + } + } + } + // Track removal to prevent resurrection from cached reports m.mu.Lock() m.removedDockerHosts[hostID] = time.Now() @@ -876,6 +893,23 @@ func (m *Monitor) RemoveHostAgent(hostID string) (models.Host, error) { } } + // Revoke the API token associated with this host agent + if host.TokenID != "" { + tokenRemoved := m.config.RemoveAPIToken(host.TokenID) + if tokenRemoved { + m.config.SortAPITokens() + m.config.APITokenEnabled = m.config.HasAPITokens() + + if m.persistence != nil { + if err := m.persistence.SaveAPITokens(m.config.APITokens); err != nil { + log.Warn().Err(err).Str("tokenID", host.TokenID).Msg("Failed to persist API token revocation after host agent removal") + } else { + log.Info().Str("tokenID", host.TokenID).Str("tokenName", host.TokenName).Msg("API token revoked for removed host agent") + } + } + } + } + m.state.RemoveConnectionHealth(hostConnectionPrefix + hostID) log.Info().