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:
@@ -0,0 +1,195 @@
|
||||
// Package tui is the impure shell layer for the TUI.
|
||||
// It converts pure Intent values into real I/O via tea.Cmd.
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
puretui "github.com/enmanuel/agents/pkg/tui"
|
||||
"github.com/enmanuel/agents/shell/process"
|
||||
)
|
||||
|
||||
// Adapter bridges pure Intents with the process Manager.
|
||||
type Adapter struct {
|
||||
mgr *process.Manager
|
||||
}
|
||||
|
||||
// NewAdapter creates an Adapter with the given Manager.
|
||||
func NewAdapter(mgr *process.Manager) *Adapter {
|
||||
return &Adapter{mgr: mgr}
|
||||
}
|
||||
|
||||
// RunIntent converts a pure Intent into a bubbletea Cmd that performs I/O.
|
||||
func (a *Adapter) RunIntent(intent puretui.Intent) tea.Cmd {
|
||||
switch intent.Kind {
|
||||
|
||||
case puretui.IntentLoadAgents:
|
||||
return a.loadAgents()
|
||||
|
||||
case puretui.IntentStartAgent:
|
||||
return a.startAgent(intent.AgentID)
|
||||
|
||||
case puretui.IntentStopAgent:
|
||||
return a.stopAgent(intent.AgentID)
|
||||
|
||||
case puretui.IntentKillAgent:
|
||||
return a.killAgent(intent.AgentID)
|
||||
|
||||
case puretui.IntentRestartAgent:
|
||||
return a.restartAgent(intent.AgentID)
|
||||
|
||||
case puretui.IntentLoadLogs:
|
||||
return a.loadLogs(intent.AgentID)
|
||||
|
||||
case puretui.IntentTick:
|
||||
return a.tick()
|
||||
|
||||
case puretui.IntentQuit:
|
||||
return tea.Quit
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adapter) loadAgents() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
statuses, err := a.mgr.StatusAll()
|
||||
if err != nil {
|
||||
return puretui.MsgAgentsLoaded{}
|
||||
}
|
||||
|
||||
views := make([]puretui.AgentView, len(statuses))
|
||||
for i, s := range statuses {
|
||||
v := puretui.AgentView{
|
||||
ID: s.ID,
|
||||
Name: s.Name,
|
||||
Version: s.Version,
|
||||
Desc: s.Desc,
|
||||
Enabled: s.Enabled,
|
||||
Running: s.Running,
|
||||
PID: s.PID,
|
||||
}
|
||||
|
||||
if s.Running {
|
||||
if stats, err := a.mgr.Stats(s.ID); err == nil {
|
||||
v.Uptime = formatUptime(stats.UptimeSecs)
|
||||
v.Memory = formatBytes(stats.MemRSSKB * 1024)
|
||||
v.CPU = fmt.Sprintf("%.1f%%", stats.CPUPct)
|
||||
v.LogSize = formatBytes(stats.LogBytes)
|
||||
}
|
||||
}
|
||||
|
||||
views[i] = v
|
||||
}
|
||||
|
||||
return puretui.MsgAgentsLoaded{Agents: views}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adapter) startAgent(id string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
agents, err := a.mgr.Scan()
|
||||
if err != nil {
|
||||
return puretui.MsgActionDone{AgentID: id, Action: "Start", Err: err}
|
||||
}
|
||||
for _, agent := range agents {
|
||||
if agent.ID == id {
|
||||
err = a.mgr.Start(agent)
|
||||
// Give the process a moment to start.
|
||||
if err == nil {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
return puretui.MsgActionDone{AgentID: id, Action: "Start", Err: err}
|
||||
}
|
||||
}
|
||||
return puretui.MsgActionDone{AgentID: id, Action: "Start", Err: fmt.Errorf("agent not found")}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adapter) stopAgent(id string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := a.mgr.Stop(id)
|
||||
return puretui.MsgActionDone{AgentID: id, Action: "Stop", Err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adapter) killAgent(id string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := a.mgr.Kill(id)
|
||||
return puretui.MsgActionDone{AgentID: id, Action: "Kill", Err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adapter) restartAgent(id string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// Stop first (ignore error if not running)
|
||||
_ = a.mgr.Stop(id)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
agents, err := a.mgr.Scan()
|
||||
if err != nil {
|
||||
return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: err}
|
||||
}
|
||||
for _, agent := range agents {
|
||||
if agent.ID == id {
|
||||
err = a.mgr.Start(agent)
|
||||
if err == nil {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: err}
|
||||
}
|
||||
}
|
||||
return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: fmt.Errorf("agent not found")}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adapter) loadLogs(id string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
lines, err := a.mgr.LogTail(id, 100)
|
||||
if err != nil {
|
||||
return puretui.MsgLogsLoaded{Lines: []string{"Error: " + err.Error()}}
|
||||
}
|
||||
return puretui.MsgLogsLoaded{Lines: lines}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adapter) tick() tea.Cmd {
|
||||
return tea.Tick(3*time.Second, func(time.Time) tea.Msg {
|
||||
return puretui.MsgTick{}
|
||||
})
|
||||
}
|
||||
|
||||
// ── formatting helpers ───────────────────────────────────────────────────
|
||||
|
||||
func formatUptime(secs int64) string {
|
||||
if secs < 0 {
|
||||
return "n/a"
|
||||
}
|
||||
d := secs / 86400
|
||||
h := (secs % 86400) / 3600
|
||||
m := (secs % 3600) / 60
|
||||
if d > 0 {
|
||||
return fmt.Sprintf("%dd %dh", d, h)
|
||||
}
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%dh %dm", h, m)
|
||||
}
|
||||
return fmt.Sprintf("%dm", m)
|
||||
}
|
||||
|
||||
func formatBytes(bytes int64) string {
|
||||
switch {
|
||||
case bytes >= 1<<30:
|
||||
return fmt.Sprintf("%.1f GB", float64(bytes)/float64(1<<30))
|
||||
case bytes >= 1<<20:
|
||||
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(1<<20))
|
||||
case bytes >= 1<<10:
|
||||
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(1<<10))
|
||||
default:
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user