more knecht
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user