Skip to content

Commit 6557292

Browse files
feat: add CPU and memory metrics display for services
- Show CPU% and RAM usage for each running service in status bar - Display totals (Σ) in "All" tab - Add GetProcessMetrics using ps command (macOS/Linux compatible) - Include metrics in daemon protocol and MCP status output - Update golangci-lint config to v2 format Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 1d59cf6 commit 6557292

File tree

10 files changed

+207
-36
lines changed

10 files changed

+207
-36
lines changed

.golangci.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1+
version: "2"
2+
13
run:
24
timeout: 5m
35
modules-download-mode: readonly
46

57
linters:
68
enable:
79
- errcheck
8-
- gosimple
910
- govet
1011
- ineffassign
1112
- staticcheck
1213
- unused
14+
15+
formatters:
16+
enable:
1317
- gofmt
1418
- goimports
1519

@@ -23,11 +27,13 @@ linters-settings:
2327
disable:
2428
- fieldalignment
2529

30+
formatters-settings:
2631
gofmt:
2732
simplify: true
2833

2934
goimports:
30-
local-prefixes: devir
35+
local-prefixes:
36+
- devir
3137

3238
issues:
3339
exclude-use-default: false

internal/daemon/daemon.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,9 +362,16 @@ func (d *Daemon) handleStatus(c *clientConn) {
362362
message = ds.Message
363363
}
364364

365+
// Capture PID for metrics collection
366+
var pid int
367+
running := state.Running
368+
if running && state.Cmd != nil && state.Cmd.Process != nil {
369+
pid = state.Cmd.Process.Pid
370+
}
371+
365372
s := ServiceStatus{
366373
Name: name,
367-
Running: state.Running,
374+
Running: running,
368375
Port: state.Service.Port,
369376
Color: color,
370377
Icon: icon,
@@ -381,6 +388,15 @@ func (d *Daemon) handleStatus(c *clientConn) {
381388
s.NextRun = state.NextRun.Format(time.RFC3339)
382389
}
383390
state.Mu.Unlock()
391+
392+
// Collect metrics after releasing lock
393+
if pid > 0 {
394+
if metrics, err := runner.GetProcessMetrics(pid); err == nil {
395+
s.CPU = metrics.CPU
396+
s.Memory = metrics.Memory
397+
}
398+
}
399+
384400
statuses = append(statuses, s)
385401
}
386402
}

internal/daemon/protocol.go

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,16 @@ const (
1818
MsgKillPorts = "kill_ports"
1919

2020
// Daemon → Client
21-
MsgStarted = "started"
22-
MsgStopped = "stopped"
23-
MsgRestarted = "restarted"
24-
MsgStatusResponse = "status_response"
25-
MsgLogsResponse = "logs_response"
26-
MsgLogsCleared = "logs_cleared"
27-
MsgPortsResponse = "ports_response"
28-
MsgKillResponse = "kill_response"
29-
MsgLogEntry = "log_entry" // Broadcast to all clients
30-
MsgError = "error"
21+
MsgStarted = "started"
22+
MsgStopped = "stopped"
23+
MsgRestarted = "restarted"
24+
MsgStatusResponse = "status_response"
25+
MsgLogsResponse = "logs_response"
26+
MsgLogsCleared = "logs_cleared"
27+
MsgPortsResponse = "ports_response"
28+
MsgKillResponse = "kill_response"
29+
MsgLogEntry = "log_entry" // Broadcast to all clients
30+
MsgError = "error"
3131
)
3232

