Skip to content

Commit 30c9b9e

Browse files
gustavobertoiclaude
andcommitted
feat(dashboard): live CPU/mem stats stream (spec 16)
Implement the reserved dashboard CPU/mem stats feature (spec 16 §CPU/mem, §gotchas [Q-DASH-STATS]). docker: - Add a read-only ContainerStats method to the Client interface + moby impl: a one-shot ContainerStats with IncludePreviousSample so CPU% is computed from the cpu/precpu delta in a single call (no streaming reader held open). Decoded to a small Stats{CPUPercent, MemUsage, MemLimit, MemPercent}. - cpuPercent applies the Docker formula (cpuDelta/systemDelta × onlineCPUs), guarded against counter resets / the first sample. Memory is cache-adjusted (inactive_file subtracted) to match `docker stats`. - Mirror ContainerStats in MockClient with a StatsCalls counter + StatsErr. dashboard: - dashRow carries HasStats/CPUPercent/MemUsage/MemLimit; the table gains "CPU% (engine)" and MEM columns (labeled engine per spec: VM-skewed on Desktop/WSL2), rendered "—" when no sample was taken; detail pane shows both. - collectDashboardData fetches a bounded, read-only sample per visible container each poll and folds it into the matching row (keyed by the row's display name: shared alias / "<project>/<service>"). - --no-stats now actually disables the fetch (zero ContainerStats calls); stats default ON for the cockpit. The --json snapshot includes a "stats" section when enabled and available. Stays strictly read-only: no flock, only SDK reads. Tests: table-driven CPU% computation + full projection, cache-adjusted memory, the mock feeding the collector/model, --no-stats skipping the fetch, and the CPU%/MEM columns rendering via Update/View without a TTY. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 15fe475 commit 30c9b9e

7 files changed

Lines changed: 548 additions & 9 deletions

File tree

‎internal/cli/dashboard.go‎

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,18 +39,22 @@ func newDashboardCmd(g *GlobalOpts) *cobra.Command {
3939
// this reconcile takes the lock only if it prunes — same as status).
4040
_, _ = mgr.Reconcile(cmd.Context())
4141

42+
// Stats default ON for the cockpit; --no-stats disables the per-container
43+
// ContainerStats fetch for low-power machines (spec 16 §gotchas [Q-DASH-STATS]).
44+
stats := !noStats
45+
4246
if !dashboardInteractive(cmd, g) {
43-
return printDashboardSnapshot(cmd, g, mgr)
47+
return printDashboardSnapshot(cmd, g, mgr, stats)
4448
}
4549

4650
ctx := cmd.Context()
47-
fetch := func(c context.Context) dashboardData { return collectDashboardData(c, mgr) }
51+
fetch := func(c context.Context) dashboardData { return collectDashboardData(c, mgr, stats) }
4852
model := newDashboardModel(ctx, fetch, dashboardPoll)
4953
_, err = tea.NewProgram(model, tea.WithContext(ctx)).Run()
5054
return err
5155
},
5256
}
53-
cmd.Flags().BoolVar(&noStats, "no-stats", false, "reserved: disable the CPU/mem stats stream (stats are opt-in in this build)")
57+
cmd.Flags().BoolVar(&noStats, "no-stats", false, "disable the per-container CPU/mem stats fetch (lower overhead)")
5458
return cmd
5559
}
5660

