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:
+192
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user