From 00680499ee677adf66d1c8bd8eaa5cec3338242b Mon Sep 17 00:00:00 2001 From: Nick Goodall Date: Wed, 14 Jan 2026 00:00:39 +0000 Subject: [PATCH] status cmd --- README.md | 4 ++ main.go | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 151 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3e2623f..ccd2fe9 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ Run the local dev server. Syncs `pb_public`, `pb_migrations`, and `pb_hooks`, then restarts the remote PocketBase service. The command will automatically run `setup` if the PocketBase binary isn’t present on the remote. +### `status` + +Show the remote PocketBase systemd service state along with its configured port, PID, and uptime. + ### `setup` Run everything required to deploy an application to a fresh host. This will: diff --git a/main.go b/main.go index fa08d4c..bc3e5d9 100644 --- a/main.go +++ b/main.go @@ -78,6 +78,7 @@ func defaultCommands() []command { {name: "dev", description: "run the PocketBase binary locally", action: runDev}, {name: "setup", description: "provision the remote server and install PocketBase", action: runSetup}, {name: "deploy", description: "sync migrations/hooks/static assets (runs setup if needed)", action: runDeploy}, + {name: "status", description: "show status for the remote PocketBase service", action: runStatus}, {name: "logs", description: "show PocketBase logs", action: runLogs}, {name: "secrets", description: "manage deployment secrets", action: runSecrets}, } @@ -897,9 +898,12 @@ const ( totalSetupSteps = 5 remoteWindowSize = 10 remoteIndent = " " - remoteLineColor = "\033[96m" - headerColor = "\033[95m" - localTimeColor = "\033[92m" + remoteLineColor = "" + headerColor = "\033[1;37m" + localTimeColor = "\033[0;32m" + statusActiveColor = "\033[92m" + statusWarnColor = "\033[93m" + statusFailColor = "\033[91m" remoteColorReset = "\033[0m" ) @@ -1112,6 +1116,146 @@ func runDeploy() error { return nil } +func runStatus() error { + ctx, err := buildDeploymentContext() + if err != nil { + return err + } + defer closeSSHControlMaster(ctx.serverIP) + + printInfo("Service", fmt.Sprintf("pb@%s", ctx.serviceName), remoteLineColor) + printInfo("Server", ctx.serverIP, remoteLineColor) + printInfo("Domain", ctx.domain, remoteLineColor) + printInfo("Port", fmt.Sprintf("%d", ctx.port), remoteLineColor) + + props, err := querySystemdProperties(ctx) + if err != nil { + return fmt.Errorf("unable to query PocketBase service: %w", err) + } + + state := strings.TrimSpace(props["ActiveState"]) + if state == "" { + state = "unknown" + } + statusLine := state + if sub := strings.TrimSpace(props["SubState"]); sub != "" { + statusLine = fmt.Sprintf("%s (%s)", state, sub) + } + printInfo("Status", statusLine, statusColorFor(state)) + + if pid := strings.TrimSpace(props["ExecMainPID"]); pid != "" && pid != "0" { + printInfo("PID", pid, remoteLineColor) + } + + if started := strings.TrimSpace(props["ActiveEnterTimestamp"]); started != "" { + printInfo("Active since", started, localTimeColor) + } + + if uptime := computeUptime(ctx.serverIP, props["ActiveEnterTimestampMonotonic"]); uptime > 0 { + printInfo("Uptime", formatDuration(uptime), localTimeColor) + } + + return nil +} + +func printInfo(label, value, valueColor string) { + if valueColor == "" { + valueColor = remoteLineColor + } + fmt.Printf("%s%s:%s %s%s%s\n", headerColor, label, remoteColorReset, valueColor, value, remoteColorReset) +} + +func statusColorFor(state string) string { + switch strings.ToLower(state) { + case "active": + return statusActiveColor + case "activating", "deactivating": + return statusWarnColor + case "failed", "inactive": + return statusFailColor + default: + return remoteLineColor + } +} + +// querySystemdProperties asks systemd for a few service properties. +func querySystemdProperties(ctx *deploymentContext) (map[string]string, error) { + script := fmt.Sprintf(`set -euo pipefail +systemctl show pb@%s -p ActiveState -p SubState -p ActiveEnterTimestamp -p ActiveEnterTimestampMonotonic -p ExecMainPID +`, ctx.serviceName) + output, err := runSSHCollect(ctx.serverIP, script) + if err != nil { + return nil, err + } + return parseKeyValueLines(output), nil +} + +func computeUptime(server, startMicro string) time.Duration { + startMicro = strings.TrimSpace(startMicro) + if startMicro == "" { + return 0 + } + start, err := strconv.ParseInt(startMicro, 10, 64) + if err != nil || start <= 0 { + return 0 + } + now, err := remoteMonotonicMicro(server) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: unable to compute uptime: %v\n", err) + return 0 + } + diff := now - start + if diff <= 0 { + return 0 + } + return time.Duration(diff) * time.Microsecond +} + +func remoteMonotonicMicro(server string) (int64, error) { + output, err := runSSHOutput(server, `awk '{printf "%d", $1*1000000}' /proc/uptime`) + if err != nil { + return 0, err + } + return strconv.ParseInt(strings.TrimSpace(output), 10, 64) +} + +func parseKeyValueLines(input string) map[string]string { + props := make(map[string]string) + scanner := bufio.NewScanner(strings.NewReader(input)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + if idx := strings.Index(line, "="); idx > 0 { + key := strings.TrimSpace(line[:idx]) + value := strings.TrimSpace(line[idx+1:]) + props[key] = value + } + } + return props +} + +func formatDuration(d time.Duration) string { + if d < time.Second { + return d.Round(time.Millisecond).String() + } + parts := make([]string, 0, 3) + if hours := d / time.Hour; hours > 0 { + parts = append(parts, fmt.Sprintf("%dh", hours)) + d -= hours * time.Hour + } + if mins := d / time.Minute; mins > 0 { + parts = append(parts, fmt.Sprintf("%dm", mins)) + d -= mins * time.Minute + } + seconds := int64(d / time.Second) + if seconds > 0 || len(parts) == 0 { + parts = append(parts, fmt.Sprintf("%ds", seconds)) + } + return strings.Join(parts, " ") +} + func runLogs() error { ctx, err := buildDeploymentContext() if err != nil {