mirror of
https://github.com/DeBrosOfficial/network.git
synced 2026-01-30 14:53:02 +00:00
Added Stats for deployments on CLI
This commit is contained in:
parent
e706ed3397
commit
15ecf366d5
@ -33,6 +33,7 @@ func HandleDeploymentsCommand(args []string) {
|
|||||||
deploymentsCmd.AddCommand(deployments.DeleteCmd)
|
deploymentsCmd.AddCommand(deployments.DeleteCmd)
|
||||||
deploymentsCmd.AddCommand(deployments.RollbackCmd)
|
deploymentsCmd.AddCommand(deployments.RollbackCmd)
|
||||||
deploymentsCmd.AddCommand(deployments.LogsCmd)
|
deploymentsCmd.AddCommand(deployments.LogsCmd)
|
||||||
|
deploymentsCmd.AddCommand(deployments.StatsCmd)
|
||||||
|
|
||||||
deploymentsCmd.SetArgs(args)
|
deploymentsCmd.SetArgs(args)
|
||||||
|
|
||||||
|
|||||||
116
pkg/cli/deployments/stats.go
Normal file
116
pkg/cli/deployments/stats.go
Normal file
@ -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 <name>",
|
||||||
|
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)
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"text/template"
|
"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)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -87,6 +87,7 @@ type Gateway struct {
|
|||||||
updateHandler *deploymentshandlers.UpdateHandler
|
updateHandler *deploymentshandlers.UpdateHandler
|
||||||
rollbackHandler *deploymentshandlers.RollbackHandler
|
rollbackHandler *deploymentshandlers.RollbackHandler
|
||||||
logsHandler *deploymentshandlers.LogsHandler
|
logsHandler *deploymentshandlers.LogsHandler
|
||||||
|
statsHandler *deploymentshandlers.StatsHandler
|
||||||
domainHandler *deploymentshandlers.DomainHandler
|
domainHandler *deploymentshandlers.DomainHandler
|
||||||
sqliteHandler *sqlitehandlers.SQLiteHandler
|
sqliteHandler *sqlitehandlers.SQLiteHandler
|
||||||
sqliteBackupHandler *sqlitehandlers.BackupHandler
|
sqliteBackupHandler *sqlitehandlers.BackupHandler
|
||||||
@ -334,6 +335,13 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) {
|
|||||||
logger.Logger,
|
logger.Logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
gw.statsHandler = deploymentshandlers.NewStatsHandler(
|
||||||
|
gw.deploymentService,
|
||||||
|
gw.processManager,
|
||||||
|
logger.Logger,
|
||||||
|
baseDeployPath,
|
||||||
|
)
|
||||||
|
|
||||||
gw.domainHandler = deploymentshandlers.NewDomainHandler(
|
gw.domainHandler = deploymentshandlers.NewDomainHandler(
|
||||||
gw.deploymentService,
|
gw.deploymentService,
|
||||||
logger.Logger,
|
logger.Logger,
|
||||||
|
|||||||
91
pkg/gateway/handlers/deployments/stats_handler.go
Normal file
91
pkg/gateway/handlers/deployments/stats_handler.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
@ -120,6 +120,7 @@ func (g *Gateway) Routes() http.Handler {
|
|||||||
mux.HandleFunc("/v1/deployments/rollback", g.withHomeNodeProxy(g.rollbackHandler.HandleRollback))
|
mux.HandleFunc("/v1/deployments/rollback", g.withHomeNodeProxy(g.rollbackHandler.HandleRollback))
|
||||||
mux.HandleFunc("/v1/deployments/versions", g.rollbackHandler.HandleListVersions)
|
mux.HandleFunc("/v1/deployments/versions", g.rollbackHandler.HandleListVersions)
|
||||||
mux.HandleFunc("/v1/deployments/logs", g.withHomeNodeProxy(g.logsHandler.HandleLogs))
|
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)
|
mux.HandleFunc("/v1/deployments/events", g.logsHandler.HandleGetEvents)
|
||||||
|
|
||||||
// Custom domains
|
// Custom domains
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user