feat(tui): add Bubble Tea dashboard model for Apply phase
This commit is contained in:
175
internal/tui/dashboard/model.go
Normal file
175
internal/tui/dashboard/model.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package dashboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/engine"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DoneMsg is sent from the engine goroutine once engine.Run returns.
|
||||||
|
type DoneMsg struct {
|
||||||
|
Summary engine.Summary
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// rowState tracks per-module progress in the dashboard.
|
||||||
|
type rowState struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Current int
|
||||||
|
Total int
|
||||||
|
LastMsg string
|
||||||
|
LogTail []string
|
||||||
|
StatusIcon string // ·, ⠋ (running), ✓, ✗, ⊘
|
||||||
|
Done bool
|
||||||
|
Started time.Time
|
||||||
|
Ended time.Time
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model is the Bubble Tea model for the Apply-phase dashboard.
|
||||||
|
type Model struct {
|
||||||
|
rows []*rowState
|
||||||
|
rowIndex map[string]*rowState
|
||||||
|
cursor int
|
||||||
|
showLogs bool
|
||||||
|
summary *engine.Summary
|
||||||
|
runErr error
|
||||||
|
quitting bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewModel seeds the dashboard from the list of module IDs selected to run.
|
||||||
|
// Rows start in the "pending" state and flip to running on first ProgressMsg.
|
||||||
|
func NewModel(selected []string, nameByID map[string]string) *Model {
|
||||||
|
m := &Model{rowIndex: make(map[string]*rowState, len(selected))}
|
||||||
|
for _, id := range selected {
|
||||||
|
name := id
|
||||||
|
if n, ok := nameByID[id]; ok {
|
||||||
|
name = n
|
||||||
|
}
|
||||||
|
r := &rowState{ID: id, Name: name, StatusIcon: "·"}
|
||||||
|
m.rows = append(m.rows, r)
|
||||||
|
m.rowIndex[id] = r
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tui.ProgressMsg:
|
||||||
|
if r, ok := m.rowIndex[msg.ModuleID]; ok {
|
||||||
|
if r.Started.IsZero() {
|
||||||
|
r.Started = time.Now()
|
||||||
|
}
|
||||||
|
r.Current = msg.Current
|
||||||
|
r.Total = msg.Total
|
||||||
|
r.LastMsg = msg.Message
|
||||||
|
r.StatusIcon = "⠋"
|
||||||
|
}
|
||||||
|
case tui.LogMsg:
|
||||||
|
if r, ok := m.rowIndex[msg.ModuleID]; ok {
|
||||||
|
r.LogTail = appendCapped(r.LogTail, msg.Line, 200)
|
||||||
|
}
|
||||||
|
case DoneMsg:
|
||||||
|
m.summary = &msg.Summary
|
||||||
|
m.runErr = msg.Err
|
||||||
|
for _, res := range msg.Summary.Results {
|
||||||
|
r, ok := m.rowIndex[res.ID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.Done = true
|
||||||
|
r.Ended = time.Now()
|
||||||
|
switch res.Status {
|
||||||
|
case engine.StatusSucceeded, engine.StatusPlanned:
|
||||||
|
r.StatusIcon = "✓"
|
||||||
|
case engine.StatusFailed:
|
||||||
|
r.StatusIcon = "✗"
|
||||||
|
r.Err = res.Err
|
||||||
|
case engine.StatusSkipped:
|
||||||
|
r.StatusIcon = "⊘"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "q", "ctrl+c":
|
||||||
|
m.quitting = true
|
||||||
|
return m, tea.Quit
|
||||||
|
case "up", "k":
|
||||||
|
if m.cursor > 0 {
|
||||||
|
m.cursor--
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if m.cursor < len(m.rows)-1 {
|
||||||
|
m.cursor++
|
||||||
|
}
|
||||||
|
case "enter":
|
||||||
|
m.showLogs = !m.showLogs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) View() string {
|
||||||
|
var b strings.Builder
|
||||||
|
header := lipgloss.NewStyle().Bold(true).Render("archduino install")
|
||||||
|
b.WriteString(header + "\n")
|
||||||
|
|
||||||
|
for i, r := range m.rows {
|
||||||
|
cursor := " "
|
||||||
|
if i == m.cursor {
|
||||||
|
cursor = "▸ "
|
||||||
|
}
|
||||||
|
progress := ""
|
||||||
|
if r.Total > 0 {
|
||||||
|
progress = fmt.Sprintf("%d/%d", r.Current, r.Total)
|
||||||
|
}
|
||||||
|
detail := r.LastMsg
|
||||||
|
if r.Err != nil {
|
||||||
|
detail = "failed — " + r.Err.Error()
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "%s%s %-16s %-6s %s\n", cursor, r.StatusIcon, r.ID, progress, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.showLogs && len(m.rows) > 0 {
|
||||||
|
active := m.rows[m.cursor]
|
||||||
|
b.WriteString("\n─ log tail ─\n")
|
||||||
|
for _, line := range active.LogTail {
|
||||||
|
b.WriteString(" " + line + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.summary != nil {
|
||||||
|
ok, fail, skip := m.summary.Counts()
|
||||||
|
fmt.Fprintf(&b, "\nsummary: %d succeeded, %d failed, %d skipped\n", ok, fail, skip)
|
||||||
|
if m.runErr != nil {
|
||||||
|
fmt.Fprintf(&b, "error: %v\n", m.runErr)
|
||||||
|
}
|
||||||
|
b.WriteString("\n[q] quit\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString("\n[↑/↓] navigate [enter] toggle log [q] abort\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary returns the engine summary once the run has completed, or nil.
|
||||||
|
func (m *Model) Summary() *engine.Summary { return m.summary }
|
||||||
|
|
||||||
|
// RunErr returns the engine's top-level error, if any.
|
||||||
|
func (m *Model) RunErr() error { return m.runErr }
|
||||||
|
|
||||||
|
func appendCapped(s []string, line string, max int) []string {
|
||||||
|
s = append(s, line)
|
||||||
|
if len(s) > max {
|
||||||
|
s = s[len(s)-max:]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
56
internal/tui/dashboard/model_test.go
Normal file
56
internal/tui/dashboard/model_test.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package dashboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/engine"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProgressUpdatesRow(t *testing.T) {
|
||||||
|
m := NewModel([]string{"a"}, map[string]string{"a": "Alpha"})
|
||||||
|
m.Update(tui.ProgressMsg{ModuleID: "a", Current: 3, Total: 10, Message: "doing"})
|
||||||
|
if r := m.rowIndex["a"]; r.Current != 3 || r.Total != 10 || r.LastMsg != "doing" {
|
||||||
|
t.Fatalf("row state wrong: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogAppendedBounded(t *testing.T) {
|
||||||
|
m := NewModel([]string{"a"}, nil)
|
||||||
|
for i := 0; i < 300; i++ {
|
||||||
|
m.Update(tui.LogMsg{ModuleID: "a", Line: "x"})
|
||||||
|
}
|
||||||
|
if got := len(m.rowIndex["a"].LogTail); got != 200 {
|
||||||
|
t.Fatalf("log tail = %d, want 200", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoneMsgFlipsIcons(t *testing.T) {
|
||||||
|
m := NewModel([]string{"ok", "bad", "skip"}, nil)
|
||||||
|
m.Update(DoneMsg{Summary: engine.Summary{Results: []engine.ModuleResult{
|
||||||
|
{ID: "ok", Status: engine.StatusSucceeded},
|
||||||
|
{ID: "bad", Status: engine.StatusFailed, Err: errors.New("boom")},
|
||||||
|
{ID: "skip", Status: engine.StatusSkipped},
|
||||||
|
}}})
|
||||||
|
if m.rowIndex["ok"].StatusIcon != "✓" {
|
||||||
|
t.Errorf("ok icon = %q", m.rowIndex["ok"].StatusIcon)
|
||||||
|
}
|
||||||
|
if m.rowIndex["bad"].StatusIcon != "✗" {
|
||||||
|
t.Errorf("bad icon = %q", m.rowIndex["bad"].StatusIcon)
|
||||||
|
}
|
||||||
|
if m.rowIndex["skip"].StatusIcon != "⊘" {
|
||||||
|
t.Errorf("skip icon = %q", m.rowIndex["skip"].StatusIcon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewMentionsModules(t *testing.T) {
|
||||||
|
m := NewModel([]string{"alpha", "bravo"}, nil)
|
||||||
|
out := m.View()
|
||||||
|
for _, want := range []string{"alpha", "bravo", "archduino install"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("view missing %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user