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