Skip to content

Commit 539719b

Browse files
committed
Merge ISS-49-resource-metrics into main
2 parents 8a11000 + e22b3a9 commit 539719b

4 files changed

Lines changed: 643 additions & 0 deletions

File tree

cmd/devbox/main.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/junixlabs/devbox/internal/doctor"
1717
devboxerr "github.com/junixlabs/devbox/internal/errors"
1818
"github.com/junixlabs/devbox/internal/identity"
19+
"github.com/junixlabs/devbox/internal/metrics"
1920
"github.com/junixlabs/devbox/internal/server"
2021
"github.com/junixlabs/devbox/internal/snapshot"
2122
devboxssh "github.com/junixlabs/devbox/internal/ssh"
@@ -65,6 +66,7 @@ func main() {
6566
rootCmd.AddCommand(destroyCmd(wm))
6667
rootCmd.AddCommand(sshCmd(wm))
6768
rootCmd.AddCommand(doctorCmd())
69+
rootCmd.AddCommand(statsCmd())
6870
rootCmd.AddCommand(serverCmd())
6971
rootCmd.AddCommand(templateCmd())
7072
rootCmd.AddCommand(tuiCmd(wm))
@@ -682,6 +684,125 @@ func timeAgo(t time.Time) string {
682684
}
683685
}
684686

