package tui import ( "fmt" "strings" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/jensbecker/homelab/knecht/config" "github.com/jensbecker/homelab/knecht/drift" "github.com/jensbecker/homelab/knecht/portainer" "github.com/jensbecker/homelab/knecht/stack" ) var ( titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) successStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("10")) mutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) driftStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) addStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) removeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) ) type viewMode int const ( modeSummary viewMode = iota modeDiff ) // ── messages ───────────────────────────────────────────────────────────────── type stacksLoadedMsg []stackItem type updateDoneMsg struct{ err error } type envExampleWrittenMsg struct{ err error } type errMsg error // ── list item ──────────────────────────────────────────────────────────────── type stackItem struct { remote portainer.Stack local *stack.Local driftResult *drift.Result } func (s stackItem) Title() string { name := s.remote.Name if s.driftResult != nil && s.driftResult.HasDrift() { name += driftStyle.Render(" ~") } return name } func (s stackItem) Description() string { status := "running" if s.remote.Status != 1 { status = mutedStyle.Render("stopped") } if s.driftResult == nil { return status + " · no local compose" } return status + " · " + s.driftResult.Summary() } func (s stackItem) FilterValue() string { return s.remote.Name } // ── model ──────────────────────────────────────────────────────────────────── type model struct { list list.Model spinner spinner.Model client *portainer.Client svcPath string mode viewMode updating bool status string // transient success/error message width int height int } func initialModel(client *portainer.Client, svcPath string) model { l := list.New(nil, list.NewDefaultDelegate(), 0, 0) l.Title = "knecht" l.Styles.Title = titleStyle l.SetShowHelp(false) // we render our own key hints s := spinner.New() s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) return model{ list: l, spinner: s, client: client, svcPath: svcPath, } } // ── commands ────────────────────────────────────────────────────────────────── func loadStacks(client *portainer.Client, svcPath string) tea.Cmd { return func() tea.Msg { remotes, err := client.ListStacks() if err != nil { return errMsg(err) } locals, _ := stack.Discover(svcPath) localByName := make(map[string]stack.Local, len(locals)) for _, l := range locals { localByName[l.Name] = l } items := make([]stackItem, 0, len(remotes)) for _, r := range remotes { item := stackItem{remote: r} if l, ok := localByName[r.Name]; ok { lCopy := l item.local = &lCopy item.driftResult = computeDrift(client, &r, &lCopy) } items = append(items, item) } return stacksLoadedMsg(items) } } func writeEnvExample(s stackItem) tea.Cmd { return func() tea.Msg { if s.local == nil || s.driftResult == nil { return envExampleWrittenMsg{fmt.Errorf("no local stack info")} } // No .env.example at all — create from scratch if len(s.driftResult.PortainerOnlyKeys) > 0 { return envExampleWrittenMsg{s.local.WriteEnvExample(s.driftResult.PortainerOnlyKeys)} } // .env.example exists but has unknown keys — append them if len(s.driftResult.UnknownKeys) > 0 { return envExampleWrittenMsg{s.local.AppendEnvExample(s.driftResult.UnknownKeys)} } return envExampleWrittenMsg{fmt.Errorf("nothing to write")} } } func updateStack(client *portainer.Client, s stackItem) tea.Cmd { return func() tea.Msg { compose, err := s.local.ReadCompose() if err != nil { return updateDoneMsg{err} } _, err = client.UpdateStack(s.remote.ID, compose, s.remote.Env) return updateDoneMsg{err} } } // ── bubbletea ──────────────────────────────────────────────────────────────── func (m model) Init() tea.Cmd { return tea.Batch(loadStacks(m.client, m.svcPath), m.spinner.Tick) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.list.SetSize(msg.Width/2, msg.Height-4) case stacksLoadedMsg: m.updating = false items := make([]list.Item, len(msg)) for i, s := range msg { sCopy := s items[i] = sCopy } m.list.SetItems(items) case updateDoneMsg: m.updating = false if msg.err != nil { m.status = errorStyle.Render("Update failed: " + msg.err.Error()) } else { m.status = successStyle.Render("Updated successfully — refreshing...") cmds = append(cmds, loadStacks(m.client, m.svcPath)) } case envExampleWrittenMsg: if msg.err != nil { m.status = errorStyle.Render("Failed to write .env.example: " + msg.err.Error()) } else { m.status = successStyle.Render(".env.example created — refreshing...") cmds = append(cmds, loadStacks(m.client, m.svcPath)) } case errMsg: m.updating = false m.status = errorStyle.Render("Error: " + msg.Error()) case spinner.TickMsg: if m.updating { var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) cmds = append(cmds, cmd) } case tea.KeyMsg: // Don't pass keys to the list while filtering is active if m.list.SettingFilter() { break } switch msg.String() { case "q", "ctrl+c": return m, tea.Quit case "r": m.status = "" m.mode = modeSummary cmds = append(cmds, loadStacks(m.client, m.svcPath)) return m, tea.Batch(cmds...) case "d": if m.mode == modeDiff { m.mode = modeSummary } else { m.mode = modeDiff } return m, nil case "u": item, ok := m.list.SelectedItem().(stackItem) if !ok || item.local == nil || m.updating { return m, nil } m.updating = true m.status = "" m.mode = modeSummary cmds = append(cmds, updateStack(m.client, item), m.spinner.Tick) return m, tea.Batch(cmds...) case "e": item, ok := m.list.SelectedItem().(stackItem) if !ok || item.driftResult == nil { return m, nil } hasEnvDrift := len(item.driftResult.PortainerOnlyKeys) > 0 || len(item.driftResult.UnknownKeys) > 0 if !hasEnvDrift { return m, nil } cmds = append(cmds, writeEnvExample(item)) return m, tea.Batch(cmds...) case "esc": m.mode = modeSummary m.status = "" return m, nil } } var listCmd tea.Cmd m.list, listCmd = m.list.Update(msg) cmds = append(cmds, listCmd) return m, tea.Batch(cmds...) } // ── views ───────────────────────────────────────────────────────────────────── func (m model) View() string { left := m.list.View() var detail string if m.updating { detail = m.spinner.View() + " Updating..." } else if item, ok := m.list.SelectedItem().(stackItem); ok { switch m.mode { case modeDiff: detail = renderDiff(item) default: detail = renderSummary(item) } } if m.status != "" { detail += "\n\n" + m.status } right := lipgloss.NewStyle(). Width(m.width/2 - 2). Height(m.height - 4). Padding(1, 2). Border(lipgloss.RoundedBorder()). Render(detail) return lipgloss.JoinHorizontal(lipgloss.Top, left, right) } func renderSummary(s stackItem) string { out := titleStyle.Render(s.remote.Name) + "\n\n" if s.remote.Status == 1 { out += fmt.Sprintf("status: %s\n", successStyle.Render("running")) } else { out += fmt.Sprintf("status: %s\n", mutedStyle.Render("stopped")) } if s.driftResult == nil { out += mutedStyle.Render("no local compose file\n") return out } out += fmt.Sprintf("drift: %s\n", s.driftResult.Summary()) if len(s.driftResult.MissingKeys) > 0 { out += "\n" + driftStyle.Render("missing env keys:") + "\n" for _, k := range s.driftResult.MissingKeys { out += fmt.Sprintf(" ! %s\n", k) } } if len(s.driftResult.UnknownKeys) > 0 { out += "\n" + mutedStyle.Render("unknown env keys (in Portainer, not in .env.example):") + "\n" for _, k := range s.driftResult.UnknownKeys { out += fmt.Sprintf(" ? %s\n", k) } out += mutedStyle.Render(" [e] append to .env.example") + "\n" } if len(s.driftResult.PortainerOnlyKeys) > 0 { out += "\n" + driftStyle.Render("no .env.example — Portainer has:") + "\n" for _, k := range s.driftResult.PortainerOnlyKeys { out += fmt.Sprintf(" • %s\n", k) } out += mutedStyle.Render(" [e] generate .env.example") + "\n" } out += "\n" + mutedStyle.Render("[u] update [d] diff [r] refresh [esc] back [q] quit") return out } func renderDiff(s stackItem) string { out := titleStyle.Render(s.remote.Name+" — diff") + "\n\n" if s.driftResult == nil { return out + mutedStyle.Render("no local compose file") } // Compose diff out += mutedStyle.Render("compose:") + "\n" if len(s.driftResult.ComposeDiff) == 0 { out += " " + successStyle.Render("in sync") + "\n" } else { for _, line := range s.driftResult.ComposeDiff { if strings.HasPrefix(line, "+") { out += " " + addStyle.Render(line) + "\n" } else { out += " " + removeStyle.Render(line) + "\n" } } } // Env key diff out += "\n" + mutedStyle.Render("env keys:") + "\n" if len(s.driftResult.MissingKeys) == 0 && len(s.driftResult.UnknownKeys) == 0 { out += " " + successStyle.Render("in sync") + "\n" } for _, k := range s.driftResult.MissingKeys { out += " " + driftStyle.Render("! missing: "+k) + "\n" } for _, k := range s.driftResult.UnknownKeys { out += " " + mutedStyle.Render("? unknown: "+k) + "\n" } if len(s.driftResult.PortainerOnlyKeys) > 0 { out += "\n" + driftStyle.Render("no .env.example — Portainer has:") + "\n" for _, k := range s.driftResult.PortainerOnlyKeys { out += fmt.Sprintf(" • %s\n", k) } } out += "\n" + mutedStyle.Render("[d] back [u] update [e] generate .env.example [q] quit") return out } // ── helpers ─────────────────────────────────────────────────────────────────── func computeDrift(client *portainer.Client, s *portainer.Stack, local *stack.Local) *drift.Result { remoteCompose, err := client.GetStackFile(s.ID) if err != nil { return nil } localCompose, _ := local.ReadCompose() exampleKeys, _ := local.EnvExampleKeys() portainerKeys := make([]string, len(s.Env)) for i, e := range s.Env { portainerKeys[i] = e.Name } result := &drift.Result{ ComposeDiff: drift.Compose(localCompose, remoteCompose), } if exampleKeys == nil && len(portainerKeys) > 0 { // No .env.example but Portainer has keys — surface as its own drift type result.PortainerOnlyKeys = portainerKeys } else { result.MissingKeys, result.UnknownKeys = drift.EnvKeys(exampleKeys, portainerKeys) } return result } func Run(client *portainer.Client, svcPath string, _ *config.Config) error { p := tea.NewProgram(initialModel(client, svcPath), tea.WithAltScreen()) _, err := p.Run() return err }