Files
llmgw/internal/cli/tui_node.go
Ilker ManapandClaude Opus 5.5 aa3efcebbe Add a standby control plane with server add, and draw the screens as windows
server add gives another machine this llmgw, a certificate, the
configuration and store address, lets it into the store through Patroni, and
starts it; --vip keeps a floating address on the leader with keepalived,
following a new /leader endpoint. Nodes learn every control plane from the
leader and notice a dead one by HTTP/2 ping instead of after the kernel's
fifteen minutes. The setup, console, node and init screens are drawn full
screen in one frame: title bar, sidebar, bordered panel, key bar, dialogs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 22:29:12 +02:00

387 lines
9.9 KiB
Go

package cli
import (
"context"
"fmt"
"io"
"os"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-isatty"
"gitea.mynodes.xyz/ilker/llmgw/internal/onboard"
)
// The full-screen way through 'llmgw node add': the same steps as the plain
// one, drawn as a checklist that fills in, with the questions a script
// cannot ask — which fabric the adapters are on, whether to go on past a
// problem — asked in place.
func isTerminal(w io.Writer) bool {
f, ok := w.(*os.File)
return ok && isatty.IsTerminal(f.Fd()) && os.Getenv("TERM") != "dumb"
}
var (
tuiAccent = lipgloss.AdaptiveColor{Light: "#0B7F7D", Dark: "#3FC2BC"}
tuiOK = lipgloss.AdaptiveColor{Light: "#1F8A4C", Dark: "#6FD3A6"}
tuiWarn = lipgloss.AdaptiveColor{Light: "#A15C00", Dark: "#E9B35A"}
tuiBad = lipgloss.AdaptiveColor{Light: "#B42318", Dark: "#F07C6C"}
tuiDim = lipgloss.AdaptiveColor{Light: "#56696C", Dark: "#8FA5A4"}
sTitle = lipgloss.NewStyle().Bold(true).Foreground(tuiAccent)
sStep = lipgloss.NewStyle().Width(14)
sDim = lipgloss.NewStyle().Foreground(tuiDim)
sOK = lipgloss.NewStyle().Foreground(tuiOK)
sWarn = lipgloss.NewStyle().Foreground(tuiWarn)
sBad = lipgloss.NewStyle().Foreground(tuiBad)
sBox = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(tuiAccent).Padding(0, 1)
)
// Messages from the onboarding goroutine to the screen.
type (
evMsg onboard.Event
finishMsg struct {
node, summary string
err error
}
askFabricsMsg struct {
suggested []onboard.Fabric
reply chan []onboard.Fabric
}
askContinueMsg struct {
fails int
reply chan bool
}
)
type stepState struct {
level, detail string
}
type nodeModel struct {
title string
steps map[string]stepState
notes []onboard.Event // warnings and failures, with their fixes
spin spinnerModel
cancel context.CancelFunc
width int
height int
done bool
err error
node string
summary string
// The question being asked, if any.
fabrics *askFabricsMsg
inputs []textField
focus int
confirm *askContinueMsg
quitAsked bool
}
func newNodeModel(title string, cancel context.CancelFunc) nodeModel {
sp := newSpinner()
return nodeModel{title: title, steps: map[string]stepState{}, spin: sp, cancel: cancel, width: 100}
}
func (m nodeModel) Init() tea.Cmd { return m.spin.Tick }
func (m nodeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case spinTickMsg:
var cmd tea.Cmd
m.spin, cmd = m.spin.Update(msg)
return m, cmd
case evMsg:
e := onboard.Event(msg)
switch e.Level {
case onboard.Running, onboard.Done:
m.steps[e.Step] = stepState{e.Level, e.Detail}
case onboard.Fail:
m.steps[e.Step] = stepState{e.Level, e.Detail}
if e.Step == onboard.StepCheck && strings.Contains(e.Detail, ":") {
m.notes = append(m.notes, e)
}
case onboard.Warn:
m.notes = append(m.notes, e)
case onboard.Info:
if e.Step == onboard.StepEngines {
m.steps[e.Step] = stepState{onboard.Running, e.Detail}
}
}
case askFabricsMsg:
m.fabrics = &msg
m.inputs = nil
for i, f := range msg.suggested {
in := newTextField()
in.Prompt = ""
in.SetValue(f.Name)
in.CharLimit = 40
in.Width = 24
if i == 0 {
in.Focus()
}
m.inputs = append(m.inputs, in)
}
m.focus = 0
case askContinueMsg:
m.confirm = &msg
case finishMsg:
// Left on the screen until a key: the frame is gone once it closes.
m.done, m.err, m.node, m.summary = true, msg.err, msg.node, msg.summary
return m, nil
case tea.KeyMsg:
if m.done && (msg.Type == tea.KeyEnter || msg.String() == "q" || msg.Type == tea.KeyEsc) {
return m, tea.Quit
}
return m.key(msg)
}
return m, nil
}
func (m nodeModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
if k.Type == tea.KeyCtrlC {
m.cancel()
m.quitAsked = true
return m, tea.Quit
}
switch {
case m.confirm != nil:
switch strings.ToLower(k.String()) {
case "y":
m.confirm.reply <- true
m.confirm = nil
case "n", "enter", "esc":
m.confirm.reply <- false
m.confirm = nil
}
return m, nil
case m.fabrics != nil:
switch k.Type {
case tea.KeyTab, tea.KeyDown:
m.move(1)
return m, nil
case tea.KeyShiftTab, tea.KeyUp:
m.move(-1)
return m, nil
case tea.KeyEsc:
m.fabrics.reply <- nil
m.fabrics = nil
return m, nil
case tea.KeyEnter:
var out []onboard.Fabric
for i, in := range m.inputs {
if name := strings.TrimSpace(in.Value()); name != "" {
out = append(out, onboard.Fabric{Name: name, HCAs: m.fabrics.suggested[i].HCAs, Key: m.fabrics.suggested[i].Key})
}
}
m.fabrics.reply <- out
m.fabrics = nil
return m, nil
}
var cmd tea.Cmd
m.inputs[m.focus], cmd = m.inputs[m.focus].Update(k)
return m, cmd
}
if k.String() == "q" {
m.cancel()
m.quitAsked = true
return m, tea.Quit
}
return m, nil
}
func (m *nodeModel) move(d int) {
m.inputs[m.focus].Blur()
m.focus = (m.focus + d + len(m.inputs)) % len(m.inputs)
m.inputs[m.focus].Focus()
}
// View draws the onboarding in the frame: its steps down the side, what each
// did in the panel, a question as a dialog.
func (m nodeModel) View() string {
var nav []navItem
current := false
for _, step := range onboard.Steps {
st, ok := m.steps[step]
state := "todo"
switch {
case ok && st.level == onboard.Running && !m.done && !current:
state, current = "current", true
case ok && st.level != onboard.Running:
state = "done"
}
nav = append(nav, navItem{Label: step, State: state})
}
keys := "Ctrl+C stop"
if m.done {
keys = "Enter close"
}
host, _ := os.Hostname()
f := frame{
Width: m.width, Height: m.height, Title: "llmgw · add a node", Where: host,
NavTitle: "Steps", Nav: nav, PanelTitle: m.title, Body: "\n" + m.content(), Keys: keys,
}
switch {
case m.fabrics != nil:
var q strings.Builder
q.WriteString("These adapters are up. Which fabric is each group on?\n")
q.WriteString(sDim.Render("Give each the name it has on every other node attached to it.\nLeave a name empty to leave that group out.") + "\n\n")
for i, fb := range m.fabrics.suggested {
fmt.Fprintf(&q, " %s %s\n", m.inputs[i].View(), sDim.Render(strings.Join(fb.HCAs, ", ")))
}
q.WriteString("\n" + sDim.Render("Tab next · Enter use these · Esc none"))
f.DialogTitle, f.Dialog = "Fabrics", q.String()
case m.confirm != nil:
f.DialogTitle = "Problems found"
f.Dialog = fmt.Sprintf("doctor found %d problem(s) on the machine.\nAdd the node anyway?\n\n%s", m.confirm.fails, sDim.Render("Y yes · N no"))
}
return f.render()
}
// content is the onboarding's steps and notes, as the panel shows them.
func (m nodeModel) content() string {
var b strings.Builder
detailWidth := max(20, m.width-navWidth-26)
for _, step := range onboard.Steps {
st, ok := m.steps[step]
var mark string
switch {
case !ok:
mark = sDim.Render("·")
case st.level == onboard.Running && !m.done:
mark = m.spin.View()
case st.level == onboard.Fail:
mark = sBad.Render("✗")
case st.level == onboard.Running:
mark = sDim.Render("·")
default:
mark = sOK.Render("✓")
}
name := sStep.Render(step)
if !ok {
name = sDim.Render(sStep.Render(step))
}
fmt.Fprintf(&b, " %s %s %s\n", mark, name, truncate(st.detail, detailWidth))
}
if len(m.notes) > 0 {
b.WriteString("\n")
for _, n := range m.notes {
mark := sWarn.Render("!")
if n.Level == onboard.Fail {
mark = sBad.Render("✗")
}
fmt.Fprintf(&b, " %s %s\n", mark, wrap(n.Detail, m.width-6, " "))
if n.Fix != "" {
fmt.Fprintf(&b, " %s\n", sDim.Render("→ "+wrap(n.Fix, m.width-8, " ")))
}
}
}
switch {
case m.done && m.err != nil:
b.WriteString("\n" + sBad.Render("✗ ") + wrap(m.err.Error(), m.width-4, " ") + "\n")
case m.done:
b.WriteString("\n" + sOK.Render("✓ ") + m.summary + "\n")
case m.quitAsked:
b.WriteString("\n" + sDim.Render("stopped") + "\n")
}
return b.String()
}
func truncate(s string, n int) string {
if n <= 1 || len([]rune(s)) <= n {
return s
}
r := []rune(s)
return string(r[:n-1]) + "…"
}
// wrap breaks s at spaces to fit width, indenting every line after the first.
func wrap(s string, width int, indent string) string {
if width < 20 {
return s
}
var lines []string
line := ""
for _, w := range strings.Fields(s) {
if line != "" && len(line)+1+len(w) > width {
lines = append(lines, line)
line = w
continue
}
if line == "" {
line = w
} else {
line += " " + w
}
}
lines = append(lines, line)
return strings.Join(lines, "\n"+indent)
}
// runTUI adds one node with the screen above.
func (na *nodeAdd) runTUI(ctx context.Context, o onboard.Options, title string) (string, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
m := newNodeModel(title, cancel)
p := tea.NewProgram(m, tea.WithAltScreen())
emit := func(e onboard.Event) { p.Send(evMsg(e)) }
o.AskFabrics = na.rememberFabrics(func(s []onboard.Fabric) []onboard.Fabric {
reply := make(chan []onboard.Fabric)
p.Send(askFabricsMsg{s, reply})
select {
case f := <-reply:
return f
case <-ctx.Done():
return nil
}
})
o.AskContinue = func(fails int) bool {
reply := make(chan bool)
p.Send(askContinueMsg{fails, reply})
select {
case ok := <-reply:
return ok
case <-ctx.Done():
return false
}
}
go func() {
res, err := onboard.New(o, emit).Run(ctx)
node := ""
if res != nil {
node = res.Node
}
if err == nil && !na.noWait {
err = na.watch(ctx, node, emit)
}
summary := ""
if err == nil {
summary = node + " is in the cluster"
}
p.Send(finishMsg{node, summary, err})
}()
final, err := p.Run()
if err != nil {
return "", err
}
fm := final.(nodeModel)
// The screen is gone; what it ended with is said again where it stays.
fmt.Println(fm.content())
if fm.quitAsked && !fm.done {
cancel()
return fm.node, fmt.Errorf("stopped; the node may be half set up, and running this again finishes it")
}
return fm.node, fm.err
}