Compare commits
2 Commits
960e12f967
...
397cbea7fb
| Author | SHA1 | Date | |
|---|---|---|---|
| 397cbea7fb | |||
| b4fddbb5b6 |
95
knecht/README.md
Normal file
95
knecht/README.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# 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 <stack>` | Deploy a new stack from `services/<stack>/docker-compose.yml` |
|
||||||
|
| `knecht update <stack>` | Update an existing stack, preserving Portainer env vars |
|
||||||
|
| `knecht restart <stack>` | Stop then start a stack |
|
||||||
|
| `knecht diff <stack>` | Print compose and env key drift |
|
||||||
|
| `knecht logs [container]` | Open Dozzle in the browser; deep-links to a specific container if given |
|
||||||
|
|
||||||
|
`<stack>` 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/<stack>/docker-compose.yml` against what is deployed in Portainer, and the keys in `services/<stack>/.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/<name>/
|
||||||
|
├── docker-compose.yml # required
|
||||||
|
└── .env.example # optional — documents required Portainer env vars
|
||||||
|
```
|
||||||
|
|
||||||
|
`knecht deploy <name>` / `knecht update <name>` read these files directly. Env var *values* are never stored locally — only the keys in `.env.example`, as documentation.
|
||||||
19
knecht/TODO.md
Normal file
19
knecht/TODO.md
Normal file
@@ -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
|
||||||
|
- [x] `knecht restart <stack>` — 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)
|
||||||
|
- [x] 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
|
||||||
@@ -13,10 +13,14 @@ var listCmd = &cobra.Command{
|
|||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List all stacks with status and drift",
|
Short: "List all stacks with status and drift",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
_, client, svcPath, err := setup()
|
cfg, client, svcPath, err := setup()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
ignoreSet := make(map[string]bool, len(cfg.Ignore))
|
||||||
|
for _, name := range cfg.Ignore {
|
||||||
|
ignoreSet[name] = true
|
||||||
|
}
|
||||||
|
|
||||||
stacks, err := client.ListStacks()
|
stacks, err := client.ListStacks()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -35,7 +39,12 @@ var listCmd = &cobra.Command{
|
|||||||
fmt.Printf("%-20s %-10s %s\n", "STACK", "STATUS", "DRIFT")
|
fmt.Printf("%-20s %-10s %s\n", "STACK", "STATUS", "DRIFT")
|
||||||
fmt.Println(repeat("-", 60))
|
fmt.Println(repeat("-", 60))
|
||||||
|
|
||||||
|
remoteNames := make(map[string]bool, len(stacks))
|
||||||
for _, s := range stacks {
|
for _, s := range stacks {
|
||||||
|
if ignoreSet[s.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remoteNames[s.Name] = true
|
||||||
status := statusLabel(s.Status)
|
status := statusLabel(s.Status)
|
||||||
driftSummary := "no local compose"
|
driftSummary := "no local compose"
|
||||||
|
|
||||||
@@ -49,6 +58,14 @@ var listCmd = &cobra.Command{
|
|||||||
|
|
||||||
fmt.Printf("%-20s %-10s %s\n", s.Name, status, driftSummary)
|
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
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ var logsCmd = &cobra.Command{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
logsBase := deriveLogsURL(cfg.Portainer.URL)
|
logsBase := deriveLogsURL(cfg.URL)
|
||||||
|
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
fmt.Printf("Opening %s\n", logsBase)
|
fmt.Printf("Opening %s\n", logsBase)
|
||||||
|
|||||||
45
knecht/cmd/restart.go
Normal file
45
knecht/cmd/restart.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var restartCmd = &cobra.Command{
|
||||||
|
Use: "restart <stack>",
|
||||||
|
Short: "Stop and start a stack",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
_, client, _, err := setup()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
name := args[0]
|
||||||
|
remote, err := client.GetStackByName(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if remote == nil {
|
||||||
|
return fmt.Errorf("stack %q not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Stopping %q...\n", name)
|
||||||
|
if err := client.StopStack(remote.ID); err != nil {
|
||||||
|
return fmt.Errorf("stop failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Starting %q...\n", name)
|
||||||
|
if err := client.StartStack(remote.ID); err != nil {
|
||||||
|
return fmt.Errorf("start failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Restarted %q\n", name)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(restartCmd)
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ func setup() (*config.Config, *portainer.Client, string, error) {
|
|||||||
return nil, nil, "", err
|
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 {
|
if err != nil {
|
||||||
return nil, nil, "", fmt.Errorf("connecting to Portainer: %w", err)
|
return nil, nil, "", fmt.Errorf("connecting to Portainer: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import (
|
|||||||
|
|
||||||
"github.com/jensbecker/homelab/knecht/portainer"
|
"github.com/jensbecker/homelab/knecht/portainer"
|
||||||
"github.com/jensbecker/homelab/knecht/stack"
|
"github.com/jensbecker/homelab/knecht/stack"
|
||||||
|
"github.com/jensbecker/homelab/knecht/tui"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var pruneEnvFlag bool
|
||||||
|
|
||||||
var updateCmd = &cobra.Command{
|
var updateCmd = &cobra.Command{
|
||||||
Use: "update <stack>",
|
Use: "update <stack>",
|
||||||
Short: "Update an existing stack, preserving env vars",
|
Short: "Update an existing stack, preserving env vars",
|
||||||
@@ -37,15 +40,19 @@ var updateCmd = &cobra.Command{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for missing env keys and prompt for values
|
|
||||||
exampleKeys, err := local.EnvExampleKeys()
|
exampleKeys, err := local.EnvExampleKeys()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
env, err := mergeEnvVars(remote.Env, exampleKeys)
|
|
||||||
|
env, err := mergeEnvVars(name, remote.Env, exampleKeys, pruneEnvFlag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if env == nil {
|
||||||
|
fmt.Println("Cancelled.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
s, err := client.UpdateStack(remote.ID, compose, env)
|
s, err := client.UpdateStack(remote.ID, compose, env)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -56,20 +63,46 @@ var updateCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeEnvVars takes existing Portainer env vars and prompts for any keys
|
// mergeEnvVars preserves existing Portainer env vars and prompts via TUI for
|
||||||
// present in .env.example but missing from Portainer.
|
// any keys present in .env.example but missing from Portainer.
|
||||||
func mergeEnvVars(existing []portainer.EnvVar, exampleKeys []string) ([]portainer.EnvVar, error) {
|
// Returns nil if the user cancelled the form.
|
||||||
|
func mergeEnvVars(stackName string, existing []portainer.EnvVar, exampleKeys []string, prune bool) ([]portainer.EnvVar, error) {
|
||||||
envMap := make(map[string]string, len(existing))
|
envMap := make(map[string]string, len(existing))
|
||||||
for _, e := range existing {
|
for _, e := range existing {
|
||||||
envMap[e.Name] = e.Value
|
envMap[e.Name] = e.Value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove keys not in .env.example when pruning
|
||||||
|
if prune && exampleKeys != nil {
|
||||||
|
allowed := make(map[string]bool, len(exampleKeys))
|
||||||
|
for _, k := range exampleKeys {
|
||||||
|
allowed[k] = true
|
||||||
|
}
|
||||||
|
for k := range envMap {
|
||||||
|
if !allowed[k] {
|
||||||
|
fmt.Printf("Removing unknown env var %q\n", k)
|
||||||
|
delete(envMap, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var missing []string
|
||||||
for _, key := range exampleKeys {
|
for _, key := range exampleKeys {
|
||||||
if _, ok := envMap[key]; !ok {
|
if _, ok := envMap[key]; !ok {
|
||||||
fmt.Printf("Missing env var %q — enter value (leave empty to skip): ", key)
|
missing = append(missing, key)
|
||||||
var val string
|
}
|
||||||
fmt.Scanln(&val)
|
}
|
||||||
envMap[key] = val
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,5 +114,6 @@ func mergeEnvVars(existing []portainer.EnvVar, exampleKeys []string) ([]portaine
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
updateCmd.Flags().BoolVar(&pruneEnvFlag, "prune-env", false, "remove env vars not defined in .env.example")
|
||||||
rootCmd.AddCommand(updateCmd)
|
rootCmd.AddCommand(updateCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,12 @@ import (
|
|||||||
"github.com/BurntSushi/toml"
|
"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 {
|
type Config struct {
|
||||||
Portainer Portainer `toml:"portainer"`
|
URL string `toml:"url"`
|
||||||
ServicesPath string `toml:"services_path"` // optional, defaults to git root / services
|
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) {
|
func Load() (*Config, error) {
|
||||||
@@ -28,16 +25,16 @@ func Load() (*Config, error) {
|
|||||||
var cfg Config
|
var cfg Config
|
||||||
if _, err := toml.DecodeFile(path, &cfg); err != nil {
|
if _, err := toml.DecodeFile(path, &cfg); err != nil {
|
||||||
if os.IsNotExist(err) {
|
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)
|
return nil, fmt.Errorf("parsing config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Portainer.URL == "" {
|
if cfg.URL == "" {
|
||||||
return nil, fmt.Errorf("portainer.url is required in %s", path)
|
return nil, fmt.Errorf("url is required in %s", path)
|
||||||
}
|
}
|
||||||
if cfg.Portainer.Token == "" {
|
if cfg.Token == "" {
|
||||||
return nil, fmt.Errorf("portainer.token is required in %s", path)
|
return nil, fmt.Errorf("token is required in %s", path)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
|
|||||||
@@ -147,6 +147,24 @@ func (c *Client) CreateStack(name, composeContent string, env []EnvVar) (*Stack,
|
|||||||
return &s, nil
|
return &s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) StopStack(id int) error {
|
||||||
|
resp, err := c.do("POST", fmt.Sprintf("/api/stacks/%d/stop?endpointId=%d", id, c.endpointID), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) StartStack(id int) error {
|
||||||
|
resp, err := c.do("POST", fmt.Sprintf("/api/stacks/%d/start?endpointId=%d", id, c.endpointID), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) UpdateStack(id int, composeContent string, env []EnvVar) (*Stack, error) {
|
func (c *Client) UpdateStack(id int, composeContent string, env []EnvVar) (*Stack, error) {
|
||||||
body := map[string]any{
|
body := map[string]any{
|
||||||
"stackFileContent": composeContent,
|
"stackFileContent": composeContent,
|
||||||
|
|||||||
157
knecht/tui/form.go
Normal file
157
knecht/tui/form.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
"github.com/charmbracelet/bubbles/list"
|
||||||
@@ -15,13 +17,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
||||||
successStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("10"))
|
successStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("10"))
|
||||||
mutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
mutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
||||||
driftStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("11"))
|
driftStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("11"))
|
||||||
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
|
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
|
||||||
addStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
|
addStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
|
||||||
removeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
|
removeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
|
||||||
|
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("208"))
|
||||||
)
|
)
|
||||||
|
|
||||||
type viewMode int
|
type viewMode int
|
||||||
@@ -31,23 +34,38 @@ const (
|
|||||||
modeDiff
|
modeDiff
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── messages ─────────────────────────────────────────────────────────────────
|
// ── messages ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type stacksLoadedMsg []stackItem
|
type stacksLoadedMsg []stackItem
|
||||||
type updateDoneMsg struct{ err error }
|
type updateDoneMsg struct{ err error }
|
||||||
|
type deployDoneMsg struct{ err error }
|
||||||
|
type pruneDoneMsg struct{ err error }
|
||||||
type envExampleWrittenMsg struct{ err error }
|
type envExampleWrittenMsg struct{ err error }
|
||||||
type errMsg error
|
type errMsg error
|
||||||
|
|
||||||
// ── list item ────────────────────────────────────────────────────────────────
|
// ── list item ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type stackItem struct {
|
type stackItem struct {
|
||||||
remote portainer.Stack
|
remote *portainer.Stack // nil = not deployed
|
||||||
local *stack.Local
|
local *stack.Local
|
||||||
driftResult *drift.Result
|
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 {
|
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() {
|
if s.driftResult != nil && s.driftResult.HasDrift() {
|
||||||
name += driftStyle.Render(" ~")
|
name += driftStyle.Render(" ~")
|
||||||
}
|
}
|
||||||
@@ -55,6 +73,9 @@ func (s stackItem) Title() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s stackItem) Description() string {
|
func (s stackItem) Description() string {
|
||||||
|
if s.remote == nil {
|
||||||
|
return warnStyle.Render("not deployed")
|
||||||
|
}
|
||||||
status := "running"
|
status := "running"
|
||||||
if s.remote.Status != 1 {
|
if s.remote.Status != 1 {
|
||||||
status = mutedStyle.Render("stopped")
|
status = mutedStyle.Render("stopped")
|
||||||
@@ -65,43 +86,45 @@ func (s stackItem) Description() string {
|
|||||||
return status + " · " + s.driftResult.Summary()
|
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 {
|
type model struct {
|
||||||
list list.Model
|
list list.Model
|
||||||
spinner spinner.Model
|
spinner spinner.Model
|
||||||
client *portainer.Client
|
client *portainer.Client
|
||||||
svcPath string
|
svcPath string
|
||||||
|
ignore map[string]bool
|
||||||
|
logsURL string // base URL for Dozzle
|
||||||
mode viewMode
|
mode viewMode
|
||||||
updating bool
|
updating bool
|
||||||
status string // transient success/error message
|
status string
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
}
|
}
|
||||||
|
|
||||||
func initialModel(client *portainer.Client, svcPath string) model {
|
func initialModel(client *portainer.Client, svcPath string, ignore []string, logsURL string) model {
|
||||||
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
|
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
|
||||||
l.Title = "knecht"
|
l.Title = "knecht"
|
||||||
l.Styles.Title = titleStyle
|
l.Styles.Title = titleStyle
|
||||||
l.SetShowHelp(false) // we render our own key hints
|
l.SetShowHelp(false)
|
||||||
|
|
||||||
s := spinner.New()
|
s := spinner.New()
|
||||||
s.Spinner = spinner.Dot
|
s.Spinner = spinner.Dot
|
||||||
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("12"))
|
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("12"))
|
||||||
|
|
||||||
return model{
|
ignoreSet := make(map[string]bool, len(ignore))
|
||||||
list: l,
|
for _, name := range ignore {
|
||||||
spinner: s,
|
ignoreSet[name] = true
|
||||||
client: client,
|
|
||||||
svcPath: svcPath,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return model{list: l, spinner: s, client: client, svcPath: svcPath, ignore: ignoreSet, logsURL: logsURL}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── commands ──────────────────────────────────────────────────────────────────
|
// ── 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 {
|
return func() tea.Msg {
|
||||||
remotes, err := client.ListStacks()
|
remotes, err := client.ListStacks()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -114,34 +137,34 @@ func loadStacks(client *portainer.Client, svcPath string) tea.Cmd {
|
|||||||
localByName[l.Name] = l
|
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 {
|
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 {
|
if l, ok := localByName[r.Name]; ok {
|
||||||
lCopy := l
|
lCopy := l
|
||||||
item.local = &lCopy
|
item.local = &lCopy
|
||||||
item.driftResult = computeDrift(client, &r, &lCopy)
|
item.driftResult = computeDrift(client, &rCopy, &lCopy)
|
||||||
}
|
}
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
return stacksLoadedMsg(items)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeEnvExample(s stackItem) tea.Cmd {
|
// Local-only stacks (not deployed)
|
||||||
return func() tea.Msg {
|
for _, l := range locals {
|
||||||
if s.local == nil || s.driftResult == nil {
|
if ignore[l.Name] || remoteNames[l.Name] {
|
||||||
return envExampleWrittenMsg{fmt.Errorf("no local stack info")}
|
continue
|
||||||
|
}
|
||||||
|
lCopy := l
|
||||||
|
items = append(items, stackItem{local: &lCopy})
|
||||||
}
|
}
|
||||||
// No .env.example at all — create from scratch
|
|
||||||
if len(s.driftResult.PortainerOnlyKeys) > 0 {
|
return stacksLoadedMsg(items)
|
||||||
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")}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,12 +179,102 @@ 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 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 {
|
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) {
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
@@ -170,7 +283,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
m.list.SetSize(msg.Width/2, msg.Height-4)
|
m.list.SetSize(msg.Width/2, msg.Height-2)
|
||||||
|
|
||||||
case stacksLoadedMsg:
|
case stacksLoadedMsg:
|
||||||
m.updating = false
|
m.updating = false
|
||||||
@@ -186,16 +299,34 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.status = errorStyle.Render("Update failed: " + msg.err.Error())
|
m.status = errorStyle.Render("Update failed: " + msg.err.Error())
|
||||||
} else {
|
} else {
|
||||||
m.status = successStyle.Render("Updated successfully — refreshing...")
|
m.status = successStyle.Render("Updated — refreshing...")
|
||||||
cmds = append(cmds, loadStacks(m.client, m.svcPath))
|
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:
|
case envExampleWrittenMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.status = errorStyle.Render("Failed to write .env.example: " + msg.err.Error())
|
m.status = errorStyle.Render("Failed to write .env.example: " + msg.err.Error())
|
||||||
} else {
|
} else {
|
||||||
m.status = successStyle.Render(".env.example created — refreshing...")
|
m.status = successStyle.Render(".env.example written — refreshing...")
|
||||||
cmds = append(cmds, loadStacks(m.client, m.svcPath))
|
cmds = append(cmds, loadStacks(m.client, m.svcPath, m.ignore))
|
||||||
}
|
}
|
||||||
|
|
||||||
case errMsg:
|
case errMsg:
|
||||||
@@ -210,7 +341,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
// Don't pass keys to the list while filtering is active
|
|
||||||
if m.list.SettingFilter() {
|
if m.list.SettingFilter() {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -222,10 +352,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case "r":
|
case "r":
|
||||||
m.status = ""
|
m.status = ""
|
||||||
m.mode = modeSummary
|
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...)
|
return m, tea.Batch(cmds...)
|
||||||
|
|
||||||
case "d":
|
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 {
|
if m.mode == modeDiff {
|
||||||
m.mode = modeSummary
|
m.mode = modeSummary
|
||||||
} else {
|
} else {
|
||||||
@@ -235,7 +369,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case "u":
|
case "u":
|
||||||
item, ok := m.list.SelectedItem().(stackItem)
|
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
|
return m, nil
|
||||||
}
|
}
|
||||||
m.updating = true
|
m.updating = true
|
||||||
@@ -244,6 +378,33 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
cmds = append(cmds, updateStack(m.client, item), m.spinner.Tick)
|
cmds = append(cmds, updateStack(m.client, item), m.spinner.Tick)
|
||||||
return m, tea.Batch(cmds...)
|
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":
|
case "e":
|
||||||
item, ok := m.list.SelectedItem().(stackItem)
|
item, ok := m.list.SelectedItem().(stackItem)
|
||||||
if !ok || item.driftResult == nil {
|
if !ok || item.driftResult == nil {
|
||||||
@@ -277,7 +438,7 @@ func (m model) View() string {
|
|||||||
|
|
||||||
var detail string
|
var detail string
|
||||||
if m.updating {
|
if m.updating {
|
||||||
detail = m.spinner.View() + " Updating..."
|
detail = m.spinner.View() + " Working..."
|
||||||
} else if item, ok := m.list.SelectedItem().(stackItem); ok {
|
} else if item, ok := m.list.SelectedItem().(stackItem); ok {
|
||||||
switch m.mode {
|
switch m.mode {
|
||||||
case modeDiff:
|
case modeDiff:
|
||||||
@@ -291,10 +452,15 @@ func (m model) View() string {
|
|||||||
detail += "\n\n" + m.status
|
detail += "\n\n" + m.status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const marginLeft = 4
|
||||||
|
const borderWidth = 2
|
||||||
|
rightWidth := m.width - lipgloss.Width(left) - marginLeft - borderWidth
|
||||||
|
|
||||||
right := lipgloss.NewStyle().
|
right := lipgloss.NewStyle().
|
||||||
Width(m.width/2 - 2).
|
Width(rightWidth).
|
||||||
Height(m.height - 4).
|
Height(m.height - 4).
|
||||||
Padding(1, 2).
|
Padding(1, 2).
|
||||||
|
MarginLeft(marginLeft).
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
Render(detail)
|
Render(detail)
|
||||||
|
|
||||||
@@ -302,7 +468,13 @@ func (m model) View() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func renderSummary(s stackItem) 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 {
|
if s.remote.Status == 1 {
|
||||||
out += fmt.Sprintf("status: %s\n", successStyle.Render("running"))
|
out += fmt.Sprintf("status: %s\n", successStyle.Render("running"))
|
||||||
@@ -312,6 +484,7 @@ func renderSummary(s stackItem) string {
|
|||||||
|
|
||||||
if s.driftResult == nil {
|
if s.driftResult == nil {
|
||||||
out += mutedStyle.Render("no local compose file\n")
|
out += mutedStyle.Render("no local compose file\n")
|
||||||
|
out += "\n" + mutedStyle.Render("[r] refresh [q] quit")
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +501,7 @@ func renderSummary(s stackItem) string {
|
|||||||
for _, k := range s.driftResult.UnknownKeys {
|
for _, k := range s.driftResult.UnknownKeys {
|
||||||
out += fmt.Sprintf(" ? %s\n", k)
|
out += fmt.Sprintf(" ? %s\n", k)
|
||||||
}
|
}
|
||||||
out += mutedStyle.Render(" [e] append to .env.example") + "\n"
|
out += mutedStyle.Render(" [e] append to .env.example [c] remove from Portainer") + "\n"
|
||||||
}
|
}
|
||||||
if len(s.driftResult.PortainerOnlyKeys) > 0 {
|
if len(s.driftResult.PortainerOnlyKeys) > 0 {
|
||||||
out += "\n" + driftStyle.Render("no .env.example — Portainer has:") + "\n"
|
out += "\n" + driftStyle.Render("no .env.example — Portainer has:") + "\n"
|
||||||
@@ -338,18 +511,17 @@ func renderSummary(s stackItem) string {
|
|||||||
out += mutedStyle.Render(" [e] generate .env.example") + "\n"
|
out += mutedStyle.Render(" [e] generate .env.example") + "\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
out += "\n" + mutedStyle.Render("[u] update [d] diff [r] refresh [esc] back [q] quit")
|
out += "\n" + mutedStyle.Render("[u] update [d] diff [l] logs [r] refresh [esc] back [q] quit")
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderDiff(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 {
|
if s.driftResult == nil {
|
||||||
return out + mutedStyle.Render("no local compose file")
|
return out + mutedStyle.Render("no local compose file")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compose diff
|
|
||||||
out += mutedStyle.Render("compose:") + "\n"
|
out += mutedStyle.Render("compose:") + "\n"
|
||||||
if len(s.driftResult.ComposeDiff) == 0 {
|
if len(s.driftResult.ComposeDiff) == 0 {
|
||||||
out += " " + successStyle.Render("in sync") + "\n"
|
out += " " + successStyle.Render("in sync") + "\n"
|
||||||
@@ -363,9 +535,8 @@ func renderDiff(s stackItem) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Env key diff
|
|
||||||
out += "\n" + mutedStyle.Render("env keys:") + "\n"
|
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"
|
out += " " + successStyle.Render("in sync") + "\n"
|
||||||
}
|
}
|
||||||
for _, k := range s.driftResult.MissingKeys {
|
for _, k := range s.driftResult.MissingKeys {
|
||||||
@@ -405,7 +576,6 @@ func computeDrift(client *portainer.Client, s *portainer.Stack, local *stack.Loc
|
|||||||
}
|
}
|
||||||
|
|
||||||
if exampleKeys == nil && len(portainerKeys) > 0 {
|
if exampleKeys == nil && len(portainerKeys) > 0 {
|
||||||
// No .env.example but Portainer has keys — surface as its own drift type
|
|
||||||
result.PortainerOnlyKeys = portainerKeys
|
result.PortainerOnlyKeys = portainerKeys
|
||||||
} else {
|
} else {
|
||||||
result.MissingKeys, result.UnknownKeys = drift.EnvKeys(exampleKeys, portainerKeys)
|
result.MissingKeys, result.UnknownKeys = drift.EnvKeys(exampleKeys, portainerKeys)
|
||||||
@@ -414,8 +584,17 @@ func computeDrift(client *portainer.Client, s *portainer.Stack, local *stack.Loc
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run(client *portainer.Client, svcPath string, _ *config.Config) error {
|
func Run(client *portainer.Client, svcPath string, cfg *config.Config) error {
|
||||||
p := tea.NewProgram(initialModel(client, svcPath), tea.WithAltScreen())
|
logsURL := deriveLogsURL(cfg.URL)
|
||||||
|
p := tea.NewProgram(initialModel(client, svcPath, cfg.Ignore, logsURL), tea.WithAltScreen())
|
||||||
_, err := p.Run()
|
_, err := p.Run()
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user