From e2889617fceb975906b8bb6256c4ddb119ab9769 Mon Sep 17 00:00:00 2001 From: Joel Male Date: Wed, 25 Feb 2026 14:44:14 +1000 Subject: [PATCH] feat: add status, uninstall commands and collector_version in pulses - 'pulsewise-collector status' shows hostname, masked token, API endpoint, service running state (systemd/launchd), and last pulse timestamp - 'pulsewise-collector uninstall' stops the service, removes the unit file, config dir, log file, and binary; prompts for confirmation, requires root - State file written to /etc/pulsewise-collector/state (or ~/.config fallback) after each successful send to power the 'last pulse' display - collector_version field added to every metrics payload so the server can surface update prompts per-host --- main.go | 231 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 224 insertions(+), 7 deletions(-) diff --git a/main.go b/main.go index e9f512c..5c6e9cc 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "log" "net/http" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -34,7 +35,16 @@ const ( collectionInterval = 30 * time.Second updateCheckInterval = 6 * time.Hour configFilePath = "/etc/pulsewise-collector/config" + stateFilePath = "/etc/pulsewise-collector/state" topProcessesCount = 10 + + launchdLabel = "app.pulsewise.collector" + launchdPlist = "/Library/LaunchDaemons/app.pulsewise.collector.plist" + systemdUnit = "pulsewise-collector" + systemdUnitFile = "/etc/systemd/system/pulsewise-collector.service" + binaryPath = "/usr/local/bin/pulsewise-collector" + configDir = "/etc/pulsewise-collector" + macLogFile = "/var/log/pulsewise-collector.log" ) type Config struct { @@ -47,10 +57,11 @@ type Config struct { } type Metrics struct { - Timestamp time.Time `json:"timestamp"` - Token string `json:"token"` - Hostname string `json:"hostname"` - Uptime uint64 `json:"uptime"` + Timestamp time.Time `json:"timestamp"` + Token string `json:"token"` + Hostname string `json:"hostname"` + CollectorVersion string `json:"collector_version"` + Uptime uint64 `json:"uptime"` System SystemInfo `json:"system"` LoadAvg LoadMetrics `json:"load_avg"` CPU CPUMetrics `json:"cpu"` @@ -152,6 +163,12 @@ func main() { case "update": runUpdateCommand() return + case "status": + runStatusCommand() + return + case "uninstall": + runUninstallCommand() + return } } @@ -233,6 +250,204 @@ func runUpdateCommand() { fmt.Printf("Done.\nSuccessfully updated to v%s.\n", latest) } +func runStatusCommand() { + const ( + bold = "\033[1m" + dim = "\033[2m" + green = "\033[0;32m" + red = "\033[0;31m" + nc = "\033[0m" + ) + + label := func(s string) string { return fmt.Sprintf(" %s%-16s%s", dim, s, nc) } + + fmt.Printf("\n%spulsewise-collector%s v%s\n\n", bold, nc, version) + + // Config + token, hostname, url, _, err := loadConfigFromFile() + if err != nil { + fmt.Printf("%s Not configured — %v\n\n", red, nc) + } else { + maskedToken := token + if len(token) >= 8 { + maskedToken = token[:4] + "..." + token[len(token)-4:] + } else { + maskedToken = "****" + } + fmt.Printf("%sCollector%s\n", bold, nc) + fmt.Printf("%s %s\n", label("Hostname"), hostname) + fmt.Printf("%s %s\n", label("Token"), maskedToken) + fmt.Printf("%s %s\n", label("API endpoint"), url) + fmt.Printf("%s %s\n", label("Interval"), collectionInterval) + fmt.Println() + } + + // Service status + fmt.Printf("%sService%s\n", bold, nc) + switch runtime.GOOS { + case "linux": + out, err := exec.Command("systemctl", "is-active", systemdUnit).Output() + state := strings.TrimSpace(string(out)) + if err == nil && state == "active" { + fmt.Printf("%s %srunning%s (systemd)\n", label("Status"), green, nc) + } else { + fmt.Printf("%s %sstopped%s (systemd)\n", label("Status"), red, nc) + } + case "darwin": + err := exec.Command("launchctl", "list", launchdLabel).Run() + if err == nil { + fmt.Printf("%s %srunning%s (launchd)\n", label("Status"), green, nc) + } else { + fmt.Printf("%s %sstopped%s (launchd)\n", label("Status"), red, nc) + } + default: + fmt.Printf("%s unknown platform\n", label("Status")) + } + + // Last pulse + if t, err := readLastPulse(); err == nil { + ago := time.Since(t).Round(time.Second) + fmt.Printf("%s %s %s(%s ago)%s\n", label("Last pulse"), t.Format("2006-01-02 15:04:05"), dim, ago, nc) + } else { + fmt.Printf("%s never\n", label("Last pulse")) + } + fmt.Println() +} + +func runUninstallCommand() { + const ( + bold = "\033[1m" + dim = "\033[2m" + green = "\033[0;32m" + red = "\033[0;31m" + yellow = "\033[1;33m" + nc = "\033[0m" + ) + + if os.Getuid() != 0 { + fmt.Fprintf(os.Stderr, "%sError:%s uninstall must be run as root (try sudo pulsewise-collector uninstall)\n", red, nc) + os.Exit(1) + } + + selfPath, _ := os.Executable() + selfPath, _ = filepath.EvalSymlinks(selfPath) + + fmt.Printf("\n%sUninstall pulsewise-collector%s\n\n", bold, nc) + fmt.Printf(" %sThe following will be removed:%s\n", dim, nc) + + switch runtime.GOOS { + case "linux": + fmt.Printf(" Service /etc/systemd/system/%s.service\n", systemdUnit) + case "darwin": + fmt.Printf(" Service %s\n", launchdPlist) + fmt.Printf(" Logs %s\n", macLogFile) + } + fmt.Printf(" Binary %s\n", selfPath) + fmt.Printf(" Config %s/\n", configDir) + fmt.Println() + + fmt.Printf(" %sAre you sure?%s [y/N]: ", yellow, nc) + var reply string + fmt.Scanln(&reply) + if strings.ToLower(strings.TrimSpace(reply)) != "y" { + fmt.Println("\n Cancelled.") + return + } + fmt.Println() + + step := func(label, path string, fn func() error) { + fmt.Printf(" %-28s", label+"...") + if err := fn(); err != nil { + fmt.Printf("%sfailed%s (%v)\n", red, nc, err) + } else { + fmt.Printf("%sdone%s\n", green, nc) + } + } + + switch runtime.GOOS { + case "linux": + step("Stopping service", "", func() error { + exec.Command("systemctl", "stop", systemdUnit).Run() + return nil + }) + step("Disabling service", "", func() error { + exec.Command("systemctl", "disable", "--quiet", systemdUnit).Run() + return nil + }) + step("Removing unit file", systemdUnitFile, func() error { + err := os.Remove(systemdUnitFile) + exec.Command("systemctl", "daemon-reload").Run() + return err + }) + case "darwin": + step("Stopping service", "", func() error { + exec.Command("launchctl", "bootout", "system", launchdPlist).Run() + return nil + }) + step("Removing plist", launchdPlist, func() error { + return os.Remove(launchdPlist) + }) + step("Removing log file", macLogFile, func() error { + err := os.Remove(macLogFile) + if os.IsNotExist(err) { + return nil + } + return err + }) + } + + step("Removing config dir", configDir, func() error { + return os.RemoveAll(configDir) + }) + + // Remove the binary last — the process keeps running from the + // already-mapped memory pages until os.Exit, so this is safe. + step("Removing binary", selfPath, func() error { + return os.Remove(selfPath) + }) + + fmt.Printf("\n %sPulsewise Collector has been uninstalled.%s\n\n", green, nc) +} + +// ───────────────────────────────────────────────────────────── +// State file (last pulse timestamp) +// ───────────────────────────────────────────────────────────── + +func resolveStateFilePath() string { + if _, err := os.Stat(filepath.Dir(stateFilePath)); err == nil { + return stateFilePath + } + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(homeDir, ".config", "pulsewise-collector", "state") +} + +func writeLastPulse() { + path := resolveStateFilePath() + if path == "" { + return + } + _ = os.WriteFile(path, []byte(fmt.Sprintf("%d", time.Now().Unix())), 0644) +} + +func readLastPulse() (time.Time, error) { + path := resolveStateFilePath() + if path == "" { + return time.Time{}, fmt.Errorf("no state path") + } + data, err := os.ReadFile(path) + if err != nil { + return time.Time{}, err + } + var ts int64 + if _, err := fmt.Sscanf(strings.TrimSpace(string(data)), "%d", &ts); err != nil { + return time.Time{}, err + } + return time.Unix(ts, 0), nil +} + // ───────────────────────────────────────────────────────────── // Auto-update // ───────────────────────────────────────────────────────────── @@ -474,14 +689,16 @@ func collectAndSend(config *Config) { return } + writeLastPulse() log.Printf("Successfully sent metrics at %s", time.Now().Format(time.RFC3339)) } func collectMetrics(config *Config) (*Metrics, error) { metrics := &Metrics{ - Timestamp: time.Now(), - Token: config.Token, - Hostname: config.Hostname, + Timestamp: time.Now(), + Token: config.Token, + Hostname: config.Hostname, + CollectorVersion: version, } // Uptime