@@ -67,15 +71,22 @@ func dashboardInteractive(cmd *cobra.Command, g *GlobalOpts) bool {
6771
// printDashboardSnapshot is the non-TTY fallback: a one-shot projection reusing
6872
// the same shared-status + per-project views as `status`, plus a redirect to the
6973
// scriptable commands.
70-
func printDashboardSnapshot(cmd *cobra.Command, g *GlobalOpts, mgr *workspace.Manager) error {
74+
func printDashboardSnapshot(cmd *cobra.Command, g *GlobalOpts, mgr *workspace.Manager, stats bool) error {
7175
ctx := cmd.Context()
7276
projects := collectProjectStatus(ctx, mgr)
7377
shared, err := mgr.Status()
7478
if err != nil {
7579
return err
7680
}
7781
if g.JSON {
78-
return writeJSON(cmd, map[string]any{"projects": projects, "shared": shared})
82+
out := map[string]any{"projects": projects, "shared": shared}
83+
// Include live stats when enabled and available, keyed by the same
84+
// service display name the TUI rows use (spec 16: keep the snapshot path
85+
// working, include stats when available).
86+
if st := collectDashboardStats(ctx, mgr.Docker, stats); len(st) > 0 {
87+
out["stats"] = st
88+
}
89+
return writeJSON(cmd, out)
7990
}
8091
if g.Quiet {
8192
return nil
@@ -91,7 +102,7 @@ func printDashboardSnapshot(cmd *cobra.Command, g *GlobalOpts, mgr *workspace.Ma
91102
// collectDashboardData is the read-only collector: it fans in shared-service rows
92103
// (ledger), per-project service rows (live containers + health), and a bounded
93104
// tail of recent log lines into one snapshot. Lock-free.
94-
func collectDashboardData(ctx context.Context, mgr *workspace.Manager) dashboardData {
105+
func collectDashboardData(ctx context.Context, mgr *workspace.Manager, stats bool) dashboardData {
95106
var data dashboardData
96107

97108
shared, err := mgr.Status()
@@ -121,10 +132,64 @@ func collectDashboardData(ctx context.Context, mgr *workspace.Manager) dashboard
121132
}
122133
}
123134

135+
attachDashboardStats(ctx, mgr.Docker, data.Rows, stats)
136+
124137
data.Logs = collectRecentLogs(ctx, mgr.Docker, 8)
125138
return data
126139
}
127140

141+
// attachDashboardStats fetches a live CPU/mem sample per visible container (a
142+
// bounded, read-only fetch — one call each, no streaming reader) and folds it
143+
// into the matching rows. A disabled flag or an unreadable container leaves the
144+
// row's HasStats false, which the table renders as "—". Best-effort: never fatal.
145+
func attachDashboardStats(ctx context.Context, client docker.Client, rows []dashRow, enabled bool) {
146+
byKey := collectDashboardStats(ctx, client, enabled)
147+
if len(byKey) == 0 {
148+
return
149+
}
150+
for i := range rows {
151+
if st, ok := byKey[rows[i].Name]; ok {
152+
rows[i].HasStats = true
153+
rows[i].CPUPercent = st.CPUPercent
154+
rows[i].MemUsage = st.MemUsage
155+
rows[i].MemLimit = st.MemLimit
156+
}
157+
}
158+
}
159+
160+
// collectDashboardStats resolves the workspace's managed containers and fetches
161+
// one resource-usage sample each, keyed by the row's display name (the shared
162+
// DNS alias, or "<project>/<service>"). Returns nil when stats are disabled — the
163+
// --no-stats path so a low-power machine issues zero ContainerStats calls.
164+
func collectDashboardStats(ctx context.Context, client docker.Client, enabled bool) map[string]docker.Stats {
165+
if !enabled {
166+
return nil
167+
}
168+
targets, err := resolveLogTargets(ctx, client, nil)
169+
if err != nil {
170+
return nil
171+
}
172+
out := make(map[string]docker.Stats, len(targets))
173+
for _, t := range targets {
174+
st, err := client.ContainerStats(ctx, t.ID)
175+
if err != nil {
176+
continue // unreadable container: leave the row without stats
177+
}
178+
out[dashStatsKey(t)] = st
179+
}
180+
return out
181+
}
182+
183+
// dashStatsKey maps a log target onto the dashboard row's display name so stats
184+
// join to the right row: a shared service by its DNS alias, a project service by
185+
// "<project>/<service>". logTarget.Project is empty for shared services.
186+
func dashStatsKey(t logTarget) string {
187+
if t.Project != "" {
188+
return t.Project + "/" + t.Service
189+
}
190+
return t.Service
191+
}
192+
128193
// dashEngine renders a shared row's "engine version" detail.
129194
func dashEngine(s workspace.SharedStatus) string {
130195
if s.Major == "" || s.Major == "default" {

‎internal/cli/dashboard_model.go‎

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ type dashRow struct {
2121
Projects []string // shared: the referencing projects
2222
Engine string // shared: engine + version
2323
URL string // project: https://<svc>.<proj>.localhost
24+
25+
// Live resource usage (spec 16 CPU/mem columns), populated per visible
26+
// container on each poll unless --no-stats disabled the fetch. HasStats
27+
// distinguishes "0.0%" (a real, idle sample) from "no sample taken".
28+
HasStats bool
29+
CPUPercent float64 // engine-computed CPU% (VM-skewed on Desktop/WSL2)
30+
MemUsage uint64 // cache-adjusted bytes
31+
MemLimit uint64 // bytes; 0 when unlimited/unknown
2432
}
2533

2634
// dashLog is one recent, service-tagged log line for the bottom pane.
@@ -86,8 +94,10 @@ func newDashboardModel(ctx context.Context, fetch func(context.Context) dashboar
8694
t := table.New(
8795
table.WithColumns([]table.Column{
8896
{Title: "SERVICE", Width: 22},
89-
{Title: "STATE", Width: 10},
90-
{Title: "HEALTH", Width: 10},
97+
{Title: "STATE", Width: 9},
98+
{Title: "HEALTH", Width: 9},
99+
{Title: "CPU% (engine)", Width: 13},
100+
{Title: "MEM", Width: 11},
91101
{Title: "REFS", Width: 18},
92102
}),
93103
table.WithFocused(true),
@@ -219,6 +229,9 @@ func (m dashboardModel) selectedDetail() string {
219229
state += " (" + r.Health + ")"
220230
}
221231
parts = append(parts, "state: "+state)
232+
if r.HasStats {
233+
parts = append(parts, fmt.Sprintf("cpu: %.1f%%", r.CPUPercent), "mem: "+dashMem(r))
234+
}
222235
return strings.Join(parts, " ")
223236
}
224237

@@ -245,11 +258,47 @@ func dashTableRows(rows []dashRow) []table.Row {
245258
case r.URL != "":
246259
refs = r.URL
247260
}
248-
out = append(out, table.Row{r.Name, r.State, r.Health, refs})
261+
out = append(out, table.Row{r.Name, r.State, r.Health, dashCPU(r), dashMem(r), refs})
249262
}
250263
return out
251264
}
252265

266+
// dashCPU renders a row's CPU% cell — "—" when no sample was taken (stats off or
267+
// an unreadable container), else one decimal place.
268+
func dashCPU(r dashRow) string {
269+
if !r.HasStats {
270+
return "—"
271+
}
272+
return fmt.Sprintf("%.1f%%", r.CPUPercent)
273+
}
274+
275+
// dashMem renders a row's memory cell as "usage/limit" (or just usage when the
276+
// Engine reports no limit); "—" when no sample was taken.
277+
func dashMem(r dashRow) string {
278+
if !r.HasStats {
279+
return "—"
280+
}
281+
if r.MemLimit == 0 {
282+
return formatBytes(r.MemUsage)
283+
}
284+
return formatBytes(r.MemUsage) + "/" + formatBytes(r.MemLimit)
285+
}
286+
287+
// formatBytes renders a byte count in binary units with no decimals below 1 KiB
288+
// and one decimal above, for a compact, stable dashboard cell.
289+
func formatBytes(b uint64) string {
290+
const unit = 1024
291+
if b < unit {
292+
return fmt.Sprintf("%dB", b)
293+
}
294+
div, exp := uint64(unit), 0
295+
for n := b / unit; n >= unit; n /= unit {
296+
div *= unit
297+
exp++
298+
}
299+
return fmt.Sprintf("%.1f%cB", float64(b)/float64(div), "KMGTPE"[exp])
300+
}
301+
253302
func clampLogs(logs []dashLog) []dashLog {
254303
if len(logs) <= dashLogCap {
255304
return logs
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
8+
tea "charm.land/bubbletea/v2"
9+
10+
"github.com/open-source-cloud/devstack/internal/docker"
11+
"github.com/open-source-cloud/devstack/internal/generate"
12+
)
13+
14+
// statsMockClient seeds a shared + a project container plus their canned stats
15+
// so the collector's stats fetch is exercised without a daemon.
16+
func statsMockClient() *docker.MockClient {
17+
return &docker.MockClient{
18+
Containers: []docker.Container{
19+
{ID: "pg1", Name: "devstack-shared-postgres-1", State: "running", Labels: map[string]string{
20+
generate.LabelManaged: "true", generate.LabelShared: "postgres",
21+
}},
22+
{ID: "web1", Name: "devstack-shop-web-1", State: "running", Labels: map[string]string{
23+
generate.LabelManaged: "true", generate.LabelProject: "shop", generate.LabelService: "web",
24+
}},
25+
},
26+
Stats: map[string]docker.Stats{
27+
"pg1": {CPUPercent: 3.1, MemUsage: 412 * 1024 * 1024, MemLimit: 2 * 1024 * 1024 * 1024, MemPercent: 20},
28+
"web1": {CPUPercent: 12.0, MemUsage: 190 * 1024 * 1024, MemLimit: 0},
29+
},
30+
}
31+
}
32+
33+
// TestCollectDashboardStats verifies the collector keys each container's sample
34+
// by the dashboard row's display name (shared alias / "<project>/<service>").
35+
func TestCollectDashboardStats(t *testing.T) {
36+
m := statsMockClient()
37+
got := collectDashboardStats(context.Background(), m, true)
38+
if len(got) != 2 {
39+
t.Fatalf("stats map = %d entries, want 2: %+v", len(got), got)
40+
}
41+
if st, ok := got["shared-postgres"]; !ok || st.CPUPercent != 3.1 {
42+
t.Errorf("shared-postgres stats = %+v (ok=%v)", st, ok)
43+
}
44+
if st, ok := got["shop/web"]; !ok || st.MemUsage != 190*1024*1024 {
45+
t.Errorf("shop/web stats = %+v (ok=%v)", st, ok)
46+
}
47+
if m.StatsCalls != 2 {
48+
t.Fatalf("StatsCalls = %d, want 2", m.StatsCalls)
49+
}
50+
}
51+
52+
// TestCollectDashboardStatsDisabled asserts --no-stats (enabled=false) issues no
53+
// ContainerStats calls at all.
54+
func TestCollectDashboardStatsDisabled(t *testing.T) {
55+
m := statsMockClient()
56+
if got := collectDashboardStats(context.Background(), m, false); got != nil {
57+
t.Fatalf("disabled stats should be nil, got %+v", got)
58+
}
59+
if m.StatsCalls != 0 {
60+
t.Fatalf("StatsCalls = %d, want 0 (fetch must be skipped)", m.StatsCalls)
61+
}
62+
}
63+
64+
// TestAttachDashboardStats folds seeded stats into the matching rows and leaves
65+
// unmatched rows without stats.
66+
func TestAttachDashboardStats(t *testing.T) {
67+
m := statsMockClient()
68+
rows := []dashRow{
69+
{Name: "shared-postgres", Kind: "shared"},
70+
{Name: "shop/web", Kind: "project"},
71+
{Name: "shop/api", Kind: "project"}, // no container → stays statless
72+
}
73+
attachDashboardStats(context.Background(), m, rows, true)
74+
75+
if !rows[0].HasStats || rows[0].CPUPercent != 3.1 {
76+
t.Errorf("shared-postgres row = %+v", rows[0])
77+
}
78+
if !rows[1].HasStats || rows[1].MemUsage != 190*1024*1024 {
79+
t.Errorf("shop/web row = %+v", rows[1])
80+
}
81+
if rows[2].HasStats {
82+
t.Errorf("shop/api should have no stats: %+v", rows[2])
83+
}
84+
}
85+
86+
// TestAttachDashboardStatsDisabled asserts disabled stats leave every row untouched.
87+
func TestAttachDashboardStats_Disabled(t *testing.T) {
88+
m := statsMockClient()
89+
rows := []dashRow{{Name: "shared-postgres", Kind: "shared"}}
90+
attachDashboardStats(context.Background(), m, rows, false)
91+
if rows[0].HasStats {
92+
t.Fatalf("row should have no stats when disabled: %+v", rows[0])
93+
}
94+
if m.StatsCalls != 0 {
95+
t.Fatalf("StatsCalls = %d, want 0", m.StatsCalls)
96+
}
97+
}
98+
99+
// TestDashboardStatsInModel drives the Bubble Tea Update/View with stats-bearing
100+
// rows and asserts the CPU%/MEM columns render in the table and detail pane.
101+
func TestDashboardStatsInModel(t *testing.T) {
102+
data := dashboardData{Rows: []dashRow{
103+
{Name: "shared-postgres", Kind: "shared", State: "running", Refs: 2,
104+
HasStats: true, CPUPercent: 3.1, MemUsage: 412 * 1024 * 1024, MemLimit: 2 * 1024 * 1024 * 1024},
105+
}}
106+
m := newDashboardModel(context.Background(), func(context.Context) dashboardData { return data }, 0)
107+
updated, _ := m.Update(dashDataMsg(data))
108+
dm := updated.(dashboardModel)
109+
110+
tr := dm.table.Rows()
111+
if len(tr) != 1 {
112+
t.Fatalf("table rows = %d, want 1", len(tr))
113+
}
114+
// Columns: SERVICE, STATE, HEALTH, CPU%, MEM, REFS.
115+
if got := tr[0][3]; got != "3.1%" {
116+
t.Errorf("CPU%% cell = %q, want 3.1%%", got)
117+
}
118+
if got := tr[0][4]; !strings.Contains(got, "412.0MB") {
119+
t.Errorf("MEM cell = %q, want it to contain 412.0MB", got)
120+
}
121+
122+
detail := dm.selectedDetail()
123+
if !strings.Contains(detail, "cpu: 3.1%") || !strings.Contains(detail, "mem:") {
124+
t.Errorf("detail missing stats: %q", detail)
125+
}
126+
127+
dm2, _ := dm.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
128+
view := dm2.(dashboardModel).View()
129+
if !strings.Contains(view.Content, "CPU% (engine)") {
130+
t.Errorf("view missing CPU%% column header")
131+
}
132+
}
133+
134+
// TestDashboardNoStatsRendersDash verifies a statless row shows the "—" placeholder.
135+
func TestDashboardNoStatsRendersDash(t *testing.T) {
136+
r := dashRow{Name: "shop/api", Kind: "project", State: "running", HasStats: false}
137+
if got := dashCPU(r); got != "—" {
138+
t.Errorf("dashCPU = %q, want —", got)
139+
}
140+
if got := dashMem(r); got != "—" {
141+
t.Errorf("dashMem = %q, want —", got)
142+
}
143+
}
144+
145+
// TestFormatBytes covers the byte humanizer used by the MEM column.
146+
func TestFormatBytes(t *testing.T) {
147+
tests := []struct {
148+
in uint64
149+
want string
150+
}{
151+
{512, "512B"},
152+
{1024, "1.0KB"},
153+
{412 * 1024 * 1024, "412.0MB"},
154+
{2 * 1024 * 1024 * 1024, "2.0GB"},
155+
}
156+
for _, tt := range tests {
157+
if got := formatBytes(tt.in); got != tt.want {
158+
t.Errorf("formatBytes(%d) = %q, want %q", tt.in, got, tt.want)
159+
}
160+
}
161+
}

‎internal/docker/docker.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ type Client interface {
6262
// mode, ctx cancellation, or the container stopping while following. Non-TTY
6363
// containers are demuxed through stdcopy; TTY containers stream raw. Read-only.
6464
ContainerLogStream(ctx context.Context, id string, opts LogOptions) (<-chan LogLine, error)
65+
// ContainerStats retrieves a single, decoded resource-usage sample for one
66+
// container (spec 16 CPU/mem columns): CPU% computed from the cpu/precpu
67+
// delta plus cache-adjusted memory usage/limit. It opens no streaming reader
68+
// (the daemon includes a previous sample so one call yields a CPU delta), so
69+
// the dashboard can fetch it per visible container on each poll. Read-only.
70+
ContainerStats(ctx context.Context, id string) (Stats, error)
6571
// Close releases the underlying connection.
6672
Close() error
6773
}

0 commit comments

Comments
 (0)