Skip to content

Commit 9bd2c40

Browse files
feat(logs): log streaming + Bubble Tea dashboard cockpit (spec 16) (#101)
Implements spec 16's read-only observation surface: - `logs [service...]`: multiplexes container logs across the workspace's project + shared stacks via the read-only Engine SDK, filtered on the tool-owned label + an optional per-service filter (shared alias, bare engine, service, or project name). Flags --follow/-f, --tail, --since, --timestamps, --no-color, and a --json {ts,service,project,stream, container,line} line contract. TTY gets a color-keyed gutter; a pipe gets plain lines. Non-follow exits at EOF; -f is signal-aware for clean teardown. - docker.Client gains ContainerLogStream: a demuxed (stdcopy for non-TTY, raw for TTY), optionally-following/timestamped line channel with bounded fan-in backpressure; TTY-ness learned via inspect; unreadable logging drivers surface a one-liner. Mirrored in MockClient (Streams seam). - `dashboard`: a Bubble Tea v2 cockpit (bubbles/v2 table + lipgloss/v2) showing shared + project services with state/health + refs + URL and a log tail pane, keybindings (q/esc/ctrl+c quit, r refresh), 2s safety poll. Non-TTY / --json / --quiet refuses the TUI and prints a one-shot snapshot reusing the shared-status + per-project projections. Removes the logs + dashboard stubs. Tests: log stream demux/emitter/ctx, target label-filter resolution, --json line shape, multiplex over the mock, dashboard model Update/View transitions, and the non-TTY snapshot path. No real daemon required. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b7419b6 commit 9bd2c40

14 files changed

Lines changed: 1446 additions & 7 deletions

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ module github.com/open-source-cloud/devstack
55
go 1.25.8
66

77
require (
8+
charm.land/bubbles/v2 v2.0.0
9+
charm.land/bubbletea/v2 v2.0.2
810
charm.land/fang/v2 v2.0.1
911
charm.land/huh/v2 v2.0.3
1012
charm.land/lipgloss/v2 v2.0.1
@@ -36,8 +38,6 @@ require (
3638
)
3739

3840
require (
39-
charm.land/bubbles/v2 v2.0.0 // indirect
40-
charm.land/bubbletea/v2 v2.0.2 // indirect
4141
filippo.io/hpke v0.4.0 // indirect
4242
github.com/Microsoft/go-winio v0.6.2 // indirect
4343
github.com/atotto/clipboard v0.1.4 // indirect

internal/cli/dashboard.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"sort"
8+
"time"
9+
10+
tea "charm.land/bubbletea/v2"
11+
"github.com/spf13/cobra"
12+
13+
"github.com/open-source-cloud/devstack/internal/docker"
14+
"github.com/open-source-cloud/devstack/internal/workspace"
15+
)
16+
17+
// dashboardPoll is the default safety-poll cadence (spec 16: event-driven + a 2s
18+
// fallback; the model uses the fallback poll for its live refresh).
19+
const dashboardPoll = 2 * time.Second
20+
21+
// newDashboardCmd wires `devstack dashboard` (spec 16): a Bubble Tea cockpit over
22+
// the read-only Engine SDK + the ledger. On a non-TTY (or --json/--quiet) it
23+
// refuses to launch the TUI and prints a one-shot status snapshot instead — the
24+
// scriptable, non-interactive equivalent.
25+
func newDashboardCmd(g *GlobalOpts) *cobra.Command {
26+
var noStats bool
27+
cmd := &cobra.Command{
28+
Use: "dashboard",
29+
Short: "Live TUI cockpit: shared + project services, health, and a log tail",
30+
Args: cobra.NoArgs,
31+
RunE: func(cmd *cobra.Command, _ []string) error {
32+
mgr, closeFn, err := buildManager(cmd)
33+
if err != nil {
34+
return err
35+
}
36+
defer closeFn()
37+
38+
// Best-effort self-heal so ref counts are truthful (lock-free reads,
39+
// this reconcile takes the lock only if it prunes — same as status).
40+
_, _ = mgr.Reconcile(cmd.Context())
41+
42+
if !dashboardInteractive(cmd, g) {
43+
return printDashboardSnapshot(cmd, g, mgr)
44+
}
45+
46+
ctx := cmd.Context()
47+
fetch := func(c context.Context) dashboardData { return collectDashboardData(c, mgr) }
48+
model := newDashboardModel(ctx, fetch, dashboardPoll)
49+
_, err = tea.NewProgram(model, tea.WithContext(ctx)).Run()
50+
return err
51+
},
52+
}
53+
cmd.Flags().BoolVar(&noStats, "no-stats", false, "reserved: disable the CPU/mem stats stream (stats are opt-in in this build)")
54+
return cmd
55+
}
56+
57+
// dashboardInteractive reports whether the TUI may launch: a real stdout TTY and
58+
// neither --json nor --quiet requested.
59+
func dashboardInteractive(cmd *cobra.Command, g *GlobalOpts) bool {
60+
if g.JSON || g.Quiet {
61+
return false
62+
}
63+
f, ok := cmd.OutOrStdout().(*os.File)
64+
return ok && isTerminal(f)
65+
}
66+
67+
// printDashboardSnapshot is the non-TTY fallback: a one-shot projection reusing
68+
// the same shared-status + per-project views as `status`, plus a redirect to the
69+
// scriptable commands.
70+
func printDashboardSnapshot(cmd *cobra.Command, g *GlobalOpts, mgr *workspace.Manager) error {
71+
ctx := cmd.Context()
72+
projects := collectProjectStatus(ctx, mgr)
73+
shared, err := mgr.Status()
74+
if err != nil {
75+
return err
76+
}
77+
if g.JSON {
78+
return writeJSON(cmd, map[string]any{"projects": projects, "shared": shared})
79+
}
80+
if g.Quiet {
81+
return nil
82+
}
83+
w := cmd.OutOrStdout()
84+
fmt.Fprintln(w, "dashboard needs an interactive terminal; showing a one-shot snapshot.")
85+
fmt.Fprintln(w, "use `devstack logs` (follow) or `devstack status --json` for non-interactive output.")
86+
fmt.Fprintln(w)
87+
renderStatus(cmd, projects, shared)
88+
return nil
89+
}
90+
91+
// collectDashboardData is the read-only collector: it fans in shared-service rows
92+
// (ledger), per-project service rows (live containers + health), and a bounded
93+
// tail of recent log lines into one snapshot. Lock-free.
94+
func collectDashboardData(ctx context.Context, mgr *workspace.Manager) dashboardData {
95+
var data dashboardData
96+
97+
shared, err := mgr.Status()
98+
if err != nil {
99+
data.Err = err.Error()
100+
}
101+
for _, s := range shared {
102+
data.Rows = append(data.Rows, dashRow{
103+
Name: s.Alias,
104+
Kind: "shared",
105+
State: s.Status,
106+
Refs: s.RefCount,
107+
Projects: s.Projects,
108+
Engine: dashEngine(s),
109+
})
110+
}
111+
112+
for _, p := range collectProjectStatus(ctx, mgr) {
113+
for _, svc := range p.Services {
114+
data.Rows = append(data.Rows, dashRow{
115+
Name: p.Project + "/" + svc.Name,
116+
Kind: "project",
117+
State: svc.State,
118+
Health: svc.Health,
119+
URL: fmt.Sprintf("https://%s.%s.localhost", svc.Name, p.Project),
120+
})
121+
}
122+
}
123+
124+
data.Logs = collectRecentLogs(ctx, mgr.Docker, 8)
125+
return data
126+
}
127+
128+
// dashEngine renders a shared row's "engine version" detail.
129+
func dashEngine(s workspace.SharedStatus) string {
130+
if s.Major == "" || s.Major == "default" {
131+
return s.Engine
132+
}
133+
return s.Engine + " " + s.Major
134+
}
135+
136+
// collectRecentLogs pulls up to `tail` trailing lines from each managed container
137+
// (non-follow) into a service-tagged, bounded slice for the log pane. Best-effort:
138+
// an unreadable service is skipped, not fatal.
139+
func collectRecentLogs(ctx context.Context, client docker.Client, tail int) []dashLog {
140+
targets, err := resolveLogTargets(ctx, client, nil)
141+
if err != nil {
142+
return nil
143+
}
144+
var out []dashLog
145+
for _, t := range targets {
146+
ch, err := client.ContainerLogStream(ctx, t.ID, docker.LogOptions{Tail: tail})
147+
if err != nil {
148+
continue
149+
}
150+
for ll := range ch {
151+
out = append(out, dashLog{Service: t.Service, Line: ll.Text})
152+
}
153+
}
154+
// Deterministic-ish ordering: group by service (arrival order within a
155+
// service is preserved by the stream).
156+
sort.SliceStable(out, func(i, j int) bool { return out[i].Service < out[j].Service })
157+
return clampLogs(out)
158+
}

0 commit comments

Comments
 (0)