more knecht

This commit is contained in:
2026-04-04 15:05:08 +02:00
parent 960e12f967
commit b4fddbb5b6
9 changed files with 463 additions and 84 deletions

View File

@@ -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
}