package tui import ( "fmt" "os/exec" "runtime" "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")) warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("208")) ) type viewMode int const ( modeSummary viewMode = iota modeDiff ) // ── messages ────────────────────────────────────────────────────────────────── type stacksLoadedMsg []stackItem type updateDoneMsg struct{ err error } type deployDoneMsg struct{ err error } type pruneDoneMsg struct{ err error } type envExampleWrittenMsg struct{ err error } type errMsg error // ── list item ───────────────────────────────────────────────────────────────── type stackItem struct { remote *portainer.Stack // nil = not deployed local *stack.Local driftResult *drift.Result } func (s stackItem) name() string { if s.remote != nil { return s.remote.Name } if s.local != nil { return s.local.Name } return "unknown" } func (s stackItem) Title() string { name := s.name() if s.remote == nil { return warnStyle.Render(name) } if s.driftResult != nil && s.driftResult.HasDrift() { name += driftStyle.Render(" ~") } return name } func (s stackItem) Description() string { if s.remote == nil { return warnStyle.Render("not deployed") } 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.name() } // ── model ───────────────────────────────────────────────────────────────────── type model struct { list list.Model spinner spinner.Model client *portainer.Client svcPath string ignore map[string]bool logsURL string // base URL for Dozzle mode viewMode updating bool status string width int height int } func initialModel(client *portainer.Client, svcPath string, ignore []string, logsURL string) model { l := list.New(nil, list.NewDefaultDelegate(), 0, 0) l.Title = "knecht" l.Styles.Title = titleStyle l.SetShowHelp(false) s := spinner.New() s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) ignoreSet := make(map[string]bool, len(ignore)) for _, name := range ignore { ignoreSet[name] = true } return model{list: l, spinner: s, client: client, svcPath: svcPath, ignore: ignoreSet, logsURL: logsURL} } // ── commands ────────────────────────────────────────────────────────────────── func loadStacks(client *portainer.Client, svcPath string, ignore map[string]bool) 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 } // Remote stacks (deployed), with optional local match remoteNames := make(map[string]bool, len(remotes)) items := make([]stackItem, 0, len(remotes)+len(locals)) for _, r := range remotes { if ignore[r.Name] { continue } rCopy := r remoteNames[r.Name] = true item := stackItem{remote: &rCopy} if l, ok := localByName[r.Name]; ok { lCopy := l item.local = &lCopy item.driftResult = computeDrift(client, &rCopy, &lCopy) } items = append(items, item) } // Local-only stacks (not deployed) for _, l := range locals { if ignore[l.Name] || remoteNames[l.Name] { continue } lCopy := l items = append(items, stackItem{local: &lCopy}) } return stacksLoadedMsg(items) } } 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} } } func deployStack(client *portainer.Client, s stackItem) tea.Cmd { return func() tea.Msg { compose, err := s.local.ReadCompose() if err != nil { return deployDoneMsg{err} } exampleKeys, _ := s.local.EnvExampleKeys() env := make([]portainer.EnvVar, len(exampleKeys)) for i, k := range exampleKeys { env[i] = portainer.EnvVar{Name: k} } _, err = client.CreateStack(s.local.Name, compose, env) return deployDoneMsg{err} } } func openLogs(client *portainer.Client, s stackItem, baseURL string) tea.Cmd { return func() tea.Msg { url := baseURL // Try to deep-link to a container with the same name as the stack id, err := client.FindContainer(s.name()) if err == nil && id != "" { shortID := id if len(shortID) > 12 { shortID = shortID[:12] } url = baseURL + "/container/" + shortID } openBrowser(url) return nil } } func openBrowser(url string) { var cmd *exec.Cmd switch runtime.GOOS { case "darwin": cmd = exec.Command("open", url) case "linux": cmd = exec.Command("xdg-open", url) default: return } cmd.Start() //nolint } func pruneEnvVars(client *portainer.Client, s stackItem) tea.Cmd { return func() tea.Msg { if s.remote == nil || s.local == nil || s.driftResult == nil { return pruneDoneMsg{fmt.Errorf("no stack info")} } exampleKeys, err := s.local.EnvExampleKeys() if err != nil || exampleKeys == nil { return pruneDoneMsg{fmt.Errorf("no .env.example to prune against")} } allowed := make(map[string]bool, len(exampleKeys)) for _, k := range exampleKeys { allowed[k] = true } filtered := make([]portainer.EnvVar, 0, len(s.remote.Env)) for _, e := range s.remote.Env { if allowed[e.Name] { filtered = append(filtered, e) } } compose, err := s.local.ReadCompose() if err != nil { return pruneDoneMsg{err} } _, err = client.UpdateStack(s.remote.ID, compose, filtered) return pruneDoneMsg{err} } } 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")} } if len(s.driftResult.PortainerOnlyKeys) > 0 { return envExampleWrittenMsg{s.local.WriteEnvExample(s.driftResult.PortainerOnlyKeys)} } if len(s.driftResult.UnknownKeys) > 0 { return envExampleWrittenMsg{s.local.AppendEnvExample(s.driftResult.UnknownKeys)} } return envExampleWrittenMsg{fmt.Errorf("nothing to write")} } } // ── bubbletea ───────────────────────────────────────────────────────────────── func (m model) Init() tea.Cmd { return tea.Batch(loadStacks(m.client, m.svcPath, m.ignore), 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-2) 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 — refreshing...") cmds = append(cmds, loadStacks(m.client, m.svcPath, m.ignore)) } case deployDoneMsg: m.updating = false if msg.err != nil { m.status = errorStyle.Render("Deploy failed: " + msg.err.Error()) } else { m.status = successStyle.Render("Deployed — refreshing...") cmds = append(cmds, loadStacks(m.client, m.svcPath, m.ignore)) } case pruneDoneMsg: m.updating = false if msg.err != nil { m.status = errorStyle.Render("Prune failed: " + msg.err.Error()) } else { m.status = successStyle.Render("Unknown env keys removed — refreshing...") cmds = append(cmds, loadStacks(m.client, m.svcPath, m.ignore)) } 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 written — refreshing...") cmds = append(cmds, loadStacks(m.client, m.svcPath, m.ignore)) } 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: 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, m.ignore)) return m, tea.Batch(cmds...) case "d": item, ok := m.list.SelectedItem().(stackItem) if !ok || item.remote == nil { return m, nil // no diff for undeployed stacks } 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 || item.remote == 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 "D": item, ok := m.list.SelectedItem().(stackItem) if !ok || item.local == nil || item.remote != nil || m.updating { return m, nil } m.updating = true m.status = "" cmds = append(cmds, deployStack(m.client, item), m.spinner.Tick) return m, tea.Batch(cmds...) case "l": item, ok := m.list.SelectedItem().(stackItem) if !ok { return m, nil } return m, openLogs(m.client, item, m.logsURL) case "c": item, ok := m.list.SelectedItem().(stackItem) if !ok || item.driftResult == nil || len(item.driftResult.UnknownKeys) == 0 || m.updating { return m, nil } m.updating = true m.status = "" cmds = append(cmds, pruneEnvVars(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() + " Working..." } 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 } const marginLeft = 4 const borderWidth = 2 rightWidth := m.width - lipgloss.Width(left) - marginLeft - borderWidth right := lipgloss.NewStyle(). Width(rightWidth). Height(m.height - 4). Padding(1, 2). MarginLeft(marginLeft). Border(lipgloss.RoundedBorder()). Render(detail) return lipgloss.JoinHorizontal(lipgloss.Top, left, right) } func renderSummary(s stackItem) string { out := titleStyle.Render(s.name()) + "\n\n" if s.remote == nil { out += fmt.Sprintf("status: %s\n", warnStyle.Render("not deployed")) out += "\n" + mutedStyle.Render("[D] deploy [r] refresh [q] quit") return out } 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") out += "\n" + mutedStyle.Render("[r] refresh [q] quit") 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 [c] remove from Portainer") + "\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 [l] logs [r] refresh [esc] back [q] quit") return out } func renderDiff(s stackItem) string { out := titleStyle.Render(s.name()+" — diff") + "\n\n" if s.driftResult == nil { return out + mutedStyle.Render("no local compose file") } 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" } } } out += "\n" + mutedStyle.Render("env keys:") + "\n" if len(s.driftResult.MissingKeys) == 0 && len(s.driftResult.UnknownKeys) == 0 && len(s.driftResult.PortainerOnlyKeys) == 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 { result.PortainerOnlyKeys = portainerKeys } else { result.MissingKeys, result.UnknownKeys = drift.EnvKeys(exampleKeys, portainerKeys) } return result } func Run(client *portainer.Client, svcPath string, cfg *config.Config) error { logsURL := deriveLogsURL(cfg.URL) p := tea.NewProgram(initialModel(client, svcPath, cfg.Ignore, logsURL), tea.WithAltScreen()) _, err := p.Run() return err } func deriveLogsURL(portainerURL string) string { domain := strings.TrimPrefix(strings.TrimPrefix(portainerURL, "https://"), "http://") if idx := strings.Index(domain, "."); idx != -1 { return "https://logs." + domain[idx+1:] } return "https://logs." + domain }