-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdashboard_model.go
More file actions
307 lines (278 loc) · 8.32 KB
/
Copy pathdashboard_model.go
File metadata and controls
307 lines (278 loc) · 8.32 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
package cli
import (
"context"
"fmt"
"strings"
"time"
table "charm.land/bubbles/v2/table"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// dashRow is one service row in the cockpit's top-left table (spec 16 layout).
type dashRow struct {
Name string // shared-postgres | api/php
Kind string // "shared" | "project"
State string // running | exited | ...
Health string // starting|healthy|unhealthy|"" (no healthcheck)
Refs int // shared: referencing-project count
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.
type dashLog struct {
Service string
Line string
}
// dashboardData is the read-only snapshot the collector fans out to the model:
// service rows + a bounded tail of recent log lines. It is produced lock-free.
type dashboardData struct {
Rows []dashRow
Logs []dashLog
Err string // a collector-level error to surface in the footer
}
// dashDataMsg delivers a fresh snapshot into the model's Update loop.
type dashDataMsg dashboardData
// dashTickMsg is the safety-poll cadence (event streams are out of scope for the
// unit-testable model; a steady poll keeps the cockpit live).
type dashTickMsg struct{}
const dashLogCap = 500 // bounded ring for the log pane
// dashboardModel is the Bubble Tea cockpit. It is fully unit-testable without a
// TTY: construct it with a canned fetch, drive Update with messages, and inspect
// the table rows / View output.
type dashboardModel struct {
ctx context.Context
fetch func(context.Context) dashboardData
poll time.Duration
theme dashTheme
table table.Model
rows []dashRow
logs []dashLog
width int
height int
err string
quit bool
}
// dashTheme carries the few styles the cockpit needs so it tracks internal/prompt.
type dashTheme struct {
Title lipgloss.Style
Detail lipgloss.Style
Footer lipgloss.Style
Border lipgloss.Style
}
func defaultDashTheme() dashTheme {
return dashTheme{
Title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")),
Detail: lipgloss.NewStyle().Foreground(lipgloss.Color("252")),
Footer: lipgloss.NewStyle().Faint(true),
Border: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("63")),
}
}
// newDashboardModel builds the cockpit around an injected fetch (the read-only
// collector). poll<=0 disables the safety poll (used in tests).
func newDashboardModel(ctx context.Context, fetch func(context.Context) dashboardData, poll time.Duration) dashboardModel {
t := table.New(
table.WithColumns([]table.Column{
{Title: "SERVICE", Width: 22},
{Title: "STATE", Width: 9},
{Title: "HEALTH", Width: 9},
{Title: "CPU% (engine)", Width: 13},
{Title: "MEM", Width: 11},
{Title: "REFS", Width: 18},
}),
table.WithFocused(true),
table.WithHeight(10),
)
return dashboardModel{ctx: ctx, fetch: fetch, poll: poll, theme: defaultDashTheme(), table: t}
}
func (m dashboardModel) Init() tea.Cmd {
return tea.Batch(m.fetchCmd(), m.tickCmd())
}
// fetchCmd runs the read-only collector off the render loop and delivers a snapshot.
func (m dashboardModel) fetchCmd() tea.Cmd {
return func() tea.Msg {
if m.fetch == nil {
return dashDataMsg{}
}
return dashDataMsg(m.fetch(m.ctx))
}
}
func (m dashboardModel) tickCmd() tea.Cmd {
if m.poll <= 0 {
return nil
}
return tea.Tick(m.poll, func(time.Time) tea.Msg { return dashTickMsg{} })
}
func (m dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case dashDataMsg:
m.rows = msg.Rows
m.logs = clampLogs(msg.Logs)
m.err = msg.Err
m.table.SetRows(dashTableRows(m.rows))
return m, nil
case dashTickMsg:
return m, tea.Batch(m.fetchCmd(), m.tickCmd())
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.resize()
return m, nil
case tea.KeyPressMsg:
switch msg.String() {
case "q", "ctrl+c", "esc":
m.quit = true
return m, tea.Quit
case "r":
return m, m.fetchCmd()
}
}
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
return m, cmd
}
// resize distributes the terminal size between the table and the log pane.
func (m *dashboardModel) resize() {
if m.width > 0 {
m.table.SetWidth(m.width)
}
// Reserve the top table (~half, min 4 rows) and leave the rest for logs.
th := m.height/2 - 2
if th < 4 {
th = 4
}
m.table.SetHeight(th)
}
func (m dashboardModel) View() tea.View {
var b strings.Builder
b.WriteString(m.theme.Title.Render("devstack dashboard"))
b.WriteString(" ")
b.WriteString(m.theme.Footer.Render("read-only · no daemon · command-scoped"))
b.WriteString("\n\n")
b.WriteString(m.table.View())
b.WriteString("\n\n")
// Detail for the selected row.
if d := m.selectedDetail(); d != "" {
b.WriteString(m.theme.Detail.Render(d))
b.WriteString("\n\n")
}
// Log pane.
b.WriteString(m.theme.Title.Render("logs"))
b.WriteString("\n")
if len(m.logs) == 0 {
b.WriteString(m.theme.Footer.Render(" (no recent log lines)"))
b.WriteString("\n")
}
for _, l := range m.logsTail() {
fmt.Fprintf(&b, "%-18s │ %s\n", l.Service, l.Line)
}
if m.err != "" {
b.WriteString("\n")
b.WriteString(m.theme.Footer.Render("! " + m.err))
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(m.theme.Footer.Render("[r]efresh [j/k]scroll [q]uit"))
v := tea.NewView(b.String())
v.AltScreen = true
return v
}
// selectedDetail renders the right-pane detail for the highlighted service.
func (m dashboardModel) selectedDetail() string {
i := m.table.Cursor()
if i < 0 || i >= len(m.rows) {
return ""
}
r := m.rows[i]
var parts []string
parts = append(parts, "▸ "+r.Name)
if r.Engine != "" {
parts = append(parts, "engine: "+r.Engine)
}
if r.Kind == "shared" {
parts = append(parts, fmt.Sprintf("refs: %d %v", r.Refs, r.Projects))
}
if r.URL != "" {
parts = append(parts, "url: "+r.URL)
}
state := r.State
if r.Health != "" {
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, " ")
}
// logsTail returns the last visible slice of the log ring for the pane height.
func (m dashboardModel) logsTail() []dashLog {
n := m.height/2 - 4
if n < 3 {
n = 8
}
if len(m.logs) <= n {
return m.logs
}
return m.logs[len(m.logs)-n:]
}
// dashTableRows projects rows into the bubbles table shape.
func dashTableRows(rows []dashRow) []table.Row {
out := make([]table.Row, 0, len(rows))
for _, r := range rows {
refs := ""
switch {
case r.Kind == "shared":
refs = fmt.Sprintf("refs=%d", r.Refs)
case r.URL != "":
refs = r.URL
}
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
}
return logs[len(logs)-dashLogCap:]
}