Files
Felis/cmd/felis/tui_preflight.go

244 lines
5.8 KiB
Go

package main
import (
"fmt"
"felis.lolicon.best/internal/config"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
)
// preflightModel auto-verifies the control plane before the operator does any
// hands-on configuration: PostgreSQL must be reachable and schema migrations
// must be applied. Both are expected to already be true after host bootstrap;
// this stage simply proves it (and self-heals pending migrations) so the rest
// of the wizard can assume a healthy backend. It is non-interactive — it runs
// to completion and reports back to the root via preflightDoneMsg.
type preflightModel struct {
db config.DatabaseConfig
rootDomain string
adminHost string
sp spinner.Model
state pfState
err error
dbOK bool
applied int
total int
migrated bool
panelOK bool
panelNote string
}
type pfState int
const (
pfCheckDB pfState = iota
pfCheckMig
pfApplyMig
pfCheckPanel
pfDone
pfError
)
type pfDBMsg struct{ err error }
type pfMigCheckMsg struct {
applied int
total int
pending bool
err error
}
type pfMigApplyMsg struct {
applied int
err error
}
type pfPanelMsg struct{ err error }
func newPreflightModel(db config.DatabaseConfig, rootDomain, adminHostname string) *preflightModel {
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = tuiLabel
return &preflightModel{db: db, rootDomain: rootDomain, adminHost: adminHostname, sp: sp, state: pfCheckDB}
}
func (m *preflightModel) Init() tea.Cmd {
return tea.Batch(m.sp.Tick, m.checkDB())
}
func (m *preflightModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case pfDBMsg:
if msg.err != nil {
m.state, m.err = pfError, msg.err
return m, nil
}
m.dbOK = true
m.state = pfCheckMig
return m, m.checkMigrations()
case pfMigCheckMsg:
if msg.err != nil {
m.state, m.err = pfError, msg.err
return m, nil
}
m.applied, m.total = msg.applied, msg.total
if msg.pending {
m.state = pfApplyMig
return m, m.applyMigrations()
}
m.state = pfCheckPanel
return m, m.checkPanel()
case pfMigApplyMsg:
if msg.err != nil {
m.state, m.err = pfError, msg.err
return m, nil
}
m.applied, m.migrated = msg.applied, true
m.state = pfCheckPanel
return m, m.checkPanel()
case pfPanelMsg:
// Non-blocking: a panel that isn't serving yet is a warning, not a wall —
// it is often still starting. The operator can proceed regardless.
if msg.err != nil {
m.panelNote = "not serving yet (it may still be starting): " + msg.err.Error()
} else {
m.panelOK = true
}
m.state = pfDone
return m, m.finish()
case spinner.TickMsg:
var cmd tea.Cmd
m.sp, cmd = m.sp.Update(msg)
return m, cmd
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
case "r", "R", "enter":
if m.state == pfError {
m.state, m.err = pfCheckDB, nil
m.dbOK, m.migrated, m.panelOK, m.panelNote = false, false, false, ""
return m, tea.Batch(m.sp.Tick, m.checkDB())
}
}
}
return m, nil
}
func (m *preflightModel) View() string {
var b string
b += tuiLabel.Render("Preflight") + " " + tuiHint.Render("verifying the control plane before configuration") + "\n\n"
b += m.line(m.dbOK, m.state == pfCheckDB, "PostgreSQL reachable", "")
migDetail := ""
if m.total > 0 {
migDetail = pluralMigrations(m.applied, m.total)
if m.migrated {
migDetail += " (just applied)"
}
}
migDone := m.state == pfCheckPanel || m.state == pfDone
migActive := m.state == pfCheckMig || m.state == pfApplyMig
b += m.line(migDone, migActive, "Database migrations", migDetail)
// Panel serving is best-effort: ✓ when reachable, ○ + note otherwise.
panelIcon := tuiIconOpt
panelDetail := m.panelNote
switch {
case m.state == pfCheckPanel:
panelIcon = m.sp.View()
case m.panelOK:
panelIcon, panelDetail = tuiIconOK, "serving locally"
}
b += " " + panelIcon + " " + tuiLabel.Render("Panel serving")
if panelDetail != "" {
b += " " + tuiHint.Render(panelDetail)
}
b += "\n"
b += "\n" + tuiSeparator() + "\n"
switch m.state {
case pfError:
b += "\n" + tuiErrorBanner(m.errText()) + "\n\n"
b += tuiAction("r", "retry", "esc", "exit")
case pfDone:
b += tuiHint.Render("Ready.") + "\n"
default:
b += tuiAction("esc", "cancel")
}
return b
}
func (m *preflightModel) line(done, active bool, label, detail string) string {
icon := tuiIconOpt
switch {
case done:
icon = tuiIconOK
case active:
icon = m.sp.View()
}
s := " " + icon + " " + tuiLabel.Render(label)
if detail != "" {
s += " " + tuiHint.Render(detail)
}
return s + "\n"
}
func (m *preflightModel) errText() string {
if m.err == nil {
return "preflight failed"
}
return m.err.Error()
}
func (m *preflightModel) checkDB() tea.Cmd {
return func() tea.Msg { return pfDBMsg{err: checkPostgres(m.db.URL)} }
}
func (m *preflightModel) checkMigrations() tea.Cmd {
return func() tea.Msg {
// The sets, not their sizes: a database a newer release migrated can hold as
// many rows as this build has migrations, and must stop here rather than be
// "healed" by an older binary.
s, err := readSchema(m.db.URL)
if err == nil {
err = s.Newer()
}
return pfMigCheckMsg{applied: s.Applied, total: s.Total, pending: len(s.Pending) > 0, err: err}
}
}
func (m *preflightModel) applyMigrations() tea.Cmd {
return func() tea.Msg {
applied, err := applyMigrations(m.db)
return pfMigApplyMsg{applied: applied, err: err}
}
}
func (m *preflightModel) checkPanel() tea.Cmd {
return func() tea.Msg {
return pfPanelMsg{err: checkPanelAccess(m.rootDomain, m.adminHost).err}
}
}
func (m *preflightModel) finish() tea.Cmd {
return func() tea.Msg { return preflightDoneMsg{} }
}
func pluralMigrations(applied, total int) string {
if applied == total {
return fmt.Sprintf("%d applied", total)
}
return fmt.Sprintf("%d of %d applied", applied, total)
}