more knecht

This commit is contained in:
2026-04-04 15:17:33 +02:00
parent b4fddbb5b6
commit 397cbea7fb
6 changed files with 194 additions and 12 deletions

View File

@@ -2,6 +2,8 @@ package tui
import (
"fmt"
"os/exec"
"runtime"
"strings"
"github.com/charmbracelet/bubbles/list"
@@ -37,6 +39,7 @@ const (
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
@@ -93,6 +96,7 @@ type model struct {
client *portainer.Client
svcPath string
ignore map[string]bool
logsURL string // base URL for Dozzle
mode viewMode
updating bool
status string
@@ -100,7 +104,7 @@ type model struct {
height int
}
func initialModel(client *portainer.Client, svcPath string, ignore []string) model {
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
@@ -115,7 +119,7 @@ func initialModel(client *portainer.Client, svcPath string, ignore []string) mod
ignoreSet[name] = true
}
return model{list: l, spinner: s, client: client, svcPath: svcPath, ignore: ignoreSet}
return model{list: l, spinner: s, client: client, svcPath: svcPath, ignore: ignoreSet, logsURL: logsURL}
}
// ── commands ──────────────────────────────────────────────────────────────────
@@ -191,6 +195,64 @@ func deployStack(client *portainer.Client, s stackItem) tea.Cmd {
}
}
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 {
@@ -221,7 +283,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.list.SetSize(msg.Width/2, msg.Height-4)
m.list.SetSize(msg.Width/2, msg.Height-2)
case stacksLoadedMsg:
m.updating = false
@@ -250,6 +312,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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())
@@ -317,6 +388,23 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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 {
@@ -364,11 +452,15 @@ func (m model) View() string {
detail += "\n\n" + m.status
}
const marginLeft = 4
const borderWidth = 2
rightWidth := m.width - lipgloss.Width(left) - marginLeft - borderWidth
right := lipgloss.NewStyle().
Width(m.width/2 - 2).
Width(rightWidth).
Height(m.height - 4).
Padding(1, 2).
MarginLeft(4).
MarginLeft(marginLeft).
Border(lipgloss.RoundedBorder()).
Render(detail)
@@ -409,7 +501,7 @@ func renderSummary(s stackItem) string {
for _, k := range s.driftResult.UnknownKeys {
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 {
out += "\n" + driftStyle.Render("no .env.example — Portainer has:") + "\n"
@@ -419,7 +511,7 @@ func renderSummary(s stackItem) string {
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
}
@@ -493,7 +585,16 @@ func computeDrift(client *portainer.Client, s *portainer.Stack, local *stack.Loc
}
func Run(client *portainer.Client, svcPath string, cfg *config.Config) error {
p := tea.NewProgram(initialModel(client, svcPath, cfg.Ignore), tea.WithAltScreen())
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
}