Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 71 additions & 6 deletions internal/cli/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,22 @@ func newDashboardCmd(g *GlobalOpts) *cobra.Command {
// this reconcile takes the lock only if it prunes — same as status).
_, _ = mgr.Reconcile(cmd.Context())

// Stats default ON for the cockpit; --no-stats disables the per-container
// ContainerStats fetch for low-power machines (spec 16 §gotchas [Q-DASH-STATS]).
stats := !noStats

if !dashboardInteractive(cmd, g) {
return printDashboardSnapshot(cmd, g, mgr)
return printDashboardSnapshot(cmd, g, mgr, stats)
}

ctx := cmd.Context()
fetch := func(c context.Context) dashboardData { return collectDashboardData(c, mgr) }
fetch := func(c context.Context) dashboardData { return collectDashboardData(c, mgr, stats) }
model := newDashboardModel(ctx, fetch, dashboardPoll)
_, err = tea.NewProgram(model, tea.WithContext(ctx)).Run()
return err
},
}
cmd.Flags().BoolVar(&noStats, "no-stats", false, "reserved: disable the CPU/mem stats stream (stats are opt-in in this build)")
cmd.Flags().BoolVar(&noStats, "no-stats", false, "disable the per-container CPU/mem stats fetch (lower overhead)")
return cmd
}