687+
func statsCmd() *cobra.Command {
688+
cmd := &cobra.Command{
689+
Use: "stats [workspace]",
690+
Aliases: []string{"metrics"},
691+
Short: "Show resource usage for workspaces",
692+
Long: "Display CPU, memory, disk, and network I/O metrics for workspaces.\nIf a workspace name is given, shows metrics for that workspace only.\nOtherwise shows all workspaces on the server plus a server summary.",
693+
Args: cobra.MaximumNArgs(1),
694+
RunE: func(cmd *cobra.Command, args []string) error {
695+
serverFlag, _ := cmd.Flags().GetString("server")
696+
697+
if serverFlag == "" {
698+
cfg, err := config.LoadFromDir(".")
699+
if err == nil {
700+
serverFlag = cfg.Server
701+
}
702+
}
703+
704+
// Resolve server name from pool.
705+
if serverFlag != "" {
706+
configPath, _ := server.DefaultConfigPath()
707+
if pool, err := server.NewFilePool(configPath, nil); err == nil {
708+
if servers, err := pool.List(); err == nil {
709+
for _, srv := range servers {
710+
if srv.Name == serverFlag {
711+
serverFlag = server.SSHHost(&srv)
712+
break
713+
}
714+
}
715+
}
716+
}
717+
}
718+
719+
if serverFlag == "" {
720+
return fmt.Errorf("devbox stats: no server specified — use --server flag or create devbox.yaml")
721+
}
722+
723+
sshExec, err := devboxssh.New()
724+
if err != nil {
725+
return fmt.Errorf("devbox stats: %w", err)
726+
}
727+
defer sshExec.Close()
728+
729+
collector := metrics.NewCollector(sshExec)
730+
ctx := cmd.Context()
731+
732+
if len(args) == 1 {
733+
// Single workspace mode.
734+
container := args[0]
735+
wm, err := collector.CollectWorkspace(ctx, serverFlag, container)
736+
if err != nil {
737+
return fmt.Errorf("devbox stats: %w", err)
738+
}
739+
if wm.Stopped {
740+
fmt.Printf("Workspace %q is stopped\n", container)
741+
return nil
742+
}
743+
printWorkspaceMetrics(wm)
744+
return nil
745+
}
746+
747+
// Server overview mode.
748+
sm, err := collector.CollectServer(ctx, serverFlag)
749+
if err != nil {
750+
return fmt.Errorf("devbox stats: %w", err)
751+
}
752+
753+
if len(sm.Workspaces) == 0 {
754+
fmt.Println("No running containers found")
755+
} else {
756+
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
757+
fmt.Fprintln(w, "WORKSPACE\tCPU\tMEMORY\tNET I/O\tDISK")
758+
for _, wm := range sm.Workspaces {
759+
fmt.Fprintf(w, "%s\t%.1f%%\t%s / %s\t%s / %s\t-\n",
760+
wm.Container,
761+
wm.CPUPercent,
762+
formatBytesShort(wm.MemUsage), formatBytesShort(wm.MemLimit),
763+
formatBytesShort(wm.NetIn), formatBytesShort(wm.NetOut),
764+
)
765+
}
766+
w.Flush()
767+
}
768+
769+
fmt.Printf("\nServer: CPU %d cores, RAM %s / %s, Disk %s / %s\n",
770+
sm.TotalCPUs,
771+
formatBytesShort(sm.UsedMem), formatBytesShort(sm.TotalMem),
772+
formatBytesShort(sm.UsedDisk), formatBytesShort(sm.TotalDisk),
773+
)
774+
775+
return nil
776+
},
777+
}
778+
cmd.Flags().String("server", "", "Target server name or hostname")
779+
return cmd
780+
}
781+
782+
func printWorkspaceMetrics(wm *metrics.WorkspaceMetrics) {
783+
fmt.Printf("Workspace: %s\n", wm.Container)
784+
fmt.Printf(" CPU: %.1f%%\n", wm.CPUPercent)
785+
fmt.Printf(" Memory: %s / %s\n", formatBytesShort(wm.MemUsage), formatBytesShort(wm.MemLimit))
786+
fmt.Printf(" Net I/O: %s in / %s out\n", formatBytesShort(wm.NetIn), formatBytesShort(wm.NetOut))
787+
if wm.DiskTotal > 0 {
788+
pct := float64(wm.DiskUsage) / float64(wm.DiskTotal) * 100
789+
fmt.Printf(" Disk: %s / %s (%.0f%%)\n", formatBytesShort(wm.DiskUsage), formatBytesShort(wm.DiskTotal), pct)
790+
}
791+
}
792+
793+
func formatBytesShort(b uint64) string {
794+
switch {
795+
case b >= 1024*1024*1024:
796+
return fmt.Sprintf("%.1fGi", float64(b)/(1024*1024*1024))
797+
case b >= 1024*1024:
798+
return fmt.Sprintf("%.0fMi", float64(b)/(1024*1024))
799+
case b >= 1024:
800+
return fmt.Sprintf("%.0fKi", float64(b)/1024)
801+
default:
802+
return fmt.Sprintf("%dB", b)
803+
}
804+
}
805+
685806
func serverCmd() *cobra.Command {
686807
cmd := &cobra.Command{
687808
Use: "server",

internal/metrics/collector.go

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
package metrics
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"regexp"
8+
"strconv"
9+
"strings"
10+
11+
"github.com/junixlabs/devbox/internal/ssh"
12+
)
13+
14+
// validContainerName matches valid Docker container names.
15+
var validContainerName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`)
16+
17+
// dockerStatsJSON mirrors the JSON output of docker stats --format '{{json .}}'.
18+
type dockerStatsJSON struct {
19+
Name string `json:"Name"`
20+
CPUPerc string `json:"CPUPerc"`
21+
MemPerc string `json:"MemPerc"`
22+
MemUsage string `json:"MemUsage"`
23+
NetIO string `json:"NetIO"`
24+
BlockIO string `json:"BlockIO"`
25+
}
26+
27+
// sshCollector implements Collector using an ssh.Executor.
28+
type sshCollector struct {
29+
exec ssh.Executor
30+
}
31+
32+
// NewCollector creates a Collector that runs commands on remote hosts via SSH.
33+
func NewCollector(exec ssh.Executor) Collector {
34+
return &sshCollector{exec: exec}
35+
}
36+
37+
func (c *sshCollector) CollectWorkspace(ctx context.Context, host, container string) (*WorkspaceMetrics, error) {
38+
if !validContainerName.MatchString(container) {
39+
return nil, fmt.Errorf("invalid container name: %q", container)
40+
}
41+
42+
// Collect docker stats for the single container.
43+
cmd := fmt.Sprintf("docker stats %s --no-stream --format '{{json .}}'", container)
44+
stdout, _, err := c.exec.Run(ctx, host, cmd)
45+
if err != nil {
46+
// Container may be stopped — return zero metrics.
47+
return &WorkspaceMetrics{Container: container, Stopped: true}, nil
48+
}
49+
50+
wm, err := parseDockerStatsJSON(strings.TrimSpace(stdout))
51+
if err != nil {
52+
return nil, fmt.Errorf("parsing stats for container %s: %w", container, err)
53+
}
54+
55+
// Collect disk usage inside the container.
56+
diskCmd := fmt.Sprintf("docker exec %s df -B1 / 2>/dev/null | tail -1", container)
57+
diskOut, _, diskErr := c.exec.Run(ctx, host, diskCmd)
58+
if diskErr == nil {
59+
wm.DiskUsage, wm.DiskTotal = parseDfBytes(diskOut)
60+
}
61+
62+
return wm, nil
63+
}
64+
65+
func (c *sshCollector) CollectServer(ctx context.Context, host string) (*ServerMetrics, error) {
66+
// Single SSH command for all container stats + server info.
67+
cmd := "docker stats --no-stream --format '{{json .}}' 2>/dev/null; " +
68+
"echo '===METRICS_SEP==='; " +
69+
"nproc; " +
70+
"echo '===METRICS_SEP==='; " +
71+
"cat /proc/meminfo; " +
72+
"echo '===METRICS_SEP==='; " +
73+
"df -B1 / | tail -1"
74+
stdout, _, err := c.exec.Run(ctx, host, cmd)
75+
if err != nil {
76+
return nil, fmt.Errorf("collecting server metrics on %s: %w", host, err)
77+
}
78+
79+
parts := strings.Split(stdout, "===METRICS_SEP===")
80+
if len(parts) < 4 {
81+
return nil, fmt.Errorf("unexpected metrics output from %s (got %d parts)", host, len(parts))
82+
}
83+
84+
dockerOut := strings.TrimSpace(parts[0])
85+
cpuOut := strings.TrimSpace(parts[1])
86+
memOut := strings.TrimSpace(parts[2])
87+
diskOut := strings.TrimSpace(parts[3])
88+
89+
sm := &ServerMetrics{}
90+
91+
// Parse container stats.
92+
if dockerOut != "" {
93+
for _, line := range strings.Split(dockerOut, "\n") {
94+
line = strings.TrimSpace(line)
95+
if line == "" {
96+
continue
97+
}
98+
wm, err := parseDockerStatsJSON(line)
99+
if err != nil {
100+
continue
101+
}
102+
sm.Workspaces = append(sm.Workspaces, *wm)
103+
}
104+
}
105+
106+
// Parse CPU count.
107+
cpuCount, err := strconv.Atoi(cpuOut)
108+
if err == nil {
109+
sm.TotalCPUs = cpuCount
110+
}
111+
112+
// Parse memory from /proc/meminfo.
113+
sm.TotalMem, sm.UsedMem = parseMeminfo(memOut)
114+
115+
// Parse server disk.
116+
sm.UsedDisk, sm.TotalDisk = parseDfBytes(diskOut)
117+
118+
return sm, nil
119+
}
120+
121+
// parseDockerStatsJSON parses a single JSON line from docker stats.
122+
func parseDockerStatsJSON(line string) (*WorkspaceMetrics, error) {
123+
var ds dockerStatsJSON
124+
if err := json.Unmarshal([]byte(line), &ds); err != nil {
125+
return nil, fmt.Errorf("parsing docker stats JSON: %w", err)
126+
}
127+
128+
wm := &WorkspaceMetrics{Container: ds.Name}
129+
wm.CPUPercent = parsePercent(ds.CPUPerc)
130+
131+
// Parse memory: "123MiB / 4GiB"
132+
memParts := strings.Split(ds.MemUsage, "/")
133+
if len(memParts) == 2 {
134+
wm.MemUsage = ParseByteSize(strings.TrimSpace(memParts[0]))
135+
wm.MemLimit = ParseByteSize(strings.TrimSpace(memParts[1]))
136+
}
137+
138+
// Parse network: "1.5kB / 2.3MB"
139+
netParts := strings.Split(ds.NetIO, "/")
140+
if len(netParts) == 2 {
141+
wm.NetIn = ParseByteSize(strings.TrimSpace(netParts[0]))
142+
wm.NetOut = ParseByteSize(strings.TrimSpace(netParts[1]))
143+
}
144+
145+
return wm, nil
146+
}
147+
148+
// parsePercent parses "1.23%" → 1.23.
149+
func parsePercent(s string) float64 {
150+
s = strings.TrimSuffix(strings.TrimSpace(s), "%")
151+
v, _ := strconv.ParseFloat(s, 64)
152+
return v
153+
}
154+
155+
// ParseByteSize parses human-readable byte strings like "1.23GiB", "456MiB",
156+
// "789kB", "1.5MB", "100B" into bytes.
157+
func ParseByteSize(s string) uint64 {
158+
s = strings.TrimSpace(s)
159+
if s == "" {
160+
return 0
161+
}
162+
163+
type suffix struct {
164+
name string
165+
mult float64
166+
}
167+
// Order matters: check longer suffixes first.
168+
suffixes := []suffix{
169+
{"GiB", 1024 * 1024 * 1024},
170+
{"MiB", 1024 * 1024},
171+
{"KiB", 1024},
172+
{"GB", 1e9},
173+
{"MB", 1e6},
174+
{"kB", 1e3},
175+
{"B", 1},
176+
}
177+
178+
for _, sf := range suffixes {
179+
if strings.HasSuffix(s, sf.name) {
180+
numStr := strings.TrimSpace(strings.TrimSuffix(s, sf.name))
181+
v, err := strconv.ParseFloat(numStr, 64)
182+
if err != nil {
183+
return 0
184+
}
185+
return uint64(v * sf.mult)
186+
}
187+
}
188+
return 0
189+
}
190+
191+
// parseDfBytes parses the output of `df -B1 / | tail -1`.
192+
// Format: "filesystem total used avail use% mount"
193+
func parseDfBytes(output string) (used, total uint64) {
194+
fields := strings.Fields(strings.TrimSpace(output))
195+
if len(fields) < 4 {
196+
return 0, 0
197+
}
198+
t, _ := strconv.ParseUint(fields[1], 10, 64)
199+
u, _ := strconv.ParseUint(fields[2], 10, 64)
200+
return u, t
201+
}
202+
203+
// parseMeminfo extracts MemTotal and calculates used memory from /proc/meminfo.
204+
func parseMeminfo(output string) (total, used uint64) {
205+
var memTotal, memAvailable uint64
206+
for _, line := range strings.Split(output, "\n") {
207+
fields := strings.Fields(line)
208+
if len(fields) < 2 {
209+
continue
210+
}
211+
val, _ := strconv.ParseUint(fields[1], 10, 64)
212+
valBytes := val * 1024 // /proc/meminfo values are in kB
213+
switch fields[0] {
214+
case "MemTotal:":
215+
memTotal = valBytes
216+
case "MemAvailable:":
217+
memAvailable = valBytes
218+
}
219+
}
220+
if memTotal > 0 && memAvailable <= memTotal {
221+
return memTotal, memTotal - memAvailable
222+
}
223+
return memTotal, 0
224+
}

0 commit comments

Comments
 (0)