3333
// Message is the wire format for daemon communication
@@ -98,18 +98,20 @@ type RestartedResponse struct {
9898

9999
// ServiceStatus represents a service's current state
100100
type ServiceStatus struct {
101-
Name string `json:"name"`
102-
Running bool `json:"running"`
103-
Port int `json:"port"`
104-
Color string `json:"color"`
105-
Icon string `json:"icon"` // custom icon/emoji
106-
Type string `json:"type"` // service, oneshot, interval, http
107-
Status string `json:"status"` // running, completed, failed, waiting, stopped
108-
Message string `json:"message"` // dynamic status message
109-
LastRun string `json:"lastRun"` // ISO timestamp
110-
NextRun string `json:"nextRun"` // ISO timestamp (for interval)
111-
ExitCode int `json:"exitCode"` // last exit code
112-
RunCount int `json:"runCount"` // number of runs
101+
Name string `json:"name"`
102+
Running bool `json:"running"`
103+
Port int `json:"port"`
104+
Color string `json:"color"`
105+
Icon string `json:"icon"` // custom icon/emoji
106+
Type string `json:"type"` // service, oneshot, interval, http
107+
Status string `json:"status"` // running, completed, failed, waiting, stopped
108+
Message string `json:"message"` // dynamic status message
109+
LastRun string `json:"lastRun"` // ISO timestamp
110+
NextRun string `json:"nextRun"` // ISO timestamp (for interval)
111+
ExitCode int `json:"exitCode"` // last exit code
112+
RunCount int `json:"runCount"` // number of runs
113+
CPU float64 `json:"cpu"` // CPU percentage
114+
Memory uint64 `json:"memory"` // Memory in bytes (RSS)
113115
}
114116

115117
// StatusResponse contains all service statuses

internal/mcp/mcp.go

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -139,15 +139,17 @@ type StopOutput struct {
139139
}
140140

141141
type ServiceStatus struct {
142-
Name string `json:"name"`
143-
Running bool `json:"running"`
144-
Port int `json:"port"`
145-
Type string `json:"type"` // service, oneshot, interval, http
146-
Status string `json:"status"` // running, completed, failed, waiting, stopped
147-
LastRun string `json:"lastRun"` // ISO timestamp
148-
NextRun string `json:"nextRun"` // ISO timestamp (for interval)
149-
ExitCode int `json:"exitCode"` // last exit code
150-
RunCount int `json:"runCount"` // number of runs
142+
Name string `json:"name"`
143+
Running bool `json:"running"`
144+
Port int `json:"port"`
145+
Type string `json:"type"` // service, oneshot, interval, http
146+
Status string `json:"status"` // running, completed, failed, waiting, stopped
147+
LastRun string `json:"lastRun"` // ISO timestamp
148+
NextRun string `json:"nextRun"` // ISO timestamp (for interval)
149+
ExitCode int `json:"exitCode"` // last exit code
150+
RunCount int `json:"runCount"` // number of runs
151+
CPU float64 `json:"cpu"` // CPU percentage
152+
Memory uint64 `json:"memory"` // Memory in bytes (RSS)
151153
}
152154

153155
type StatusOutput struct {
@@ -270,6 +272,8 @@ func (m *Server) handleStatus(ctx context.Context, req *mcp.CallToolRequest, inp
270272
NextRun: s.NextRun,
271273
ExitCode: s.ExitCode,
272274
RunCount: s.RunCount,
275+
CPU: s.CPU,
276+
Memory: s.Memory,
273277
})
274278
}
275279

internal/runner/process_unix.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package runner
55
import (
66
"fmt"
77
"os/exec"
8+
"strconv"
89
"strings"
910
"syscall"
1011
)
@@ -56,3 +57,58 @@ func IsPortInUse(port int) bool {
5657
pid, _ := GetPortPID(port)
5758
return pid > 0
5859
}
60+
61+
// ProcessMetrics holds CPU and memory metrics for a process
62+
type ProcessMetrics struct {
63+
CPU float64 // CPU percentage
64+
Memory uint64 // Memory in bytes (RSS)
65+
}
66+
67+
// GetProcessMetrics gets CPU and memory usage for a process and its children
68+
// Uses ps command which works on both macOS and Linux
69+
func GetProcessMetrics(pid int) (ProcessMetrics, error) {
70+
if pid <= 0 {
71+
return ProcessMetrics{}, nil
72+
}
73+
74+
// Get all child PIDs using pgrep
75+
pids := []int{pid}
76+
pgrepCmd := exec.Command("pgrep", "-P", strconv.Itoa(pid))
77+
if output, err := pgrepCmd.Output(); err == nil {
78+
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
79+
if line != "" {
80+
if childPid, err := strconv.Atoi(line); err == nil {
81+
pids = append(pids, childPid)
82+
}
83+
}
84+
}
85+
}
86+
87+
var totalCPU float64
88+
var totalMemory uint64
89+
90+
for _, p := range pids {
91+
// ps -o %cpu=,rss= -p <pid>
92+
// %cpu = CPU percentage, rss = resident set size in KB
93+
cmd := exec.Command("ps", "-o", "%cpu=,rss=", "-p", strconv.Itoa(p))
94+
output, err := cmd.Output()
95+
if err != nil {
96+
continue // Process might have exited
97+
}
98+
99+
fields := strings.Fields(strings.TrimSpace(string(output)))
100+
if len(fields) >= 2 {
101+
if cpu, err := strconv.ParseFloat(fields[0], 64); err == nil {
102+
totalCPU += cpu
103+
}
104+
if rssKB, err := strconv.ParseUint(fields[1], 10, 64); err == nil {
105+
totalMemory += rssKB * 1024 // Convert KB to bytes
106+
}
107+
}
108+
}
109+
110+
return ProcessMetrics{
111+
CPU: totalCPU,
112+
Memory: totalMemory,
113+
}, nil
114+
}

internal/runner/process_windows.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,16 @@ func IsPortInUse(port int) bool {
6868
pid, _ := GetPortPID(port)
6969
return pid > 0
7070
}
71+
72+
// ProcessMetrics holds CPU and memory metrics for a process
73+
type ProcessMetrics struct {
74+
CPU float64 // CPU percentage
75+
Memory uint64 // Memory in bytes (RSS)
76+
}
77+
78+
// GetProcessMetrics gets CPU and memory usage for a process
79+
// TODO: Implement Windows-specific metrics using Windows API
80+
func GetProcessMetrics(pid int) (ProcessMetrics, error) {
81+
// Windows implementation not yet available
82+
return ProcessMetrics{}, nil
83+
}

internal/runner/runner.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,16 @@ func (r *Runner) GetServices() map[string]types.ServiceInfo {
769769
message = ds.Message
770770
}
771771

