feat: add bubbletea TUI dashboard for bot server management

Implementa un dashboard interactivo con bubbletea siguiendo el patrón
pure core / impure shell del proyecto:

- pkg/tui/ (PURE): Model, Update, View — solo fmt y strings, cero I/O.
  Update produce Intent[] (datos puros) en vez de side effects.
- shell/tui/ (IMPURE): Adapter convierte Intent[] en tea.Cmd[] con I/O real
  (process management, /proc stats, log tail).
- cmd/dashboard/ (composición): Bridge conecta pure Update con shell Adapter
  usando la Elm Architecture de bubbletea.

Pantallas: Main Menu → Agent List → Agent Actions (start/stop/restart/kill) → Logs.
Navegación: flechas ↑↓, Enter seleccionar, 0 volver, q salir.

Dependencias añadidas: bubbletea, lipgloss.
Actualiza .gitignore para anclar binarios a raíz (/agentctl, /dashboard).
Documenta nuevos scripts en CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 19:38:30 +00:00
parent 00dac8b77f
commit f5b857dbc6
11 changed files with 933 additions and 1 deletions
+20
View File
@@ -0,0 +1,20 @@
package tui
// Messages are pure data returned by the shell adapter.
// They carry the result of an I/O operation back into the pure Update.
// MsgAgentsLoaded carries refreshed agent data.
type MsgAgentsLoaded struct{ Agents []AgentView }
// MsgActionDone reports the result of an agent action (start/stop/kill/restart).
type MsgActionDone struct {
AgentID string
Action string
Err error
}
// MsgLogsLoaded carries log lines for the selected agent.
type MsgLogsLoaded struct{ Lines []string }
// MsgTick triggers a periodic refresh.
type MsgTick struct{}
+76
View File
@@ -0,0 +1,76 @@
// Package tui defines the pure TUI model, messages, update, and view.
// Zero I/O, zero side effects. Only data transformations.
package tui
// Screen identifies the current TUI screen.
type Screen int
const (
ScreenMain Screen = iota
ScreenAgentList // list all agents with status
ScreenAgentActions // actions for a selected agent
ScreenLogs // tail log output
)
// Model is the complete TUI state — pure data.
type Model struct {
Screen Screen
Agents []AgentView
Cursor int
Selected *AgentView // nil when no agent selected
LogLines []string
LogScroll int
StatusMsg string // flash message ("Started OK", "Error: ...")
WindowWidth int
WindowHeight int
}
// AgentView is a pre-formatted projection of an agent for display.
type AgentView struct {
ID string
Name string
Version string
Desc string
Enabled bool
Running bool
PID int
Uptime string // formatted: "2h 15m"
Memory string // formatted: "42 MB"
CPU string // formatted: "1.2%"
LogSize string // formatted: "350 KB"
}
// MenuOption represents a selectable menu item.
type MenuOption struct {
Label string
Desc string
}
// MainMenuOptions returns the options for the main screen.
func MainMenuOptions() []MenuOption {
return []MenuOption{
{Label: "Agents", Desc: "Gestionar agentes"},
{Label: "Quit", Desc: "Salir"},
}
}
// AgentActionOptions returns the available actions based on agent state.
func AgentActionOptions(running bool) []MenuOption {
if running {
return []MenuOption{
{Label: "Stop", Desc: "Detener el agente"},
{Label: "Restart", Desc: "Reiniciar"},
{Label: "Kill", Desc: "SIGKILL forzado"},
{Label: "Logs", Desc: "Ver log del agente"},
}
}
return []MenuOption{
{Label: "Start", Desc: "Iniciar el agente"},
{Label: "Logs", Desc: "Ver log del agente"},
}
}
// InitialModel returns the starting state.
func InitialModel() Model {
return Model{Screen: ScreenMain}
}
+231
View File
@@ -0,0 +1,231 @@
package tui
import "fmt"
// IntentKind represents a side effect the shell must perform.
type IntentKind string
const (
IntentLoadAgents IntentKind = "load_agents"
IntentStartAgent IntentKind = "start_agent"
IntentStopAgent IntentKind = "stop_agent"
IntentKillAgent IntentKind = "kill_agent"
IntentRestartAgent IntentKind = "restart_agent"
IntentLoadLogs IntentKind = "load_logs"
IntentTick IntentKind = "tick"
IntentQuit IntentKind = "quit"
)
// Intent is pure data describing a side effect to execute.
type Intent struct {
Kind IntentKind
AgentID string
}
// KeyMsg is the pure representation of a key press.
// The bridge layer converts tea.KeyMsg into this.
type KeyMsg struct {
Str string // "up", "down", "enter", "0", "q", "r", etc.
}
// WindowSizeMsg carries terminal dimensions.
type WindowSizeMsg struct {
Width int
Height int
}
// Update is PURE: (Model, msg) → (Model, []Intent). No side effects.
func Update(model Model, msg interface{}) (Model, []Intent) {
switch m := msg.(type) {
case WindowSizeMsg:
model.WindowWidth = m.Width
model.WindowHeight = m.Height
return model, nil
case MsgAgentsLoaded:
model.Agents = m.Agents
// Clamp cursor
if model.Cursor >= len(model.Agents) && len(model.Agents) > 0 {
model.Cursor = len(model.Agents) - 1
}
return model, []Intent{{Kind: IntentTick}}
case MsgActionDone:
if m.Err != nil {
model.StatusMsg = fmt.Sprintf("Error: %s %s: %v", m.Action, m.AgentID, m.Err)
} else {
model.StatusMsg = fmt.Sprintf("%s %s OK", m.Action, m.AgentID)
}
return model, []Intent{{Kind: IntentLoadAgents}}
case MsgLogsLoaded:
model.LogLines = m.Lines
model.LogScroll = max(0, len(m.Lines)-visibleLogLines(model))
return model, nil
case MsgTick:
return model, []Intent{{Kind: IntentLoadAgents}}
case KeyMsg:
return updateKey(model, m)
}
return model, nil
}
func updateKey(model Model, key KeyMsg) (Model, []Intent) {
// Global quit
if key.Str == "q" && model.Screen == ScreenMain {
return model, []Intent{{Kind: IntentQuit}}
}
if key.Str == "ctrl+c" {
return model, []Intent{{Kind: IntentQuit}}
}
switch model.Screen {
case ScreenMain:
return updateMainScreen(model, key)
case ScreenAgentList:
return updateAgentList(model, key)
case ScreenAgentActions:
return updateAgentActions(model, key)
case ScreenLogs:
return updateLogs(model, key)
}
return model, nil
}
func updateMainScreen(model Model, key KeyMsg) (Model, []Intent) {
opts := MainMenuOptions()
switch key.Str {
case "up", "k":
model.Cursor = clamp(model.Cursor-1, 0, len(opts)-1)
case "down", "j":
model.Cursor = clamp(model.Cursor+1, 0, len(opts)-1)
case "enter":
switch opts[model.Cursor].Label {
case "Agents":
model.Screen = ScreenAgentList
model.Cursor = 0
return model, []Intent{{Kind: IntentLoadAgents}}
case "Quit":
return model, []Intent{{Kind: IntentQuit}}
}
}
return model, nil
}
func updateAgentList(model Model, key KeyMsg) (Model, []Intent) {
switch key.Str {
case "0":
model.Screen = ScreenMain
model.Cursor = 0
model.StatusMsg = ""
case "up", "k":
model.Cursor = clamp(model.Cursor-1, 0, max(0, len(model.Agents)-1))
case "down", "j":
model.Cursor = clamp(model.Cursor+1, 0, max(0, len(model.Agents)-1))
case "enter":
if model.Cursor < len(model.Agents) {
sel := model.Agents[model.Cursor]
model.Selected = &sel
model.Screen = ScreenAgentActions
model.Cursor = 0
model.StatusMsg = ""
}
}
return model, nil
}
func updateAgentActions(model Model, key KeyMsg) (Model, []Intent) {
if model.Selected == nil {
model.Screen = ScreenAgentList
return model, nil
}
opts := AgentActionOptions(model.Selected.Running)
switch key.Str {
case "0":
model.Screen = ScreenAgentList
model.Cursor = 0
model.Selected = nil
model.StatusMsg = ""
return model, []Intent{{Kind: IntentLoadAgents}}
case "up", "k":
model.Cursor = clamp(model.Cursor-1, 0, len(opts)-1)
case "down", "j":
model.Cursor = clamp(model.Cursor+1, 0, len(opts)-1)
case "enter":
if model.Cursor < len(opts) {
return executeAction(model, opts[model.Cursor].Label)
}
}
return model, nil
}
func executeAction(model Model, action string) (Model, []Intent) {
id := model.Selected.ID
switch action {
case "Start":
model.StatusMsg = "Starting " + id + "..."
return model, []Intent{{Kind: IntentStartAgent, AgentID: id}}
case "Stop":
model.StatusMsg = "Stopping " + id + "..."
return model, []Intent{{Kind: IntentStopAgent, AgentID: id}}
case "Restart":
model.StatusMsg = "Restarting " + id + "..."
return model, []Intent{{Kind: IntentRestartAgent, AgentID: id}}
case "Kill":
model.StatusMsg = "Killing " + id + "..."
return model, []Intent{{Kind: IntentKillAgent, AgentID: id}}
case "Logs":
model.Screen = ScreenLogs
model.LogLines = nil
model.LogScroll = 0
model.Cursor = 0
return model, []Intent{{Kind: IntentLoadLogs, AgentID: id}}
}
return model, nil
}
func updateLogs(model Model, key KeyMsg) (Model, []Intent) {
switch key.Str {
case "0":
model.Screen = ScreenAgentActions
model.Cursor = 0
model.LogLines = nil
model.LogScroll = 0
case "up", "k":
model.LogScroll = max(0, model.LogScroll-1)
case "down", "j":
maxScroll := max(0, len(model.LogLines)-visibleLogLines(model))
model.LogScroll = min(model.LogScroll+1, maxScroll)
case "r":
if model.Selected != nil {
return model, []Intent{{Kind: IntentLoadLogs, AgentID: model.Selected.ID}}
}
}
return model, nil
}
// ── pure helpers ─────────────────────────────────────────────────────────
func visibleLogLines(m Model) int {
lines := m.WindowHeight - 6 // header + footer
if lines < 5 {
return 5
}
return lines
}
func clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
+192
View File
@@ -0,0 +1,192 @@
package tui
import (
"fmt"
"strings"
)
// View is PURE: Model → string. No side effects.
func View(model Model) string {
switch model.Screen {
case ScreenMain:
return viewMain(model)
case ScreenAgentList:
return viewAgentList(model)
case ScreenAgentActions:
return viewAgentActions(model)
case ScreenLogs:
return viewLogs(model)
default:
return ""
}
}
func viewMain(m Model) string {
var b strings.Builder
b.WriteString("\n Bot Server Dashboard\n")
b.WriteString(" " + strings.Repeat("─", 36) + "\n")
// Summary
running, stopped, disabled := countStatuses(m.Agents)
total := len(m.Agents)
if total > 0 {
b.WriteString(fmt.Sprintf(" %d agents (%d running, %d stopped, %d disabled)\n\n",
total, running, stopped, disabled))
} else {
b.WriteString(" Loading...\n\n")
}
// Menu
for i, opt := range MainMenuOptions() {
cursor := " "
if i == m.Cursor {
cursor = "> "
}
b.WriteString(fmt.Sprintf(" %s%-16s %s\n", cursor, opt.Label, opt.Desc))
}
b.WriteString("\n ↑↓ navegar enter seleccionar q salir\n")
return b.String()
}
func viewAgentList(m Model) string {
var b strings.Builder
b.WriteString("\n Agents\n")
b.WriteString(" " + strings.Repeat("─", 60) + "\n")
if len(m.Agents) == 0 {
b.WriteString(" No agents found.\n")
}
for i, a := range m.Agents {
cursor := " "
if i == m.Cursor {
cursor = "> "
}
icon := "○"
status := "stopped"
if !a.Enabled {
icon = " "
status = "disabled"
} else if a.Running {
icon = "●"
status = fmt.Sprintf("running PID %d", a.PID)
}
b.WriteString(fmt.Sprintf(" %s%s %-20s %-8s %s\n",
cursor, icon, a.ID, a.Version, status))
}
if m.StatusMsg != "" {
b.WriteString("\n " + m.StatusMsg + "\n")
}
b.WriteString("\n ↑↓ navegar enter acciones 0 volver\n")
return b.String()
}
func viewAgentActions(m Model) string {
var b strings.Builder
if m.Selected == nil {
return " No agent selected.\n"
}
a := m.Selected
icon := "○ stopped"
if a.Running {
icon = fmt.Sprintf("● running PID %d", a.PID)
}
b.WriteString(fmt.Sprintf("\n %s %s\n", a.ID, icon))
b.WriteString(" " + strings.Repeat("─", 44) + "\n")
// Stats line if running
if a.Running && (a.Memory != "" || a.CPU != "") {
parts := []string{}
if a.Uptime != "" {
parts = append(parts, "uptime: "+a.Uptime)
}
if a.Memory != "" {
parts = append(parts, "mem: "+a.Memory)
}
if a.CPU != "" {
parts = append(parts, "cpu: "+a.CPU)
}
if a.LogSize != "" {
parts = append(parts, "log: "+a.LogSize)
}
b.WriteString(" " + strings.Join(parts, " ") + "\n")
}
b.WriteString("\n")
opts := AgentActionOptions(a.Running)
for i, opt := range opts {
cursor := " "
if i == m.Cursor {
cursor = "> "
}
b.WriteString(fmt.Sprintf(" %s%-16s %s\n", cursor, opt.Label, opt.Desc))
}
if m.StatusMsg != "" {
b.WriteString("\n " + m.StatusMsg + "\n")
}
b.WriteString("\n ↑↓ navegar enter ejecutar 0 volver\n")
return b.String()
}
func viewLogs(m Model) string {
var b strings.Builder
agentID := "?"
if m.Selected != nil {
agentID = m.Selected.ID
}
b.WriteString(fmt.Sprintf("\n %s — Logs\n", agentID))
b.WriteString(" " + strings.Repeat("─", 60) + "\n")
if len(m.LogLines) == 0 {
b.WriteString(" (no log data)\n")
} else {
visible := visibleLogLines(m)
end := m.LogScroll + visible
if end > len(m.LogLines) {
end = len(m.LogLines)
}
start := m.LogScroll
if start >= len(m.LogLines) {
start = max(0, len(m.LogLines)-1)
}
for _, line := range m.LogLines[start:end] {
// Truncate long lines
if len(line) > m.WindowWidth-4 && m.WindowWidth > 10 {
line = line[:m.WindowWidth-7] + "..."
}
b.WriteString(" " + line + "\n")
}
}
b.WriteString("\n ↑↓ scroll r recargar 0 volver\n")
return b.String()
}
func countStatuses(agents []AgentView) (running, stopped, disabled int) {
for _, a := range agents {
switch {
case !a.Enabled:
disabled++
case a.Running:
running++
default:
stopped++
}
}
return
}