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) }