772+
// Collect CPU and memory metrics for running services
773+
var cpu float64
774+
var memory uint64
775+
if state.Running && state.Cmd != nil && state.Cmd.Process != nil {
776+
if metrics, err := GetProcessMetrics(state.Cmd.Process.Pid); err == nil {
777+
cpu = metrics.CPU
778+
memory = metrics.Memory
779+
}
780+
}
781+
772782
result[name] = types.ServiceInfo{
773783
Name: name,
774784
Color: color,
@@ -782,6 +792,8 @@ func (r *Runner) GetServices() map[string]types.ServiceInfo {
782792
ExitCode: state.ExitCode,
783793
RunCount: state.RunCount,
784794
Message: message,
795+
CPU: cpu,
796+
Memory: memory,
785797
}
786798
state.Mu.Unlock()
787799
}

internal/tui/model.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,28 @@ func (m *Model) GetFullServiceStatus(name string) (running bool, port int, color
388388
return false, 0, "white", "", "service", "stopped"
389389
}
390390

391+
// GetServiceMetrics returns CPU and memory metrics for a service
392+
func (m *Model) GetServiceMetrics(name string) (cpu float64, memory uint64) {
393+
if m.clientMode {
394+
if s, ok := m.statuses[name]; ok {
395+
return s.CPU, s.Memory
396+
}
397+
return 0, 0
398+
}
399+
400+
// Legacy mode - get metrics directly from the process
401+
if state, ok := m.Runner.Services[name]; ok {
402+
state.Mu.Lock()
403+
defer state.Mu.Unlock()
404+
if state.Running && state.Cmd != nil && state.Cmd.Process != nil {
405+
if metrics, err := runner.GetProcessMetrics(state.Cmd.Process.Pid); err == nil {
406+
return metrics.CPU, metrics.Memory
407+
}
408+
}
409+
}
410+
return 0, 0
411+
}
412+
391413
func containsIgnoreCase(s, substr string) bool {
392414
return len(s) >= len(substr) &&
393415
(s == substr ||

internal/tui/view.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,11 @@ func (m Model) renderLogs() string {
119119

120120
func (m Model) renderStatusBar() string {
121121
var parts []string
122+
var totalCPU float64
123+
var totalMemory uint64
122124

123125
for _, name := range m.services {
124-
_, port, color, icon, svcType, status := m.GetFullServiceStatus(name)
126+
running, port, color, icon, svcType, status := m.GetFullServiceStatus(name)
125127

126128
statusStr := getStyledStatus(status)
127129

@@ -147,18 +149,54 @@ func (m Model) renderStatusBar() string {
147149
}
148150
}
149151

150-
parts = append(parts, fmt.Sprintf("%s %s%s", statusStr, serviceStyle.Render(displayName), portStr))
152+
// Get metrics for running services
153+
metricsStr := ""
154+
if running {
155+
cpu, memory := m.GetServiceMetrics(name)
156+
totalCPU += cpu
157+
totalMemory += memory
158+
if cpu > 0 || memory > 0 {
159+
metricsStr = fmt.Sprintf(" %s %s", formatCPU(cpu), formatMemory(memory))
160+
}
161+
}
162+
163+
parts = append(parts, fmt.Sprintf("%s %s%s%s", statusStr, serviceStyle.Render(displayName), portStr, metricsStr))
151164
}
152165

153166
statusContent := strings.Join(parts, " │ ")
154167

168+
// Add totals when on "All" tab and there are running services
169+
if m.activeTab == -1 && (totalCPU > 0 || totalMemory > 0) {
170+
statusContent = fmt.Sprintf("Σ %s %s │ ", formatCPU(totalCPU), formatMemory(totalMemory)) + statusContent
171+
}
172+
155173
if m.searchQuery != "" {
156174
statusContent += fmt.Sprintf(" │ Filter: %s", m.searchQuery)
157175
}
158176

159177
return StatusBarStyle.Width(m.width).Render(statusContent)
160178
}
161179

180+
// formatCPU formats CPU percentage
181+
func formatCPU(cpu float64) string {
182+
if cpu < 0.1 {
183+
return "0%"
184+
}
185+
return fmt.Sprintf("%.0f%%", cpu)
186+
}
187+
188+
// formatMemory formats memory in human-readable format
189+
func formatMemory(bytes uint64) string {
190+
if bytes == 0 {
191+
return "0MB"
192+
}
193+
mb := float64(bytes) / (1024 * 1024)
194+
if mb >= 1024 {
195+
return fmt.Sprintf("%.1fGB", mb/1024)
196+
}
197+
return fmt.Sprintf("%.0fMB", mb)
198+
}
199+
162200
// getStyledStatus returns styled status symbol
163201
func getStyledStatus(status string) string {
164202
switch status {

internal/types/types.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,6 @@ type ServiceInfo struct {
5151
ExitCode int // last exit code
5252
RunCount int // number of runs (for interval)
5353
Message string // dynamic status message from .devir-status
54+
CPU float64 // CPU percentage (0-100 per core, can exceed 100 with multiple cores)
55+
Memory uint64 // Memory in bytes (RSS)
5456
}

0 commit comments

Comments
 (0)