From b4fddbb5b62f305978a666bbc33096bbbffd33c3 Mon Sep 17 00:00:00 2001 From: Jens Date: Sat, 4 Apr 2026 15:05:08 +0200 Subject: [PATCH] more knecht --- knecht/README.md | 94 +++++++++++++++++++ knecht/TODO.md | 19 ++++ knecht/cmd/list.go | 19 +++- knecht/cmd/logs.go | 2 +- knecht/cmd/root.go | 2 +- knecht/cmd/update.go | 35 +++++-- knecht/config/config.go | 23 ++--- knecht/tui/form.go | 157 ++++++++++++++++++++++++++++++++ knecht/tui/tui.go | 196 ++++++++++++++++++++++++++++------------ 9 files changed, 463 insertions(+), 84 deletions(-) create mode 100644 knecht/README.md create mode 100644 knecht/TODO.md create mode 100644 knecht/tui/form.go diff --git a/knecht/README.md b/knecht/README.md new file mode 100644 index 0000000..d3dfe9a --- /dev/null +++ b/knecht/README.md @@ -0,0 +1,94 @@ +# knecht + +CLI + TUI for managing Portainer stacks from the local `services/` directory. + +## Installation + +```sh +cd knecht +go build -o knecht . +# move to somewhere on your PATH, e.g.: +mv knecht /usr/local/bin/knecht +``` + +## Configuration + +`~/.config/knecht/config.toml`: + +```toml +url = "https://portainer.example.com" +token = "ptr_..." +ignore = ["portainer"] # stack names to hide (e.g. self-managed containers) + +# Optional +endpoint = "local" # Portainer environment name, auto-detected if omitted +services_path = "/path/to/services" # defaults to git root / services +``` + +The API token is generated in Portainer under **Account → Access tokens**. + +## Commands + +| Command | Description | +|---------|-------------| +| `knecht` | Launch the TUI | +| `knecht list` | Print all stacks with status and drift summary | +| `knecht deploy ` | Deploy a new stack from `services//docker-compose.yml` | +| `knecht update ` | Update an existing stack, preserving Portainer env vars | +| `knecht diff ` | Print compose and env key drift | +| `knecht logs [container]` | Open Dozzle in the browser; deep-links to a specific container if given | + +`` always maps to a folder name under `services/`. + +## TUI + +Run `knecht` with no arguments to launch the interactive TUI. + +``` +┌─ stacks ──────────────┐ ┌─ detail ──────────────────────────────┐ +│ ● traefik live │ │ rrr │ +│ ● rrr live ~ │ │ │ +│ ● jellyfin live │ │ status: running │ +│ dummy not deploy │ │ drift: compose ~3 lines │ +│ │ │ │ +│ │ │ [u] update [d] diff [r] refresh │ +└───────────────────────┘ └──────────────────────────────────────┘ +``` + +### Keybinds + +| Key | Action | +|-----|--------| +| `↑` / `↓` | Navigate stacks | +| `u` | Update selected stack (preserves Portainer env vars) | +| `D` | Deploy selected stack (only available when not deployed) | +| `d` | Toggle diff view | +| `e` | Generate or append `.env.example` from Portainer env keys | +| `r` | Refresh all stacks | +| `esc` | Return to summary view | +| `q` / `ctrl+c` | Quit | + +## Drift Detection + +knecht compares the local `services//docker-compose.yml` against what is deployed in Portainer, and the keys in `services//.env.example` against the env vars set in Portainer. + +| Indicator | Meaning | +|-----------|---------| +| `~` next to stack name | Drift detected | +| `! missing: KEY` | Key is in `.env.example` but not set in Portainer | +| `? unknown: KEY` | Key is in Portainer but not in `.env.example` — press `e` to append | +| `no .env.example` | Portainer has env vars but no example file exists — press `e` to generate | + +Stacks with no `.env.example` file are not penalised for env drift — absence of the file means "no opinion". + +## Stack Convention + +Each stack is a directory under `services/` containing: + +``` +services// +├── docker-compose.yml # required +└── .env.example # optional — documents required Portainer env vars +``` + +`knecht deploy ` / `knecht update ` read these files directly. Env var *values* are never stored locally — only the keys in `.env.example`, as documentation. diff --git a/knecht/TODO.md b/knecht/TODO.md new file mode 100644 index 0000000..7184340 --- /dev/null +++ b/knecht/TODO.md @@ -0,0 +1,19 @@ +# knecht — TODO + +## Functional + +- [x] `knecht update` — replace `fmt.Scanln` env var prompt with a proper TUI input form for missing keys +- [ ] `knecht restart ` — specced but not implemented +- [ ] Compose diff — switch from set-based comparison to a real unified diff (reordered lines currently show as false drift) + +## TUI + +- [ ] Diff view — add scrolling for long diffs (currently clipped) +- [ ] Add `[l]` keybind to open Dozzle logs for the selected stack +- [ ] Confirmation prompt before `[u]` update and `[D]` deploy + +## Housekeeping + +- [ ] Delete `services/dummy/` once done testing local-only stack display +- [ ] Implement `knecht init` to scaffold `~/.config/knecht/config.toml` (error message references it but command doesn't exist) +- [ ] `knecht diff` CLI — apply same color formatting as TUI diff view diff --git a/knecht/cmd/list.go b/knecht/cmd/list.go index f203b28..98f490c 100644 --- a/knecht/cmd/list.go +++ b/knecht/cmd/list.go @@ -13,10 +13,14 @@ var listCmd = &cobra.Command{ Use: "list", Short: "List all stacks with status and drift", RunE: func(cmd *cobra.Command, args []string) error { - _, client, svcPath, err := setup() + cfg, client, svcPath, err := setup() if err != nil { return err } + ignoreSet := make(map[string]bool, len(cfg.Ignore)) + for _, name := range cfg.Ignore { + ignoreSet[name] = true + } stacks, err := client.ListStacks() if err != nil { @@ -35,7 +39,12 @@ var listCmd = &cobra.Command{ fmt.Printf("%-20s %-10s %s\n", "STACK", "STATUS", "DRIFT") fmt.Println(repeat("-", 60)) + remoteNames := make(map[string]bool, len(stacks)) for _, s := range stacks { + if ignoreSet[s.Name] { + continue + } + remoteNames[s.Name] = true status := statusLabel(s.Status) driftSummary := "no local compose" @@ -49,6 +58,14 @@ var listCmd = &cobra.Command{ fmt.Printf("%-20s %-10s %s\n", s.Name, status, driftSummary) } + + // Local-only stacks + for _, l := range locals { + if ignoreSet[l.Name] || remoteNames[l.Name] { + continue + } + fmt.Printf("%-20s %-10s %s\n", l.Name, "not deployed", "-") + } return nil }, } diff --git a/knecht/cmd/logs.go b/knecht/cmd/logs.go index 49b4b9d..db7fc1b 100644 --- a/knecht/cmd/logs.go +++ b/knecht/cmd/logs.go @@ -19,7 +19,7 @@ var logsCmd = &cobra.Command{ return err } - logsBase := deriveLogsURL(cfg.Portainer.URL) + logsBase := deriveLogsURL(cfg.URL) if len(args) == 0 { fmt.Printf("Opening %s\n", logsBase) diff --git a/knecht/cmd/root.go b/knecht/cmd/root.go index c37fcb5..19dc925 100644 --- a/knecht/cmd/root.go +++ b/knecht/cmd/root.go @@ -44,7 +44,7 @@ func setup() (*config.Config, *portainer.Client, string, error) { return nil, nil, "", err } - client, err := portainer.New(cfg.Portainer.URL, cfg.Portainer.Token, cfg.Portainer.Endpoint) + client, err := portainer.New(cfg.URL, cfg.Token, cfg.Endpoint) if err != nil { return nil, nil, "", fmt.Errorf("connecting to Portainer: %w", err) } diff --git a/knecht/cmd/update.go b/knecht/cmd/update.go index 4459e73..0bb5266 100644 --- a/knecht/cmd/update.go +++ b/knecht/cmd/update.go @@ -5,6 +5,7 @@ import ( "github.com/jensbecker/homelab/knecht/portainer" "github.com/jensbecker/homelab/knecht/stack" + "github.com/jensbecker/homelab/knecht/tui" "github.com/spf13/cobra" ) @@ -37,15 +38,19 @@ var updateCmd = &cobra.Command{ return err } - // Check for missing env keys and prompt for values exampleKeys, err := local.EnvExampleKeys() if err != nil { return err } - env, err := mergeEnvVars(remote.Env, exampleKeys) + + env, err := mergeEnvVars(name, remote.Env, exampleKeys) if err != nil { return err } + if env == nil { + fmt.Println("Cancelled.") + return nil + } s, err := client.UpdateStack(remote.ID, compose, env) if err != nil { @@ -56,20 +61,32 @@ var updateCmd = &cobra.Command{ }, } -// mergeEnvVars takes existing Portainer env vars and prompts for any keys -// present in .env.example but missing from Portainer. -func mergeEnvVars(existing []portainer.EnvVar, exampleKeys []string) ([]portainer.EnvVar, error) { +// mergeEnvVars preserves existing Portainer env vars and prompts via TUI for +// any keys present in .env.example but missing from Portainer. +// Returns nil if the user cancelled the form. +func mergeEnvVars(stackName string, existing []portainer.EnvVar, exampleKeys []string) ([]portainer.EnvVar, error) { envMap := make(map[string]string, len(existing)) for _, e := range existing { envMap[e.Name] = e.Value } + var missing []string for _, key := range exampleKeys { if _, ok := envMap[key]; !ok { - fmt.Printf("Missing env var %q — enter value (leave empty to skip): ", key) - var val string - fmt.Scanln(&val) - envMap[key] = val + missing = append(missing, key) + } + } + + if len(missing) > 0 { + values, err := tui.PromptMissingEnv(stackName, missing) + if err != nil { + return nil, err + } + if values == nil { + return nil, nil // cancelled + } + for k, v := range values { + envMap[k] = v } } diff --git a/knecht/config/config.go b/knecht/config/config.go index ced5886..e13e9a7 100644 --- a/knecht/config/config.go +++ b/knecht/config/config.go @@ -8,15 +8,12 @@ import ( "github.com/BurntSushi/toml" ) -type Portainer struct { - URL string `toml:"url"` - Token string `toml:"token"` - Endpoint string `toml:"endpoint"` // optional name override, defaults to auto-discover -} - type Config struct { - Portainer Portainer `toml:"portainer"` - ServicesPath string `toml:"services_path"` // optional, defaults to git root / services + URL string `toml:"url"` + Token string `toml:"token"` + Endpoint string `toml:"endpoint"` // optional, auto-discovered if empty + ServicesPath string `toml:"services_path"` // optional, defaults to git root / services + Ignore []string `toml:"ignore"` } func Load() (*Config, error) { @@ -28,16 +25,16 @@ func Load() (*Config, error) { var cfg Config if _, err := toml.DecodeFile(path, &cfg); err != nil { if os.IsNotExist(err) { - return nil, fmt.Errorf("config file not found at %s — run `knecht init` to create one", path) + return nil, fmt.Errorf("config file not found at %s", path) } return nil, fmt.Errorf("parsing config: %w", err) } - if cfg.Portainer.URL == "" { - return nil, fmt.Errorf("portainer.url is required in %s", path) + if cfg.URL == "" { + return nil, fmt.Errorf("url is required in %s", path) } - if cfg.Portainer.Token == "" { - return nil, fmt.Errorf("portainer.token is required in %s", path) + if cfg.Token == "" { + return nil, fmt.Errorf("token is required in %s", path) } return &cfg, nil diff --git a/knecht/tui/form.go b/knecht/tui/form.go new file mode 100644 index 0000000..6d734b4 --- /dev/null +++ b/knecht/tui/form.go @@ -0,0 +1,157 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) + labelStyle = lipgloss.NewStyle().Width(32) + blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + cursorStyle = focusedStyle +) + +// secretKey returns true for keys that likely contain sensitive values. +func secretKey(key string) bool { + upper := strings.ToUpper(key) + for _, word := range []string{"KEY", "TOKEN", "PASSWORD", "SECRET", "PASS"} { + if strings.Contains(upper, word) { + return true + } + } + return false +} + +type formModel struct { + title string + keys []string + inputs []textinput.Model + cursor int + done bool +} + +func newFormModel(title string, keys []string) formModel { + inputs := make([]textinput.Model, len(keys)) + for i, key := range keys { + t := textinput.New() + t.Placeholder = "enter value" + t.CursorStyle = cursorStyle + if secretKey(key) { + t.EchoMode = textinput.EchoPassword + t.EchoCharacter = '•' + } + if i == 0 { + t.Focus() + t.PromptStyle = focusedStyle + t.TextStyle = focusedStyle + } + inputs[i] = t + } + return formModel{title: title, keys: keys, inputs: inputs} +} + +func (m formModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + m.done = true + m.keys = nil // signal cancelled + return m, tea.Quit + + case "tab", "down": + m.cursor = (m.cursor + 1) % len(m.inputs) + return m, m.focusAt(m.cursor) + + case "shift+tab", "up": + m.cursor = (m.cursor - 1 + len(m.inputs)) % len(m.inputs) + return m, m.focusAt(m.cursor) + + case "enter": + if m.cursor < len(m.inputs)-1 { + m.cursor++ + return m, m.focusAt(m.cursor) + } + // Last field — submit + m.done = true + return m, tea.Quit + } + } + + // Forward key events to the focused input + var cmd tea.Cmd + m.inputs[m.cursor], cmd = m.inputs[m.cursor].Update(msg) + return m, cmd +} + +func (m formModel) focusAt(idx int) tea.Cmd { + var cmds []tea.Cmd + for i := range m.inputs { + if i == idx { + m.inputs[i].PromptStyle = focusedStyle + m.inputs[i].TextStyle = focusedStyle + cmds = append(cmds, m.inputs[i].Focus()) + } else { + m.inputs[i].Blur() + m.inputs[i].PromptStyle = blurredStyle + m.inputs[i].TextStyle = blurredStyle + } + } + return tea.Batch(cmds...) +} + +func (m formModel) View() string { + var b strings.Builder + b.WriteString(titleStyle.Render(m.title) + "\n\n") + + for i, key := range m.keys { + label := key + if secretKey(key) { + label += mutedStyle.Render(" (hidden)") + } + if i == m.cursor { + b.WriteString(focusedStyle.Render("› ") + labelStyle.Render(label) + m.inputs[i].View() + "\n") + } else { + b.WriteString(blurredStyle.Render(" ") + labelStyle.Render(label) + m.inputs[i].View() + "\n") + } + } + + b.WriteString("\n" + mutedStyle.Render("tab/↑↓ navigate enter confirm esc cancel")) + return b.String() +} + +// PromptMissingEnv runs a TUI form for the given keys and returns a map of +// key → value. Returns nil if the user cancelled. +func PromptMissingEnv(stackName string, keys []string) (map[string]string, error) { + if len(keys) == 0 { + return map[string]string{}, nil + } + + title := fmt.Sprintf("Missing env vars for %q", stackName) + m := newFormModel(title, keys) + p := tea.NewProgram(m) + result, err := p.Run() + if err != nil { + return nil, err + } + + final := result.(formModel) + if final.keys == nil { + return nil, nil // cancelled + } + + values := make(map[string]string, len(keys)) + for i, key := range keys { + values[key] = final.inputs[i].Value() + } + return values, nil +} diff --git a/knecht/tui/tui.go b/knecht/tui/tui.go index 6d420b9..025edcf 100644 --- a/knecht/tui/tui.go +++ b/knecht/tui/tui.go @@ -15,13 +15,14 @@ import ( ) 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")) + 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 @@ -31,23 +32,37 @@ const ( modeDiff ) -// ── messages ───────────────────────────────────────────────────────────────── +// ── messages ────────────────────────────────────────────────────────────────── type stacksLoadedMsg []stackItem type updateDoneMsg struct{ err error } +type deployDoneMsg struct{ err error } type envExampleWrittenMsg struct{ err error } type errMsg error -// ── list item ──────────────────────────────────────────────────────────────── +// ── list item ───────────────────────────────────────────────────────────────── type stackItem struct { - remote portainer.Stack + 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.remote.Name + name := s.name() + if s.remote == nil { + return warnStyle.Render(name) + } if s.driftResult != nil && s.driftResult.HasDrift() { name += driftStyle.Render(" ~") } @@ -55,6 +70,9 @@ func (s stackItem) Title() string { } 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") @@ -65,43 +83,44 @@ func (s stackItem) Description() string { return status + " · " + s.driftResult.Summary() } -func (s stackItem) FilterValue() string { return s.remote.Name } +func (s stackItem) FilterValue() string { return s.name() } -// ── model ──────────────────────────────────────────────────────────────────── +// ── model ───────────────────────────────────────────────────────────────────── type model struct { list list.Model spinner spinner.Model client *portainer.Client svcPath string + ignore map[string]bool mode viewMode updating bool - status string // transient success/error message + status string width int height int } -func initialModel(client *portainer.Client, svcPath string) model { +func initialModel(client *portainer.Client, svcPath string, ignore []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 + l.SetShowHelp(false) 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, + 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} } // ── commands ────────────────────────────────────────────────────────────────── -func loadStacks(client *portainer.Client, svcPath string) tea.Cmd { +func loadStacks(client *portainer.Client, svcPath string, ignore map[string]bool) tea.Cmd { return func() tea.Msg { remotes, err := client.ListStacks() if err != nil { @@ -114,34 +133,34 @@ func loadStacks(client *portainer.Client, svcPath string) tea.Cmd { localByName[l.Name] = l } - items := make([]stackItem, 0, len(remotes)) + // 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 { - item := stackItem{remote: r} + 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, &r, &lCopy) + item.driftResult = computeDrift(client, &rCopy, &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")} + // 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}) } - // 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")} + + return stacksLoadedMsg(items) } } @@ -156,12 +175,44 @@ func updateStack(client *portainer.Client, s stackItem) tea.Cmd { } } -// ── bubbletea ──────────────────────────────────────────────────────────────── +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 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.spinner.Tick) + 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 @@ -186,16 +237,25 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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)) + 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 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)) + m.status = successStyle.Render(".env.example written — refreshing...") + cmds = append(cmds, loadStacks(m.client, m.svcPath, m.ignore)) } case errMsg: @@ -210,7 +270,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.KeyMsg: - // Don't pass keys to the list while filtering is active if m.list.SettingFilter() { break } @@ -222,10 +281,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "r": m.status = "" m.mode = modeSummary - cmds = append(cmds, loadStacks(m.client, m.svcPath)) + 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 { @@ -235,7 +298,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "u": item, ok := m.list.SelectedItem().(stackItem) - if !ok || item.local == nil || m.updating { + if !ok || item.local == nil || item.remote == nil || m.updating { return m, nil } m.updating = true @@ -244,6 +307,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 "e": item, ok := m.list.SelectedItem().(stackItem) if !ok || item.driftResult == nil { @@ -277,7 +350,7 @@ func (m model) View() string { var detail string if m.updating { - detail = m.spinner.View() + " Updating..." + detail = m.spinner.View() + " Working..." } else if item, ok := m.list.SelectedItem().(stackItem); ok { switch m.mode { case modeDiff: @@ -295,6 +368,7 @@ func (m model) View() string { Width(m.width/2 - 2). Height(m.height - 4). Padding(1, 2). + MarginLeft(4). Border(lipgloss.RoundedBorder()). Render(detail) @@ -302,7 +376,13 @@ func (m model) View() string { } func renderSummary(s stackItem) string { - out := titleStyle.Render(s.remote.Name) + "\n\n" + 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")) @@ -312,6 +392,7 @@ func renderSummary(s stackItem) string { if s.driftResult == nil { out += mutedStyle.Render("no local compose file\n") + out += "\n" + mutedStyle.Render("[r] refresh [q] quit") return out } @@ -343,13 +424,12 @@ func renderSummary(s stackItem) string { } func renderDiff(s stackItem) string { - out := titleStyle.Render(s.remote.Name+" — diff") + "\n\n" + out := titleStyle.Render(s.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" @@ -363,9 +443,8 @@ func renderDiff(s stackItem) string { } } - // Env key diff out += "\n" + mutedStyle.Render("env keys:") + "\n" - if len(s.driftResult.MissingKeys) == 0 && len(s.driftResult.UnknownKeys) == 0 { + 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 { @@ -405,7 +484,6 @@ func computeDrift(client *portainer.Client, s *portainer.Stack, local *stack.Loc } 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) @@ -414,8 +492,8 @@ func computeDrift(client *portainer.Client, s *portainer.Stack, local *stack.Loc return result } -func Run(client *portainer.Client, svcPath string, _ *config.Config) error { - p := tea.NewProgram(initialModel(client, svcPath), tea.WithAltScreen()) +func Run(client *portainer.Client, svcPath string, cfg *config.Config) error { + p := tea.NewProgram(initialModel(client, svcPath, cfg.Ignore), tea.WithAltScreen()) _, err := p.Run() return err }