Expand All @@ -67,15 +71,22 @@ func dashboardInteractive(cmd *cobra.Command, g *GlobalOpts) bool {
// printDashboardSnapshot is the non-TTY fallback: a one-shot projection reusing
// the same shared-status + per-project views as `status`, plus a redirect to the
// scriptable commands.
func printDashboardSnapshot(cmd *cobra.Command, g *GlobalOpts, mgr *workspace.Manager) error {
func printDashboardSnapshot(cmd *cobra.Command, g *GlobalOpts, mgr *workspace.Manager, stats bool) error {
ctx := cmd.Context()
projects := collectProjectStatus(ctx, mgr)
shared, err := mgr.Status()
if err != nil {
return err
}
if g.JSON {
return writeJSON(cmd, map[string]any{"projects": projects, "shared": shared})
out := map[string]any{"projects": projects, "shared": shared}
// Include live stats when enabled and available, keyed by the same
// service display name the TUI rows use (spec 16: keep the snapshot path
// working, include stats when available).
if st := collectDashboardStats(ctx, mgr.Docker, stats); len(st) > 0 {
out["stats"] = st
}
return writeJSON(cmd, out)
}
if g.Quiet {
return nil
Expand All @@ -91,7 +102,7 @@ func printDashboardSnapshot(cmd *cobra.Command, g *GlobalOpts, mgr *workspace.Ma
// collectDashboardData is the read-only collector: it fans in shared-service rows
// (ledger), per-project service rows (live containers + health), and a bounded
// tail of recent log lines into one snapshot. Lock-free.
func collectDashboardData(ctx context.Context, mgr *workspace.Manager) dashboardData {
func collectDashboardData(ctx context.Context, mgr *workspace.Manager, stats bool) dashboardData {
var data dashboardData

shared, err := mgr.Status()
Expand Down Expand Up @@ -121,10 +132,64 @@ func collectDashboardData(ctx context.Context, mgr *workspace.Manager) dashboard
}
}

attachDashboardStats(ctx, mgr.Docker, data.Rows, stats)

data.Logs = collectRecentLogs(ctx, mgr.Docker, 8)
return data
}

// attachDashboardStats fetches a live CPU/mem sample per visible container (a
// bounded, read-only fetch — one call each, no streaming reader) and folds it
// into the matching rows. A disabled flag or an unreadable container leaves the
// row's HasStats false, which the table renders as "—". Best-effort: never fatal.
func attachDashboardStats(ctx context.Context, client docker.Client, rows []dashRow, enabled bool) {
byKey := collectDashboardStats(ctx, client, enabled)
if len(byKey) == 0 {
return
}
for i := range rows {
if st, ok := byKey[rows[i].Name]; ok {
rows[i].HasStats = true
rows[i].CPUPercent = st.CPUPercent
rows[i].MemUsage = st.MemUsage
rows[i].MemLimit = st.MemLimit
}
}
}

// collectDashboardStats resolves the workspace's managed containers and fetches
// one resource-usage sample each, keyed by the row's display name (the shared
// DNS alias, or "<project>/<service>"). Returns nil when stats are disabled — the
// --no-stats path so a low-power machine issues zero ContainerStats calls.
func collectDashboardStats(ctx context.Context, client docker.Client, enabled bool) map[string]docker.Stats {
if !enabled {
return nil
}
targets, err := resolveLogTargets(ctx, client, nil)
if err != nil {
return nil
}
out := make(map[string]docker.Stats, len(targets))
for _, t := range targets {
st, err := client.ContainerStats(ctx, t.ID)
if err != nil {
continue // unreadable container: leave the row without stats
}
out[dashStatsKey(t)] = st
}
return out
}

// dashStatsKey maps a log target onto the dashboard row's display name so stats
// join to the right row: a shared service by its DNS alias, a project service by
// "<project>/<service>". logTarget.Project is empty for shared services.
func dashStatsKey(t logTarget) string {
if t.Project != "" {
return t.Project + "/" + t.Service
}
return t.Service
}

// dashEngine renders a shared row's "engine version" detail.
func dashEngine(s workspace.SharedStatus) string {
if s.Major == "" || s.Major == "default" {
Expand Down
55 changes: 52 additions & 3 deletions internal/cli/dashboard_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ type dashRow struct {
Projects []string // shared: the referencing projects
Engine string // shared: engine + version
URL string // project: https://<svc>.<proj>.localhost

// Live resource usage (spec 16 CPU/mem columns), populated per visible
// container on each poll unless --no-stats disabled the fetch. HasStats
// distinguishes "0.0%" (a real, idle sample) from "no sample taken".
HasStats bool
CPUPercent float64 // engine-computed CPU% (VM-skewed on Desktop/WSL2)
MemUsage uint64 // cache-adjusted bytes
MemLimit uint64 // bytes; 0 when unlimited/unknown
}

// dashLog is one recent, service-tagged log line for the bottom pane.
Expand Down Expand Up @@ -86,8 +94,10 @@ func newDashboardModel(ctx context.Context, fetch func(context.Context) dashboar
t := table.New(
table.WithColumns([]table.Column{
{Title: "SERVICE", Width: 22},
{Title: "STATE", Width: 10},
{Title: "HEALTH", Width: 10},
{Title: "STATE", Width: 9},
{Title: "HEALTH", Width: 9},
{Title: "CPU% (engine)", Width: 13},
{Title: "MEM", Width: 11},
{Title: "REFS", Width: 18},
}),
table.WithFocused(true),
Expand Down Expand Up @@ -219,6 +229,9 @@ func (m dashboardModel) selectedDetail() string {
state += " (" + r.Health + ")"
}
parts = append(parts, "state: "+state)
if r.HasStats {
parts = append(parts, fmt.Sprintf("cpu: %.1f%%", r.CPUPercent), "mem: "+dashMem(r))
}
return strings.Join(parts, " ")
}

Expand All @@ -245,11 +258,47 @@ func dashTableRows(rows []dashRow) []table.Row {
case r.URL != "":
refs = r.URL
}
out = append(out, table.Row{r.Name, r.State, r.Health, refs})
out = append(out, table.Row{r.Name, r.State, r.Health, dashCPU(r), dashMem(r), refs})
}
return out
}

// dashCPU renders a row's CPU% cell — "—" when no sample was taken (stats off or
// an unreadable container), else one decimal place.
func dashCPU(r dashRow) string {
if !r.HasStats {
return "—"
}
return fmt.Sprintf("%.1f%%", r.CPUPercent)
}

// dashMem renders a row's memory cell as "usage/limit" (or just usage when the
// Engine reports no limit); "—" when no sample was taken.
func dashMem(r dashRow) string {
if !r.HasStats {
return "—"
}
if r.MemLimit == 0 {
return formatBytes(r.MemUsage)
}
return formatBytes(r.MemUsage) + "/" + formatBytes(r.MemLimit)
}

// formatBytes renders a byte count in binary units with no decimals below 1 KiB
// and one decimal above, for a compact, stable dashboard cell.
func formatBytes(b uint64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%dB", b)
}
div, exp := uint64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f%cB", float64(b)/float64(div), "KMGTPE"[exp])
}

func clampLogs(logs []dashLog) []dashLog {
if len(logs) <= dashLogCap {
return logs
Expand Down
161 changes: 161 additions & 0 deletions internal/cli/dashboard_stats_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package cli

import (
"context"
"strings"
"testing"

tea "charm.land/bubbletea/v2"

"github.com/open-source-cloud/devstack/internal/docker"
"github.com/open-source-cloud/devstack/internal/generate"
)

// statsMockClient seeds a shared + a project container plus their canned stats
// so the collector's stats fetch is exercised without a daemon.
func statsMockClient() *docker.MockClient {
return &docker.MockClient{
Containers: []docker.Container{
{ID: "pg1", Name: "devstack-shared-postgres-1", State: "running", Labels: map[string]string{
generate.LabelManaged: "true", generate.LabelShared: "postgres",
}},
{ID: "web1", Name: "devstack-shop-web-1", State: "running", Labels: map[string]string{
generate.LabelManaged: "true", generate.LabelProject: "shop", generate.LabelService: "web",
}},
},
Stats: map[string]docker.Stats{
"pg1": {CPUPercent: 3.1, MemUsage: 412 * 1024 * 1024, MemLimit: 2 * 1024 * 1024 * 1024, MemPercent: 20},
"web1": {CPUPercent: 12.0, MemUsage: 190 * 1024 * 1024, MemLimit: 0},
},
}
}

// TestCollectDashboardStats verifies the collector keys each container's sample
// by the dashboard row's display name (shared alias / "<project>/<service>").
func TestCollectDashboardStats(t *testing.T) {
m := statsMockClient()
got := collectDashboardStats(context.Background(), m, true)
if len(got) != 2 {
t.Fatalf("stats map = %d entries, want 2: %+v", len(got), got)
}
if st, ok := got["shared-postgres"]; !ok || st.CPUPercent != 3.1 {
t.Errorf("shared-postgres stats = %+v (ok=%v)", st, ok)
}
if st, ok := got["shop/web"]; !ok || st.MemUsage != 190*1024*1024 {
t.Errorf("shop/web stats = %+v (ok=%v)", st, ok)
}
if m.StatsCalls != 2 {
t.Fatalf("StatsCalls = %d, want 2", m.StatsCalls)
}
}

// TestCollectDashboardStatsDisabled asserts --no-stats (enabled=false) issues no
// ContainerStats calls at all.
func TestCollectDashboardStatsDisabled(t *testing.T) {
m := statsMockClient()
if got := collectDashboardStats(context.Background(), m, false); got != nil {
t.Fatalf("disabled stats should be nil, got %+v", got)
}
if m.StatsCalls != 0 {
t.Fatalf("StatsCalls = %d, want 0 (fetch must be skipped)", m.StatsCalls)
}
}

// TestAttachDashboardStats folds seeded stats into the matching rows and leaves
// unmatched rows without stats.
func TestAttachDashboardStats(t *testing.T) {
m := statsMockClient()
rows := []dashRow{
{Name: "shared-postgres", Kind: "shared"},
{Name: "shop/web", Kind: "project"},
{Name: "shop/api", Kind: "project"}, // no container → stays statless
}
attachDashboardStats(context.Background(), m, rows, true)

if !rows[0].HasStats || rows[0].CPUPercent != 3.1 {
t.Errorf("shared-postgres row = %+v", rows[0])
}
if !rows[1].HasStats || rows[1].MemUsage != 190*1024*1024 {
t.Errorf("shop/web row = %+v", rows[1])
}
if rows[2].HasStats {
t.Errorf("shop/api should have no stats: %+v", rows[2])
}
}

// TestAttachDashboardStatsDisabled asserts disabled stats leave every row untouched.
func TestAttachDashboardStats_Disabled(t *testing.T) {
m := statsMockClient()
rows := []dashRow{{Name: "shared-postgres", Kind: "shared"}}
attachDashboardStats(context.Background(), m, rows, false)
if rows[0].HasStats {
t.Fatalf("row should have no stats when disabled: %+v", rows[0])
}
if m.StatsCalls != 0 {
t.Fatalf("StatsCalls = %d, want 0", m.StatsCalls)
}
}

// TestDashboardStatsInModel drives the Bubble Tea Update/View with stats-bearing
// rows and asserts the CPU%/MEM columns render in the table and detail pane.
func TestDashboardStatsInModel(t *testing.T) {
data := dashboardData{Rows: []dashRow{
{Name: "shared-postgres", Kind: "shared", State: "running", Refs: 2,
HasStats: true, CPUPercent: 3.1, MemUsage: 412 * 1024 * 1024, MemLimit: 2 * 1024 * 1024 * 1024},
}}
m := newDashboardModel(context.Background(), func(context.Context) dashboardData { return data }, 0)
updated, _ := m.Update(dashDataMsg(data))
dm := updated.(dashboardModel)

tr := dm.table.Rows()
if len(tr) != 1 {
t.Fatalf("table rows = %d, want 1", len(tr))
}
// Columns: SERVICE, STATE, HEALTH, CPU%, MEM, REFS.
if got := tr[0][3]; got != "3.1%" {
t.Errorf("CPU%% cell = %q, want 3.1%%", got)
}
if got := tr[0][4]; !strings.Contains(got, "412.0MB") {
t.Errorf("MEM cell = %q, want it to contain 412.0MB", got)
}

detail := dm.selectedDetail()
if !strings.Contains(detail, "cpu: 3.1%") || !strings.Contains(detail, "mem:") {
t.Errorf("detail missing stats: %q", detail)
}

dm2, _ := dm.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
view := dm2.(dashboardModel).View()
if !strings.Contains(view.Content, "CPU% (engine)") {
t.Errorf("view missing CPU%% column header")
}
}

// TestDashboardNoStatsRendersDash verifies a statless row shows the "—" placeholder.
func TestDashboardNoStatsRendersDash(t *testing.T) {
r := dashRow{Name: "shop/api", Kind: "project", State: "running", HasStats: false}
if got := dashCPU(r); got != "—" {
t.Errorf("dashCPU = %q, want —", got)
}
if got := dashMem(r); got != "—" {
t.Errorf("dashMem = %q, want —", got)
}
}

// TestFormatBytes covers the byte humanizer used by the MEM column.
func TestFormatBytes(t *testing.T) {
tests := []struct {
in uint64
want string
}{
{512, "512B"},
{1024, "1.0KB"},
{412 * 1024 * 1024, "412.0MB"},
{2 * 1024 * 1024 * 1024, "2.0GB"},
}
for _, tt := range tests {
if got := formatBytes(tt.in); got != tt.want {
t.Errorf("formatBytes(%d) = %q, want %q", tt.in, got, tt.want)
}
}
}
6 changes: 6 additions & 0 deletions internal/docker/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ type Client interface {
// mode, ctx cancellation, or the container stopping while following. Non-TTY
// containers are demuxed through stdcopy; TTY containers stream raw. Read-only.
ContainerLogStream(ctx context.Context, id string, opts LogOptions) (<-chan LogLine, error)
// ContainerStats retrieves a single, decoded resource-usage sample for one
// container (spec 16 CPU/mem columns): CPU% computed from the cpu/precpu
// delta plus cache-adjusted memory usage/limit. It opens no streaming reader
// (the daemon includes a previous sample so one call yields a CPU delta), so
// the dashboard can fetch it per visible container on each poll. Read-only.
ContainerStats(ctx context.Context, id string) (Stats, error)
// Close releases the underlying connection.
Close() error
}
Expand Down
Loading
Loading