From 15ecf366d5491c5eb1d5eedfc21203cd880193e1 Mon Sep 17 00:00:00 2001 From: anonpenguin23 Date: Thu, 29 Jan 2026 10:13:29 +0200 Subject: [PATCH] Added Stats for deployments on CLI --- pkg/cli/deployment_commands.go | 1 + pkg/cli/deployments/stats.go | 116 +++++++++++++ pkg/deployments/process/manager.go | 164 ++++++++++++++++++ pkg/gateway/gateway.go | 8 + .../handlers/deployments/stats_handler.go | 91 ++++++++++ pkg/gateway/routes.go | 1 + 6 files changed, 381 insertions(+) create mode 100644 pkg/cli/deployments/stats.go create mode 100644 pkg/gateway/handlers/deployments/stats_handler.go diff --git a/pkg/cli/deployment_commands.go b/pkg/cli/deployment_commands.go index 93c6168..2cc5378 100644 --- a/pkg/cli/deployment_commands.go +++ b/pkg/cli/deployment_commands.go @@ -33,6 +33,7 @@ func HandleDeploymentsCommand(args []string) { deploymentsCmd.AddCommand(deployments.DeleteCmd) deploymentsCmd.AddCommand(deployments.RollbackCmd) deploymentsCmd.AddCommand(deployments.LogsCmd) + deploymentsCmd.AddCommand(deployments.StatsCmd) deploymentsCmd.SetArgs(args) diff --git a/pkg/cli/deployments/stats.go b/pkg/cli/deployments/stats.go new file mode 100644 index 0000000..6d7f879 --- /dev/null +++ b/pkg/cli/deployments/stats.go @@ -0,0 +1,116 @@ +package deployments + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/spf13/cobra" +) + +// StatsCmd shows resource usage for a deployment +var StatsCmd = &cobra.Command{ + Use: "stats ", + Short: "Show resource usage for a deployment", + Args: cobra.ExactArgs(1), + RunE: statsDeployment, +} + +func statsDeployment(cmd *cobra.Command, args []string) error { + name := args[0] + + apiURL := getAPIURL() + url := fmt.Sprintf("%s/v1/deployments/stats?name=%s", apiURL, name) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + + token, err := getAuthToken() + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to get stats: %s", string(body)) + } + + var stats map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil { + return fmt.Errorf("failed to parse stats: %w", err) + } + + // Display + fmt.Println() + fmt.Printf(" Name: %s\n", stats["name"]) + fmt.Printf(" Type: %s\n", stats["type"]) + fmt.Printf(" Status: %s\n", stats["status"]) + + if pid, ok := stats["pid"]; ok { + pidInt := int(pid.(float64)) + if pidInt > 0 { + fmt.Printf(" PID: %d\n", pidInt) + } + } + + if uptime, ok := stats["uptime_seconds"]; ok { + secs := uptime.(float64) + if secs > 0 { + fmt.Printf(" Uptime: %s\n", formatUptime(secs)) + } + } + + fmt.Println() + + if cpu, ok := stats["cpu_percent"]; ok { + fmt.Printf(" CPU: %.1f%%\n", cpu.(float64)) + } + + if mem, ok := stats["memory_rss_mb"]; ok { + fmt.Printf(" RAM: %s\n", formatSize(mem.(float64))) + } + + if disk, ok := stats["disk_mb"]; ok { + fmt.Printf(" Disk: %s\n", formatSize(disk.(float64))) + } + + fmt.Println() + + return nil +} + +func formatUptime(seconds float64) string { + s := int(seconds) + days := s / 86400 + hours := (s % 86400) / 3600 + mins := (s % 3600) / 60 + + if days > 0 { + return fmt.Sprintf("%dd %dh %dm", days, hours, mins) + } + if hours > 0 { + return fmt.Sprintf("%dh %dm", hours, mins) + } + return fmt.Sprintf("%dm", mins) +} + +func formatSize(mb float64) string { + if mb < 0.1 { + return fmt.Sprintf("%.1f KB", mb*1024) + } + if mb >= 1024 { + return fmt.Sprintf("%.1f GB", mb/1024) + } + return fmt.Sprintf("%.1f MB", mb) +} diff --git a/pkg/deployments/process/manager.go b/pkg/deployments/process/manager.go index 33ab24c..aeceb9f 100644 --- a/pkg/deployments/process/manager.go +++ b/pkg/deployments/process/manager.go @@ -8,6 +8,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" "strings" "sync" "text/template" @@ -486,3 +487,166 @@ func (m *Manager) WaitForHealthy(ctx context.Context, deployment *deployments.De return fmt.Errorf("deployment did not become healthy within %v", timeout) } + +// DeploymentStats holds on-demand resource usage for a deployment process +type DeploymentStats struct { + PID int `json:"pid"` + CPUPercent float64 `json:"cpu_percent"` + MemoryRSS int64 `json:"memory_rss_bytes"` + DiskBytes int64 `json:"disk_bytes"` + UptimeSecs float64 `json:"uptime_seconds"` +} + +// GetStats returns on-demand resource usage stats for a deployment. +// deployPath is the directory on disk for disk usage calculation. +func (m *Manager) GetStats(ctx context.Context, deployment *deployments.Deployment, deployPath string) (*DeploymentStats, error) { + stats := &DeploymentStats{} + + // Disk usage (works on all platforms) + if deployPath != "" { + stats.DiskBytes = dirSize(deployPath) + } + + if !m.useSystemd { + // Direct mode (macOS) — only disk, no /proc + serviceName := m.getServiceName(deployment) + m.processesMu.RLock() + cmd, exists := m.processes[serviceName] + m.processesMu.RUnlock() + if exists && cmd.Process != nil { + stats.PID = cmd.Process.Pid + } + return stats, nil + } + + // Systemd mode (Linux) — get PID, CPU, RAM, uptime + serviceName := m.getServiceName(deployment) + + // Get MainPID and ActiveEnterTimestamp + cmd := exec.CommandContext(ctx, "systemctl", "show", serviceName, + "--property=MainPID,ActiveEnterTimestamp") + output, err := cmd.Output() + if err != nil { + return stats, fmt.Errorf("systemctl show failed: %w", err) + } + + props := parseSystemctlShow(string(output)) + pid, _ := strconv.Atoi(props["MainPID"]) + stats.PID = pid + + if pid <= 0 { + return stats, nil // Process not running + } + + // Uptime from ActiveEnterTimestamp + if ts := props["ActiveEnterTimestamp"]; ts != "" { + // Format: "Mon 2026-01-29 10:00:00 UTC" + if t, err := parseSystemdTimestamp(ts); err == nil { + stats.UptimeSecs = time.Since(t).Seconds() + } + } + + // Memory RSS from /proc/[pid]/status + stats.MemoryRSS = readProcMemoryRSS(pid) + + // CPU % — sample /proc/[pid]/stat twice with 1s gap + stats.CPUPercent = sampleCPUPercent(pid) + + return stats, nil +} + +// parseSystemctlShow parses "Key=Value\n" output into a map +func parseSystemctlShow(output string) map[string]string { + props := make(map[string]string) + for _, line := range strings.Split(output, "\n") { + if idx := strings.IndexByte(line, '='); idx > 0 { + props[line[:idx]] = strings.TrimSpace(line[idx+1:]) + } + } + return props +} + +// parseSystemdTimestamp parses systemd timestamp like "Mon 2026-01-29 10:00:00 UTC" +func parseSystemdTimestamp(ts string) (time.Time, error) { + // Try common systemd formats + for _, layout := range []string{ + "Mon 2006-01-02 15:04:05 MST", + "2006-01-02 15:04:05 MST", + } { + if t, err := time.Parse(layout, ts); err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("cannot parse timestamp: %s", ts) +} + +// readProcMemoryRSS reads VmRSS from /proc/[pid]/status (Linux only) +func readProcMemoryRSS(pid int) int64 { + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)) + if err != nil { + return 0 + } + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "VmRSS:") { + fields := strings.Fields(line) + if len(fields) >= 2 { + kb, _ := strconv.ParseInt(fields[1], 10, 64) + return kb * 1024 // Convert KB to bytes + } + } + } + return 0 +} + +// sampleCPUPercent reads /proc/[pid]/stat twice with a 1s gap to compute CPU % +func sampleCPUPercent(pid int) float64 { + readCPUTicks := func() (utime, stime int64, ok bool) { + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if err != nil { + return 0, 0, false + } + // Fields after the comm (in parens): state(3), ppid(4), ... utime(14), stime(15) + // Find closing paren to skip comm field which may contain spaces + closeParen := strings.LastIndexByte(string(data), ')') + if closeParen < 0 { + return 0, 0, false + } + fields := strings.Fields(string(data)[closeParen+2:]) + if len(fields) < 13 { + return 0, 0, false + } + u, _ := strconv.ParseInt(fields[11], 10, 64) // utime is field 14, index 11 after paren + s, _ := strconv.ParseInt(fields[12], 10, 64) // stime is field 15, index 12 after paren + return u, s, true + } + + u1, s1, ok1 := readCPUTicks() + if !ok1 { + return 0 + } + time.Sleep(1 * time.Second) + u2, s2, ok2 := readCPUTicks() + if !ok2 { + return 0 + } + + // Clock ticks per second (usually 100 on Linux) + clkTck := 100.0 + totalDelta := float64((u2 + s2) - (u1 + s1)) + cpuPct := (totalDelta / clkTck) * 100.0 + + return cpuPct +} + +// dirSize calculates total size of a directory +func dirSize(path string) int64 { + var size int64 + filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + size += info.Size() + return nil + }) + return size +} diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index b509a83..3f7da9b 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -87,6 +87,7 @@ type Gateway struct { updateHandler *deploymentshandlers.UpdateHandler rollbackHandler *deploymentshandlers.RollbackHandler logsHandler *deploymentshandlers.LogsHandler + statsHandler *deploymentshandlers.StatsHandler domainHandler *deploymentshandlers.DomainHandler sqliteHandler *sqlitehandlers.SQLiteHandler sqliteBackupHandler *sqlitehandlers.BackupHandler @@ -334,6 +335,13 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) { logger.Logger, ) + gw.statsHandler = deploymentshandlers.NewStatsHandler( + gw.deploymentService, + gw.processManager, + logger.Logger, + baseDeployPath, + ) + gw.domainHandler = deploymentshandlers.NewDomainHandler( gw.deploymentService, logger.Logger, diff --git a/pkg/gateway/handlers/deployments/stats_handler.go b/pkg/gateway/handlers/deployments/stats_handler.go new file mode 100644 index 0000000..416df6c --- /dev/null +++ b/pkg/gateway/handlers/deployments/stats_handler.go @@ -0,0 +1,91 @@ +package deployments + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + + "github.com/DeBrosOfficial/network/pkg/deployments" + "github.com/DeBrosOfficial/network/pkg/deployments/process" + "go.uber.org/zap" +) + +// StatsHandler handles on-demand deployment resource stats +type StatsHandler struct { + service *DeploymentService + processManager *process.Manager + logger *zap.Logger + baseDeployPath string +} + +// NewStatsHandler creates a new stats handler +func NewStatsHandler(service *DeploymentService, processManager *process.Manager, logger *zap.Logger, baseDeployPath string) *StatsHandler { + if baseDeployPath == "" { + baseDeployPath = filepath.Join(os.Getenv("HOME"), ".orama", "deployments") + } + return &StatsHandler{ + service: service, + processManager: processManager, + logger: logger, + baseDeployPath: baseDeployPath, + } +} + +// HandleStats returns on-demand resource usage for a deployment +func (h *StatsHandler) HandleStats(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + namespace := getNamespaceFromContext(ctx) + if namespace == "" { + http.Error(w, "Namespace not found in context", http.StatusUnauthorized) + return + } + + name := r.URL.Query().Get("name") + if name == "" { + http.Error(w, "name query parameter is required", http.StatusBadRequest) + return + } + + deployment, err := h.service.GetDeployment(ctx, namespace, name) + if err != nil { + if err == deployments.ErrDeploymentNotFound { + http.Error(w, "Deployment not found", http.StatusNotFound) + } else { + http.Error(w, "Failed to get deployment", http.StatusInternalServerError) + } + return + } + + deployPath := filepath.Join(h.baseDeployPath, deployment.Namespace, deployment.Name) + + resp := map[string]interface{}{ + "name": deployment.Name, + "type": string(deployment.Type), + "status": string(deployment.Status), + } + + if deployment.Port == 0 { + // Static deployment — only disk + stats, _ := h.processManager.GetStats(ctx, deployment, deployPath) + if stats != nil { + resp["disk_mb"] = float64(stats.DiskBytes) / (1024 * 1024) + } + } else { + // Dynamic deployment — full stats + stats, err := h.processManager.GetStats(ctx, deployment, deployPath) + if err != nil { + h.logger.Warn("Failed to get stats", zap.Error(err)) + } + if stats != nil { + resp["pid"] = stats.PID + resp["uptime_seconds"] = stats.UptimeSecs + resp["cpu_percent"] = stats.CPUPercent + resp["memory_rss_mb"] = float64(stats.MemoryRSS) / (1024 * 1024) + resp["disk_mb"] = float64(stats.DiskBytes) / (1024 * 1024) + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} diff --git a/pkg/gateway/routes.go b/pkg/gateway/routes.go index 9d105f7..34f010e 100644 --- a/pkg/gateway/routes.go +++ b/pkg/gateway/routes.go @@ -120,6 +120,7 @@ func (g *Gateway) Routes() http.Handler { mux.HandleFunc("/v1/deployments/rollback", g.withHomeNodeProxy(g.rollbackHandler.HandleRollback)) mux.HandleFunc("/v1/deployments/versions", g.rollbackHandler.HandleListVersions) mux.HandleFunc("/v1/deployments/logs", g.withHomeNodeProxy(g.logsHandler.HandleLogs)) + mux.HandleFunc("/v1/deployments/stats", g.withHomeNodeProxy(g.statsHandler.HandleStats)) mux.HandleFunc("/v1/deployments/events", g.logsHandler.HandleGetEvents) // Custom domains