From a65c98cc5ef5f798503799bcc60eb3a800449e72 Mon Sep 17 00:00:00 2001 From: stawan15 Date: Sun, 19 Jul 2026 12:45:53 +0700 Subject: [PATCH 1/4] feat: add header bar with project name and git branch, and update log panel to display action-specific titles --- main.go | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++----- styles.go | 32 ++++++++++++++------ 2 files changed, 103 insertions(+), 16 deletions(-) diff --git a/main.go b/main.go index 765df3e..2d85dbf 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "os" + "os/exec" + "path/filepath" "strings" "github.com/charmbracelet/bubbles/list" @@ -93,6 +95,41 @@ type model struct { confirmAct actionItem confirmDest string confirmVer string + + // Header info + projectName string + gitBranch string +} + +// detectProjectName tries to get a short project name from the git remote URL +// or falls back to the current directory name. +func detectProjectName() string { + out, err := exec.Command("git", "remote", "get-url", "origin").Output() + if err == nil { + remote := strings.TrimSpace(string(out)) + // strip .git suffix and take last path component + remote = strings.TrimSuffix(remote, ".git") + parts := strings.FieldsFunc(remote, func(r rune) bool { + return r == '/' || r == ':' + }) + if len(parts) > 0 { + return parts[len(parts)-1] + } + } + // fallback: current directory name + if cwd, err := os.Getwd(); err == nil { + return filepath.Base(cwd) + } + return "kamal-tui" +} + +// detectGitBranch returns the current git branch name. +func detectGitBranch() string { + out, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) } func initialModel() model { @@ -101,7 +138,7 @@ func initialModel() model { items = append(items, a) } al := list.New(items, list.NewDefaultDelegate(), 0, 0) - al.Title = "Actions" + al.Title = "Menu" al.SetShowStatusBar(false) al.SetFilteringEnabled(false) al.SetShowHelp(false) @@ -158,6 +195,8 @@ func initialModel() model { secList: secList, secKeyIn: secKeyIn, secValIn: secValIn, + projectName: detectProjectName(), + gitBranch: detectGitBranch(), } } @@ -183,8 +222,9 @@ func waitForDone(ch <-chan error) tea.Cmd { } func (m *model) layout() { + headerH := 1 footerH := 1 - bodyH := m.height - footerH + bodyH := m.height - headerH - footerH if bodyH < 3 { bodyH = 3 } @@ -517,6 +557,7 @@ func (m model) startRun(action actionItem, dest, version string) (tea.Model, tea m.lineCh = make(chan string) m.doneCh = make(chan error, 1) m.running = true + m.selectedAction = action m.outputBuf = nil m.statusLine = "" m.lastErr = nil @@ -533,6 +574,26 @@ func (m model) startRun(action actionItem, dest, version string) (tea.Model, tea return m, tea.Batch(m.spinner.Tick, waitForLine(m.lineCh), waitForDone(m.doneCh)) } +// headerView renders the top bar: empty left side + project::branch right-aligned. +func (m model) headerView() string { + var label string + if m.gitBranch != "" { + label = m.projectName + " :: " + m.gitBranch + } else { + label = m.projectName + } + right := headerBranchStyle.Render(label) + // Pad left so right label is flush right + rightW := lipgloss.Width(right) + padding := m.width - rightW + if padding < 0 { + padding = 0 + } + return lipgloss.NewStyle().Background(colorHeaderBg).Width(m.width).Render( + strings.Repeat(" ", padding) + right, + ) +} + func (m model) View() string { if m.width == 0 { return "loading…" @@ -578,8 +639,9 @@ func (m model) View() string { } rightW := m.width - leftW + headerH := 1 footerH := 1 - bodyH := m.height - footerH + bodyH := m.height - headerH - footerH destH := bodyH / 2 actionH := bodyH - destH @@ -593,19 +655,28 @@ func (m model) View() string { } destPanel = style.Width(leftW - 2).Height(destH - 2).Render(m.destList.View()) - // Render Actions + // Render Menu (Actions) style = inactivePanelStyle if m.activePanel == panelActions { style = activePanelStyle } actionPanel = style.Width(leftW - 2).Height(actionH - 2).Render(m.actionList.View()) - // Render Logs + // Render Logs panel with dynamic title style = inactivePanelStyle if m.activePanel == panelLogs { style = activePanelStyle } + // Build log panel title: "{ActionName} logs" or just "logs" + logTitle := "logs" + if m.selectedAction.title != "" { + // Strip emoji from title for cleanliness + clean := strings.TrimSpace(m.selectedAction.title) + logTitle = clean + " logs" + } + logPanelTitle := logPanelTitleStyle.Render(logTitle) + logContent := m.viewport.View() if m.showVersionInput { overlay := lipgloss.JoinVertical(lipgloss.Left, @@ -616,12 +687,14 @@ func (m model) View() string { logContent = lipgloss.Place(rightW-4, bodyH-4, lipgloss.Center, lipgloss.Center, activePanelStyle.Render(overlay)) } - logPanel = style.Width(rightW - 2).Height(bodyH - 2).Render(logContent) + // Compose log panel: title on top, viewport below, inside the border style + logInner := lipgloss.JoinVertical(lipgloss.Left, logPanelTitle, logContent) + logPanel = style.Width(rightW - 2).Height(bodyH - 2).Render(logInner) leftCol := lipgloss.JoinVertical(lipgloss.Left, destPanel, actionPanel) mainView := lipgloss.JoinHorizontal(lipgloss.Top, leftCol, logPanel) - return lipgloss.JoinVertical(lipgloss.Left, mainView, m.footerView()) + return lipgloss.JoinVertical(lipgloss.Left, m.headerView(), mainView, m.footerView()) } func destLabel(d string) string { diff --git a/styles.go b/styles.go index bf36722..506d8ea 100644 --- a/styles.go +++ b/styles.go @@ -4,15 +4,16 @@ import "github.com/charmbracelet/lipgloss" var ( // Tokyo Night-ish / Modern theme - colorBg = lipgloss.Color("#1a1b26") - colorFg = lipgloss.Color("#c0caf5") - colorAccent = lipgloss.Color("#7aa2f7") // Blue - colorActive = lipgloss.Color("#bb9af7") // Purple - colorBorder = lipgloss.Color("#414868") // Dark Gray - colorMuted = lipgloss.Color("#565f89") - colorGood = lipgloss.Color("#9ece6a") // Green - colorBad = lipgloss.Color("#f7768e") // Red - colorWarning = lipgloss.Color("#e0af68") // Yellow + colorBg = lipgloss.Color("#1a1b26") + colorFg = lipgloss.Color("#c0caf5") + colorAccent = lipgloss.Color("#7aa2f7") // Blue + colorActive = lipgloss.Color("#bb9af7") // Purple + colorBorder = lipgloss.Color("#414868") // Dark Gray + colorMuted = lipgloss.Color("#565f89") + colorGood = lipgloss.Color("#9ece6a") // Green + colorBad = lipgloss.Color("#f7768e") // Red + colorWarning = lipgloss.Color("#e0af68") // Yellow + colorHeaderBg = lipgloss.Color("#16161e") // Slightly darker for header strip titleStyle = lipgloss.NewStyle(). Bold(true). @@ -46,4 +47,17 @@ var ( badStyle = lipgloss.NewStyle().Foreground(colorBad).Bold(true) spinnerStyle = lipgloss.NewStyle().Foreground(colorAccent) + + // Header bar: project :: branch shown top-right + headerBranchStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(colorAccent). + Background(colorHeaderBg). + Padding(0, 1) + + // Log panel inner title: "{Action} logs" + logPanelTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(colorMuted). + Padding(0, 0, 0, 1) ) From b92718cf5179847a0c79d853ffccffb7f24a1e10 Mon Sep 17 00:00:00 2001 From: stawan15 Date: Sun, 19 Jul 2026 12:50:21 +0700 Subject: [PATCH 2/4] style: update action menu titles with Nerd Font icons --- kamal.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/kamal.go b/kamal.go index 5f73def..c5cd73b 100644 --- a/kamal.go +++ b/kamal.go @@ -29,35 +29,35 @@ func (a actionItem) FilterValue() string { return a.title } func actions() []actionItem { return []actionItem{ { - title: "πŸš€ Deploy", + title: "󰚰 Deploy", desc: "kamal deploy -d ", buildArgs: func(dest, _ string) []string { return withDest([]string{"deploy"}, dest) }, }, { - title: "βš™οΈ Setup", + title: "σ°’“ Setup", desc: "kamal setup -d (provision servers & deploy)", buildArgs: func(dest, _ string) []string { return withDest([]string{"setup"}, dest) }, }, { - title: "πŸ”‘ Env Push", + title: "σ°ˆ™ Env Push", desc: "kamal env push -d (push .env variables to servers)", buildArgs: func(dest, _ string) []string { return withDest([]string{"env", "push"}, dest) }, }, { - title: "♻️ Redeploy", + title: "σ°‘™ Redeploy", desc: "kamal redeploy -d (skip build cache invalidation steps)", buildArgs: func(dest, _ string) []string { return withDest([]string{"redeploy"}, dest) }, }, { - title: "βͺ Rollback", + title: "󰁯 Rollback", desc: "kamal rollback -d ", needsVersion: true, buildArgs: func(dest, version string) []string { @@ -66,7 +66,7 @@ func actions() []actionItem { }, }, { - title: "πŸ’Ύ DB Dump (Backup)", + title: "σ°†Ό DB Dump (Backup)", desc: "kamal app exec -i -- /bin/sh -c 'pg_dump ...'", buildArgs: func(dest, _ string) []string { args := []string{"app", "exec", "-i"} @@ -76,7 +76,7 @@ func actions() []actionItem { }, }, { - title: "πŸ’Ώ DB Restore", + title: "σ°—¨ DB Restore", desc: "kamal app exec -i -- /bin/sh -c 'pg_restore ...'", buildArgs: func(dest, _ string) []string { args := []string{"app", "exec", "-i"} @@ -86,35 +86,35 @@ func actions() []actionItem { }, }, { - title: "ℹ️ App Details", + title: "σ°‹© App Details", desc: "kamal app details -d ", buildArgs: func(dest, _ string) []string { return withDest([]string{"app", "details"}, dest) }, }, { - title: "πŸ“ App Logs", + title: "σ°…© App Logs", desc: "kamal app logs -d (last lines, no follow)", buildArgs: func(dest, _ string) []string { return withDest([]string{"app", "logs"}, dest) }, }, { - title: "⚑ App Boot", + title: "󰀡 App Boot", desc: "kamal app boot -d ", buildArgs: func(dest, _ string) []string { return withDest([]string{"app", "boot"}, dest) }, }, { - title: "πŸ•’ Audit", + title: "󰚌 Audit", desc: "kamal audit -d (recent deploy history)", buildArgs: func(dest, _ string) []string { return withDest([]string{"audit"}, dest) }, }, { - title: "πŸ—‘οΈ Remove", + title: "󰆴 Remove", desc: "kamal remove -d (remove containers and images from servers)", buildArgs: func(dest, _ string) []string { return withDest([]string{"remove"}, dest) From 4a3b2beb5b46f1ef6c23dbb3ff0a3f5a2bfe1d47 Mon Sep 17 00:00:00 2001 From: stawan15 Date: Sun, 19 Jul 2026 13:25:23 +0700 Subject: [PATCH 3/4] feat: replace action list panel with a centered keyboard-driven menu overlay --- kamal.go | 91 +++++++++++++-------------- main.go | 184 ++++++++++++++++++++++++++---------------------------- styles.go | 61 +++++++++++------- 3 files changed, 174 insertions(+), 162 deletions(-) diff --git a/kamal.go b/kamal.go index c5cd73b..3a6d8d1 100644 --- a/kamal.go +++ b/kamal.go @@ -16,6 +16,7 @@ import ( // actionItem describes one action available in the main menu. type actionItem struct { + key string title string desc string needsVersion bool // rollback needs a version/commit hash typed in @@ -29,45 +30,51 @@ func (a actionItem) FilterValue() string { return a.title } func actions() []actionItem { return []actionItem{ { - title: "󰚰 Deploy", + key: "d", + title: "Deploy", desc: "kamal deploy -d ", buildArgs: func(dest, _ string) []string { return withDest([]string{"deploy"}, dest) }, }, { - title: "σ°’“ Setup", - desc: "kamal setup -d (provision servers & deploy)", + key: "R", + title: "Redeploy", + desc: "kamal redeploy -d ", buildArgs: func(dest, _ string) []string { - return withDest([]string{"setup"}, dest) + return withDest([]string{"redeploy"}, dest) }, }, { - title: "σ°ˆ™ Env Push", - desc: "kamal env push -d (push .env variables to servers)", - buildArgs: func(dest, _ string) []string { - return withDest([]string{"env", "push"}, dest) + key: "r", + title: "Rollback", + desc: "kamal rollback -d ", + needsVersion: true, + buildArgs: func(dest, version string) []string { + args := []string{"rollback", version} + return withDest(args, dest) }, }, { - title: "σ°‘™ Redeploy", - desc: "kamal redeploy -d (skip build cache invalidation steps)", + key: "e", + title: "Env Push", + desc: "kamal env push -d ", buildArgs: func(dest, _ string) []string { - return withDest([]string{"redeploy"}, dest) + return withDest([]string{"env", "push"}, dest) }, }, { - title: "󰁯 Rollback", - desc: "kamal rollback -d ", - needsVersion: true, - buildArgs: func(dest, version string) []string { - args := []string{"rollback", version} - return withDest(args, dest) + key: "l", + title: "App Logs", + desc: "kamal app logs -d ", + buildArgs: func(dest, _ string) []string { + return withDest([]string{"app", "logs"}, dest) }, }, { - title: "σ°†Ό DB Dump (Backup)", - desc: "kamal app exec -i -- /bin/sh -c 'pg_dump ...'", + key: "D", + title: "DB Dump", + desc: "kamal app exec -i -- pg_dump ...", buildArgs: func(dest, _ string) []string { args := []string{"app", "exec", "-i"} args = withDest(args, dest) @@ -76,8 +83,9 @@ func actions() []actionItem { }, }, { - title: "σ°—¨ DB Restore", - desc: "kamal app exec -i -- /bin/sh -c 'pg_restore ...'", + key: "S", + title: "DB Restore", + desc: "kamal app exec -i -- pg_restore ...", buildArgs: func(dest, _ string) []string { args := []string{"app", "exec", "-i"} args = withDest(args, dest) @@ -86,36 +94,17 @@ func actions() []actionItem { }, }, { - title: "σ°‹© App Details", - desc: "kamal app details -d ", - buildArgs: func(dest, _ string) []string { - return withDest([]string{"app", "details"}, dest) - }, - }, - { - title: "σ°…© App Logs", - desc: "kamal app logs -d (last lines, no follow)", - buildArgs: func(dest, _ string) []string { - return withDest([]string{"app", "logs"}, dest) - }, - }, - { - title: "󰀡 App Boot", - desc: "kamal app boot -d ", - buildArgs: func(dest, _ string) []string { - return withDest([]string{"app", "boot"}, dest) - }, - }, - { - title: "󰚌 Audit", - desc: "kamal audit -d (recent deploy history)", + key: "a", + title: "Audit", + desc: "kamal audit -d ", buildArgs: func(dest, _ string) []string { return withDest([]string{"audit"}, dest) }, }, { - title: "󰆴 Remove", - desc: "kamal remove -d (remove containers and images from servers)", + key: "X", + title: "Remove", + desc: "kamal remove -d ", buildArgs: func(dest, _ string) []string { return withDest([]string{"remove"}, dest) }, @@ -123,6 +112,16 @@ func actions() []actionItem { } } +// actionByKey finds an action by its shortcut key. +func actionByKey(key string) (actionItem, bool) { + for _, a := range actions() { + if a.key == key { + return a, true + } + } + return actionItem{}, false +} + // withDest appends "-d " unless dest is the empty/default destination. func withDest(args []string, dest string) []string { if dest != "" { diff --git a/main.go b/main.go index 2d85dbf..bbde009 100644 --- a/main.go +++ b/main.go @@ -20,7 +20,6 @@ type panel int const ( panelDestinations panel = iota - panelActions panelLogs ) @@ -59,11 +58,10 @@ type model struct { activePanel panel - actionList list.Model - destList list.Model - verInput textinput.Model - viewport viewport.Model - spinner spinner.Model + destList list.Model + verInput textinput.Model + viewport viewport.Model + spinner spinner.Model selectedAction actionItem selectedDest string @@ -81,6 +79,9 @@ type model struct { showVersionInput bool versionAction actionItem + // Menu overlay + showMenu bool + // Secrets Manager State showSecrets bool addingSecret bool @@ -133,17 +134,6 @@ func detectGitBranch() string { } func initialModel() model { - items := make([]list.Item, 0, len(actions())) - for _, a := range actions() { - items = append(items, a) - } - al := list.New(items, list.NewDefaultDelegate(), 0, 0) - al.Title = "Menu" - al.SetShowStatusBar(false) - al.SetFilteringEnabled(false) - al.SetShowHelp(false) - al.Styles.Title = titleStyle - dests := discoverDestinations() ditems := make([]list.Item, 0, len(dests)) for _, d := range dests { @@ -186,12 +176,11 @@ func initialModel() model { return model{ activePanel: panelDestinations, - actionList: al, destList: dl, verInput: ti, viewport: vp, spinner: sp, - outputBuf: []string{"Welcome to kamal-tui! Select a destination and action.", "Press 's' to manage secrets."}, + outputBuf: []string{"Welcome to kamal-tui! Select a destination and press x for menu.", "Press 's' to manage secrets."}, secList: secList, secKeyIn: secKeyIn, secValIn: secValIn, @@ -234,11 +223,8 @@ func (m *model) layout() { } rightW := m.width - leftW - destH := bodyH / 2 - actionH := bodyH - destH - - m.destList.SetSize(leftW-4, destH-2) - m.actionList.SetSize(leftW-4, actionH-2) + // Destinations fills the full left column height + m.destList.SetSize(leftW-4, bodyH-2) m.viewport.Width = rightW - 4 m.viewport.Height = bodyH - 2 @@ -255,16 +241,8 @@ func (m *model) refreshSecrets() { m.secList.SetItems(items) } -func (m model) handleShortcutAction(titleSubstr string) (tea.Model, tea.Cmd) { - var action actionItem - found := false - for _, a := range actions() { - if strings.Contains(a.title, titleSubstr) { - action = a - found = true - break - } - } +func (m model) handleActionByKey(key string) (tea.Model, tea.Cmd) { + action, found := actionByKey(key) if !found { return m, nil } @@ -304,7 +282,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.MouseMsg: - if m.showSecrets || m.addingSecret || m.showVersionInput || m.showConfirm { + if m.showSecrets || m.addingSecret || m.showVersionInput || m.showConfirm || m.showMenu { return m, nil } leftW := 30 @@ -312,18 +290,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { leftW = m.width / 3 } if msg.X < leftW { - destH := (m.height - 1) / 2 - if msg.Y < destH { - m.activePanel = panelDestinations - var cmd tea.Cmd - m.destList, cmd = m.destList.Update(msg) - cmds = append(cmds, cmd) - } else { - m.activePanel = panelActions - var cmd tea.Cmd - m.actionList, cmd = m.actionList.Update(msg) - cmds = append(cmds, cmd) - } + m.activePanel = panelDestinations + var cmd tea.Cmd + m.destList, cmd = m.destList.Update(msg) + cmds = append(cmds, cmd) } else { m.activePanel = panelLogs var cmd tea.Cmd @@ -339,13 +309,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Quit case "q": - if !m.showVersionInput && !m.running && !m.showSecrets && !m.addingSecret && !m.showConfirm { + if !m.showVersionInput && !m.running && !m.showSecrets && !m.addingSecret && !m.showConfirm && !m.showMenu { if m.cancel != nil { m.cancel() } return m, tea.Quit } case "esc": + if m.showMenu { + m.showMenu = false + return m, nil + } if m.showConfirm { m.showConfirm = false return m, nil @@ -369,13 +343,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break // use ctrl+c to abort } case "tab": - if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm { - m.activePanel = (m.activePanel + 1) % 3 + if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm && !m.showMenu { + m.activePanel = (m.activePanel + 1) % 2 return m, nil } case "shift+tab": - if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm { - m.activePanel = (m.activePanel - 1 + 3) % 3 + if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm && !m.showMenu { + m.activePanel = (m.activePanel - 1 + 2) % 2 return m, nil } } @@ -454,6 +428,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Batch(cmds...) } + if m.showVersionInput { switch msg.String() { case "enter": @@ -476,39 +451,43 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } + // Menu overlay: handle action key presses + if m.showMenu { + key := msg.String() + if _, found := actionByKey(key); found { + m.showMenu = false + return m.handleActionByKey(key) + } + // Unknown key β€” close menu + m.showMenu = false + return m, nil + } + + // Normal mode shortcuts if !m.running { switch msg.String() { - case "d": - return m.handleShortcutAction("Deploy") - case "r": - return m.handleShortcutAction("Rollback") - case "l": - return m.handleShortcutAction("App Logs") + case "x": + m.showMenu = true + return m, nil case "s": m.showSecrets = true m.refreshSecrets() return m, nil + // Direct shortcuts (without opening menu) + case "d": + return m.handleActionByKey("d") } } // Panel specific updates - if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm { + if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm && !m.showMenu { switch m.activePanel { case panelDestinations: var cmd tea.Cmd m.destList, cmd = m.destList.Update(msg) cmds = append(cmds, cmd) if msg.String() == "enter" { - m.activePanel = panelActions - } - case panelActions: - var cmd tea.Cmd - m.actionList, cmd = m.actionList.Update(msg) - cmds = append(cmds, cmd) - if msg.String() == "enter" { - if it, ok := m.actionList.SelectedItem().(actionItem); ok { - return m.handleShortcutAction(it.title) - } + m.activePanel = panelLogs } case panelLogs: var cmd tea.Cmd @@ -594,12 +573,37 @@ func (m model) headerView() string { ) } +// menuView renders the LazyGit-style centered menu overlay. +func (m model) menuView() string { + items := actions() + var rows []string + + for _, a := range items { + key := menuKeyStyle.Render(fmt.Sprintf("%-3s", a.key)) + sep := menuSepStyle.Render(" ") + desc := menuDescStyle.Render(a.title) + rows = append(rows, key+sep+desc) + } + rows = append(rows, "") // blank separator + rows = append(rows, menuKeyStyle.Render("esc")+" "+menuDescStyle.Render("close")) + + inner := lipgloss.JoinVertical(lipgloss.Left, rows...) + box := menuBoxStyle.Render( + lipgloss.JoinVertical(lipgloss.Left, + titleStyle.Render("Menu"), + "", + inner, + ), + ) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) +} + func (m model) View() string { if m.width == 0 { return "loading…" } - // Render overlay if needed + // Menu overlay (highest priority after add-secret) if m.addingSecret { content := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render("Add New Secret"), @@ -633,6 +637,7 @@ func (m model) View() string { return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, activePanelStyle.Width(m.width-10).Render(content)) } + // ── Normal layout ───────────────────────────────────────────────────── leftW := 30 if m.width < 80 { leftW = m.width / 3 @@ -643,26 +648,14 @@ func (m model) View() string { footerH := 1 bodyH := m.height - headerH - footerH - destH := bodyH / 2 - actionH := bodyH - destH - - var destPanel, actionPanel, logPanel string - - // Render Destinations + // Render Destinations (full left column height) style := inactivePanelStyle if m.activePanel == panelDestinations { style = activePanelStyle } - destPanel = style.Width(leftW - 2).Height(destH - 2).Render(m.destList.View()) + destPanel := style.Width(leftW - 2).Height(bodyH - 2).Render(m.destList.View()) - // Render Menu (Actions) - style = inactivePanelStyle - if m.activePanel == panelActions { - style = activePanelStyle - } - actionPanel = style.Width(leftW - 2).Height(actionH - 2).Render(m.actionList.View()) - - // Render Logs panel with dynamic title + // Render Logs panel style = inactivePanelStyle if m.activePanel == panelLogs { style = activePanelStyle @@ -671,7 +664,6 @@ func (m model) View() string { // Build log panel title: "{ActionName} logs" or just "logs" logTitle := "logs" if m.selectedAction.title != "" { - // Strip emoji from title for cleanliness clean := strings.TrimSpace(m.selectedAction.title) logTitle = clean + " logs" } @@ -687,14 +679,18 @@ func (m model) View() string { logContent = lipgloss.Place(rightW-4, bodyH-4, lipgloss.Center, lipgloss.Center, activePanelStyle.Render(overlay)) } - // Compose log panel: title on top, viewport below, inside the border style logInner := lipgloss.JoinVertical(lipgloss.Left, logPanelTitle, logContent) - logPanel = style.Width(rightW - 2).Height(bodyH - 2).Render(logInner) + logPanel := style.Width(rightW - 2).Height(bodyH - 2).Render(logInner) - leftCol := lipgloss.JoinVertical(lipgloss.Left, destPanel, actionPanel) - mainView := lipgloss.JoinHorizontal(lipgloss.Top, leftCol, logPanel) + mainView := lipgloss.JoinHorizontal(lipgloss.Top, destPanel, logPanel) + base := lipgloss.JoinVertical(lipgloss.Left, m.headerView(), mainView, m.footerView()) + + // Render menu overlay on top of base layout + if m.showMenu { + return m.menuView() + } - return lipgloss.JoinVertical(lipgloss.Left, m.headerView(), mainView, m.footerView()) + return base } func destLabel(d string) string { @@ -709,13 +705,13 @@ func (m model) footerView() string { actionHint := "" if m.running { - actionHint = m.spinner.View() + " running... " + actionHint = m.spinner.View() + " running... " } if m.statusLine != "" { - actionHint += m.statusLine + " Β· " + actionHint += m.statusLine + " " } - left = actionHint + "d:deploy r:rollback l:logs s:secrets tab:switch panel q:quit" + left = actionHint + "d:deploy x:menu s:secrets tab:panel q:quit" return statusBarStyle.Width(m.width).Render(left) } diff --git a/styles.go b/styles.go index 506d8ea..b6093a1 100644 --- a/styles.go +++ b/styles.go @@ -4,16 +4,16 @@ import "github.com/charmbracelet/lipgloss" var ( // Tokyo Night-ish / Modern theme - colorBg = lipgloss.Color("#1a1b26") - colorFg = lipgloss.Color("#c0caf5") - colorAccent = lipgloss.Color("#7aa2f7") // Blue - colorActive = lipgloss.Color("#bb9af7") // Purple - colorBorder = lipgloss.Color("#414868") // Dark Gray - colorMuted = lipgloss.Color("#565f89") - colorGood = lipgloss.Color("#9ece6a") // Green - colorBad = lipgloss.Color("#f7768e") // Red - colorWarning = lipgloss.Color("#e0af68") // Yellow - colorHeaderBg = lipgloss.Color("#16161e") // Slightly darker for header strip + colorBg = lipgloss.Color("#1a1b26") + colorFg = lipgloss.Color("#c0caf5") + colorAccent = lipgloss.Color("#7aa2f7") // Blue + colorActive = lipgloss.Color("#bb9af7") // Purple + colorBorder = lipgloss.Color("#414868") // Dark Gray + colorMuted = lipgloss.Color("#565f89") + colorGood = lipgloss.Color("#9ece6a") // Green + colorBad = lipgloss.Color("#f7768e") // Red + colorWarning = lipgloss.Color("#e0af68") // Yellow + colorHeaderBg = lipgloss.Color("#16161e") // Slightly darker for header strip titleStyle = lipgloss.NewStyle(). Bold(true). @@ -30,14 +30,14 @@ var ( Padding(0, 1) inactivePanelStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(colorBorder). - Padding(0, 1) + Border(lipgloss.RoundedBorder()). + BorderForeground(colorBorder). + Padding(0, 1) activePanelStyle = lipgloss.NewStyle(). - Border(lipgloss.ThickBorder()). // Thick border for active panel - BorderForeground(colorActive). - Padding(0, 1) + Border(lipgloss.ThickBorder()). // Thick border for active panel + BorderForeground(colorActive). + Padding(0, 1) helpStyle = lipgloss.NewStyle(). Foreground(colorMuted). @@ -50,14 +50,31 @@ var ( // Header bar: project :: branch shown top-right headerBranchStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(colorAccent). - Background(colorHeaderBg). - Padding(0, 1) + Bold(true). + Foreground(colorAccent). + Background(colorHeaderBg). + Padding(0, 1) // Log panel inner title: "{Action} logs" logPanelTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(colorMuted). + Padding(0, 0, 0, 1) + + // Menu overlay styles (LazyGit-style) + menuBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.DoubleBorder()). + BorderForeground(colorAccent). + Background(colorBg). + Padding(0, 2) + + menuKeyStyle = lipgloss.NewStyle(). Bold(true). - Foreground(colorMuted). - Padding(0, 0, 0, 1) + Foreground(colorAccent) // Blue accent for key column + + menuDescStyle = lipgloss.NewStyle(). + Foreground(colorFg) // White fg for description column + + menuSepStyle = lipgloss.NewStyle(). + Foreground(colorBorder) // Muted separator between key and desc ) From 0dd194635aef784da1e66c816e055e4cd8fb32eb Mon Sep 17 00:00:00 2001 From: 391 Sutthiphod Roopsom Date: Sun, 19 Jul 2026 17:12:50 +0700 Subject: [PATCH 4/4] feat: add container performance dashboard with remote SSH stats (#9) - Add dashboard.go with SSH-based docker stats polling - Parse config/deploy.yml to get remote server hosts - SSH into each server in parallel and run docker stats - Show CPU%, MEM%, NET I/O, Block I/O per container per server - Color-coded metrics (ok/warn/crit thresholds) - Auto-refresh every 8s, manual refresh with 'r' - Fallback to local docker stats if no config found - Wire 'p' keybinding to open dashboard in main.go - Add gopkg.in/yaml.v3 for deploy config parsing --- dashboard.go | 510 +++++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 1 + main.go | 104 ++++++++++- 4 files changed, 613 insertions(+), 3 deletions(-) create mode 100644 dashboard.go diff --git a/dashboard.go b/dashboard.go new file mode 100644 index 0000000..3653e45 --- /dev/null +++ b/dashboard.go @@ -0,0 +1,510 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/charmbracelet/lipgloss" + "gopkg.in/yaml.v3" +) + +// ────────────────────────────────────────────────────────────────────────────── +// Data types +// ────────────────────────────────────────────────────────────────────────────── + +// ContainerStat holds one row from `docker stats --no-stream`. +type ContainerStat struct { + Host string // which remote server this came from + Name string + CPUPct float64 + MemUsage string + MemLimit string + MemPct float64 + NetIn string + NetOut string + BlockIn string + BlockOut string + StatusLv string // "ok" | "warn" | "crit" +} + +// dashRefreshMsg is sent when a new poll cycle completes. +type dashRefreshMsg struct { + stats []ContainerStat + err error +} + +// dashTickMsg drives the periodic refresh timer. +type dashTickMsg struct{} + +// ────────────────────────────────────────────────────────────────────────────── +// Dashboard-specific styles +// ────────────────────────────────────────────────────────────────────────────── + +var ( + dashHdrStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(colorAccent). + PaddingRight(1) + + dashCellStyle = lipgloss.NewStyle(). + Foreground(colorFg). + PaddingRight(1) + + dashOkStyle = lipgloss.NewStyle(). + Foreground(colorGood). + Bold(true). + PaddingRight(1) + + dashWarnStyle = lipgloss.NewStyle(). + Foreground(colorWarning). + Bold(true). + PaddingRight(1) + + dashCritStyle = lipgloss.NewStyle(). + Foreground(colorBad). + Bold(true). + PaddingRight(1) + + dashSepStyle = lipgloss.NewStyle(). + Foreground(colorBorder) + + dashHostStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(colorActive). + PaddingLeft(1) + + dashBarOk = lipgloss.NewStyle().Foreground(colorGood) + dashBarWarn = lipgloss.NewStyle().Foreground(colorWarning) + dashBarCrit = lipgloss.NewStyle().Foreground(colorBad) + dashBarBg = lipgloss.NewStyle().Foreground(colorBorder) +) + +// ────────────────────────────────────────────────────────────────────────────── +// Kamal config parsing β€” read servers from config/deploy[.dest].yml +// ────────────────────────────────────────────────────────────────────────────── + +// deployConfig mirrors the parts of Kamal's deploy.yml we care about. +type deployConfig struct { + SSH struct { + User string `yaml:"user"` + Port int `yaml:"port"` + } `yaml:"ssh"` + Servers interface{} `yaml:"servers"` // can be []string or map[string]role +} + +type kamalRole struct { + Hosts []string `yaml:"hosts"` +} + +// readKamalHosts parses config/deploy[.dest].yml and returns all unique server hosts. +func readKamalHosts(dest string) (hosts []string, sshUser string, sshPort int) { + candidates := []string{ + filepath.Join("config", "deploy.yml"), + } + if dest != "" { + candidates = append(candidates, + filepath.Join("config", fmt.Sprintf("deploy.%s.yml", dest)), + ) + } + + seen := map[string]bool{} + sshUser = "root" // Kamal default + sshPort = 22 + + for _, path := range candidates { + data, err := os.ReadFile(path) + if err != nil { + continue + } + + var cfg deployConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + continue + } + + // SSH user/port + if cfg.SSH.User != "" { + sshUser = cfg.SSH.User + } + if cfg.SSH.Port > 0 { + sshPort = cfg.SSH.Port + } + + // servers can be: + // servers: + // - 1.2.3.4 (simple list) + // or + // servers: + // web: + // hosts: [1.2.3.4] + // worker: + // hosts: [5.6.7.8] + extractHosts(cfg.Servers, seen) + } + + for h := range seen { + hosts = append(hosts, h) + } + return +} + +func extractHosts(raw interface{}, seen map[string]bool) { + if raw == nil { + return + } + switch v := raw.(type) { + case []interface{}: + // Simple list of hosts + for _, item := range v { + if h, ok := item.(string); ok && h != "" { + seen[h] = true + } + } + case map[string]interface{}: + for _, roleVal := range v { + switch rv := roleVal.(type) { + case map[string]interface{}: + // role object: look for "hosts" key + if hostsRaw, ok := rv["hosts"]; ok { + extractHosts(hostsRaw, seen) + } + case []interface{}: + // shorthand role: just a list + extractHosts(rv, seen) + } + } + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Polling β€” SSH into each remote server and run docker stats +// ────────────────────────────────────────────────────────────────────────────── + +const dashPollInterval = 8 * time.Second + +// pollDockerStats fetches container stats from ALL remote Kamal servers. +// It SSHes into each host (in parallel) and runs `docker stats --no-stream`. +func pollDockerStats(ctx context.Context, dest string) ([]ContainerStat, error) { + hosts, sshUser, sshPort := readKamalHosts(dest) + + // Fallback to local docker if no config found (dev mode) + if len(hosts) == 0 { + return pollLocalDockerStats(ctx) + } + + type result struct { + stats []ContainerStat + err error + } + + results := make([]result, len(hosts)) + var wg sync.WaitGroup + + for i, host := range hosts { + wg.Add(1) + go func(idx int, h string) { + defer wg.Done() + stats, err := sshDockerStats(ctx, h, sshUser, sshPort) + results[idx] = result{stats: stats, err: err} + }(i, host) + } + + wg.Wait() + + var all []ContainerStat + var firstErr error + for _, r := range results { + if r.err != nil && firstErr == nil { + firstErr = r.err + } + all = append(all, r.stats...) + } + + if len(all) == 0 && firstErr != nil { + return nil, firstErr + } + return all, nil +} + +// sshDockerStats runs `docker stats --no-stream` on a remote host via SSH. +func sshDockerStats(ctx context.Context, host, user string, port int) ([]ContainerStat, error) { + target := fmt.Sprintf("%s@%s", user, host) + portStr := strconv.Itoa(port) + + cmd := exec.CommandContext(ctx, "ssh", + "-o", "StrictHostKeyChecking=no", + "-o", "ConnectTimeout=8", + "-o", "BatchMode=yes", + "-p", portStr, + target, + `docker stats --no-stream --format "{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}\t{{.BlockIO}}"`, + ) + + out, err := cmd.Output() + if err != nil { + return []ContainerStat{{ + Host: host, + Name: "(SSH failed)", + StatusLv: "crit", + }}, fmt.Errorf("ssh %s: %w", host, err) + } + + stats := parseDockerStats(string(out)) + // Tag each stat with the host it came from + for i := range stats { + stats[i].Host = host + } + return stats, nil +} + +// pollLocalDockerStats is the fallback when no config/deploy.yml is found. +func pollLocalDockerStats(ctx context.Context) ([]ContainerStat, error) { + out, err := exec.CommandContext(ctx, + "docker", "stats", "--no-stream", + "--format", `{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}\t{{.BlockIO}}`, + ).Output() + if err != nil { + return nil, fmt.Errorf("docker stats: %w", err) + } + stats := parseDockerStats(string(out)) + for i := range stats { + stats[i].Host = "localhost" + } + return stats, nil +} + +func parseDockerStats(raw string) []ContainerStat { + var stats []ContainerStat + for _, line := range strings.Split(strings.TrimSpace(raw), "\n") { + if line == "" { + continue + } + parts := strings.Split(line, "\t") + if len(parts) < 6 { + continue + } + cpu := parsePct(parts[1]) + mem := parsePct(parts[3]) + + memParts := strings.SplitN(parts[2], " / ", 2) + memUsage, memLimit := "", "" + if len(memParts) == 2 { + memUsage = strings.TrimSpace(memParts[0]) + memLimit = strings.TrimSpace(memParts[1]) + } + + netParts := strings.SplitN(parts[4], " / ", 2) + netIn, netOut := "", "" + if len(netParts) == 2 { + netIn = strings.TrimSpace(netParts[0]) + netOut = strings.TrimSpace(netParts[1]) + } + + blkParts := strings.SplitN(parts[5], " / ", 2) + blkIn, blkOut := "", "" + if len(blkParts) == 2 { + blkIn = strings.TrimSpace(blkParts[0]) + blkOut = strings.TrimSpace(blkParts[1]) + } + + stats = append(stats, ContainerStat{ + Name: parts[0], + CPUPct: cpu, + MemUsage: memUsage, + MemLimit: memLimit, + MemPct: mem, + NetIn: netIn, + NetOut: netOut, + BlockIn: blkIn, + BlockOut: blkOut, + StatusLv: containerStatusLevel(cpu, mem), + }) + } + return stats +} + +func parsePct(s string) float64 { + s = strings.TrimSuffix(strings.TrimSpace(s), "%") + v, _ := strconv.ParseFloat(s, 64) + return v +} + +func containerStatusLevel(cpu, mem float64) string { + if cpu > 80 || mem > 85 { + return "crit" + } + if cpu > 50 || mem > 70 { + return "warn" + } + return "ok" +} + +// ────────────────────────────────────────────────────────────────────────────── +// Rendering +// ────────────────────────────────────────────────────────────────────────────── + +func renderDashboard(stats []ContainerStat, lastErr error, width int, dest string) string { + const ( + colName = 30 + colCPU = 9 + colMem = 22 + colMemPct = 9 + colNet = 22 + colBlk = 20 + ) + + var sb strings.Builder + + // Title + destLabel := "default" + if dest != "" { + destLabel = dest + } + sb.WriteString(titleStyle.Render(fmt.Sprintf("󰐿 Container Performance [dest: %s]", destLabel))) + sb.WriteString("\n\n") + + if lastErr != nil && len(stats) == 0 { + sb.WriteString(badStyle.Render(" βœ— Error: "+lastErr.Error()) + "\n") + sb.WriteString(helpStyle.Render(" Tip: Make sure SSH keys are set up and the server is reachable.") + "\n\n") + sb.WriteString(helpStyle.Render(" r: retry Β· esc: close")) + return sb.String() + } + + if len(stats) == 0 { + sb.WriteString(helpStyle.Render(" No containers found on remote servers.") + "\n\n") + sb.WriteString(helpStyle.Render(" r: retry Β· esc: close")) + return sb.String() + } + + sep := dashSepStyle.Render(strings.Repeat("─", minInt(width-6, 118))) + + // Column header + hdr := dashHdrStyle.Width(colName).Render(trunc("CONTAINER", colName-1)) + + dashHdrStyle.Width(colCPU).Render("CPU%") + + dashHdrStyle.Width(colMem).Render("MEM USAGE/LIMIT") + + dashHdrStyle.Width(colMemPct).Render("MEM%") + + dashHdrStyle.Width(colNet).Render("NET IN/OUT") + + dashHdrStyle.Width(colBlk).Render("BLK IN/OUT") + + // Group stats by host + hostOrder := []string{} + byHost := map[string][]ContainerStat{} + for _, s := range stats { + if _, exists := byHost[s.Host]; !exists { + hostOrder = append(hostOrder, s.Host) + } + byHost[s.Host] = append(byHost[s.Host], s) + } + + for _, host := range hostOrder { + hostStats := byHost[host] + + // Server section header + sb.WriteString(dashHostStyle.Render(fmt.Sprintf("󰒍 %s", host)) + "\n") + sb.WriteString(" " + hdr + "\n") + sb.WriteString(" " + sep + "\n") + + for _, s := range hostStats { + cpuStr := fmt.Sprintf("%.1f%%", s.CPUPct) + memStr := fmt.Sprintf("%s/%s", s.MemUsage, s.MemLimit) + memPctStr := fmt.Sprintf("%.1f%%", s.MemPct) + netStr := fmt.Sprintf("%s/%s", s.NetIn, s.NetOut) + blkStr := fmt.Sprintf("%s/%s", s.BlockIn, s.BlockOut) + + indicator := dashOkStyle.Render("●") + switch s.StatusLv { + case "warn": + indicator = dashWarnStyle.Render("●") + case "crit": + indicator = dashCritStyle.Render("●") + } + + row := " " + indicator + " " + + dashCellStyle.Width(colName-3).Render(trunc(s.Name, colName-4)) + + colorizePct(cpuStr, s.CPUPct, 50, 80, colCPU) + + dashCellStyle.Width(colMem).Render(trunc(memStr, colMem-1)) + + colorizePct(memPctStr, s.MemPct, 70, 85, colMemPct) + + dashCellStyle.Width(colNet).Render(trunc(netStr, colNet-1)) + + dashCellStyle.Width(colBlk).Render(trunc(blkStr, colBlk-1)) + + sb.WriteString(row + "\n") + + // Mini bars + cpuBar := miniBar(s.CPUPct, 30, s.StatusLv) + memBar := miniBar(s.MemPct, 30, s.StatusLv) + sb.WriteString(fmt.Sprintf(" %s CPU %s MEM\n", cpuBar, memBar)) + sb.WriteString("\n") + } + } + + // Footer + sb.WriteString(" " + sep + "\n") + ts := time.Now().Format("15:04:05") + sb.WriteString(helpStyle.Render(fmt.Sprintf( + " Refreshed: %s Β· Every %ds Β· r: refresh now Β· esc: close", + ts, int(dashPollInterval.Seconds()), + ))) + + return sb.String() +} + +func colorizePct(s string, val, warnT, critT float64, w int) string { + switch { + case val >= critT: + return dashCritStyle.Width(w).Render(trunc(s, w-1)) + case val >= warnT: + return dashWarnStyle.Width(w).Render(trunc(s, w-1)) + default: + return dashOkStyle.Width(w).Render(trunc(s, w-1)) + } +} + +func miniBar(pct float64, barW int, status string) string { + filled := int(pct / 100.0 * float64(barW)) + if filled > barW { + filled = barW + } + if filled < 0 { + filled = 0 + } + empty := barW - filled + + var barStyle *lipgloss.Style + switch status { + case "crit": + barStyle = &dashBarCrit + case "warn": + barStyle = &dashBarWarn + default: + barStyle = &dashBarOk + } + + filledStr := barStyle.Render(strings.Repeat("β–ˆ", filled)) + emptyStr := dashBarBg.Render(strings.Repeat("β–‘", empty)) + return fmt.Sprintf("[%s%s] %4.1f%%", filledStr, emptyStr, pct) +} + +func trunc(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + if n <= 1 { + return string(r[:n]) + } + return string(r[:n-1]) + "…" +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/go.mod b/go.mod index d46f39d..b3cfa6a 100644 --- a/go.mod +++ b/go.mod @@ -30,4 +30,5 @@ require ( golang.org/x/sync v0.8.0 // indirect golang.org/x/sys v0.27.0 // indirect golang.org/x/text v0.3.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 5ac1da8..60ca150 100644 --- a/go.sum +++ b/go.sum @@ -59,5 +59,6 @@ golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index bbde009..515a896 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "os/exec" "path/filepath" "strings" + "time" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" @@ -100,6 +101,12 @@ type model struct { // Header info projectName string gitBranch string + + // Performance Dashboard + showDashboard bool + dashStats []ContainerStat + dashErr error + dashLoading bool } // detectProjectName tries to get a short project name from the git remote URL @@ -193,6 +200,24 @@ func (m model) Init() tea.Cmd { return nil } +// dashFetch runs pollDockerStats in a goroutine and returns the result as a Cmd. +// dest is the currently selected Kamal destination (empty = default). +func dashFetch(dest string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + stats, err := pollDockerStats(ctx, dest) + return dashRefreshMsg{stats: stats, err: err} + } +} + +// dashTick schedules the next auto-refresh after dashPollInterval. +func dashTick() tea.Cmd { + return tea.Tick(dashPollInterval, func(t time.Time) tea.Msg { + return dashTickMsg{} + }) +} + func waitForLine(ch <-chan string) tea.Cmd { return func() tea.Msg { line, ok := <-ch @@ -281,6 +306,25 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.layout() return m, nil + case dashRefreshMsg: + m.dashLoading = false + m.dashStats = msg.stats + m.dashErr = msg.err + if m.showDashboard { + return m, dashTick() + } + return m, nil + + case dashTickMsg: + if m.showDashboard { + dest := "" + if it, ok := m.destList.SelectedItem().(destItem); ok { + dest = string(it) + } + return m, dashFetch(dest) + } + return m, nil + case tea.MouseMsg: if m.showSecrets || m.addingSecret || m.showVersionInput || m.showConfirm || m.showMenu { return m, nil @@ -309,6 +353,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Quit case "q": + if m.showDashboard { + m.showDashboard = false + return m, nil + } if !m.showVersionInput && !m.running && !m.showSecrets && !m.addingSecret && !m.showConfirm && !m.showMenu { if m.cancel != nil { m.cancel() @@ -316,6 +364,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } case "esc": + if m.showDashboard { + m.showDashboard = false + return m, nil + } if m.showMenu { m.showMenu = false return m, nil @@ -342,13 +394,23 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.running { break // use ctrl+c to abort } + case "r": + // Manual refresh when dashboard is open + if m.showDashboard { + m.dashLoading = true + dest := "" + if it, ok := m.destList.SelectedItem().(destItem); ok { + dest = string(it) + } + return m, dashFetch(dest) + } case "tab": - if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm && !m.showMenu { + if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm && !m.showMenu && !m.showDashboard { m.activePanel = (m.activePanel + 1) % 2 return m, nil } case "shift+tab": - if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm && !m.showMenu { + if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm && !m.showMenu && !m.showDashboard { m.activePanel = (m.activePanel - 1 + 2) % 2 return m, nil } @@ -473,6 +535,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.showSecrets = true m.refreshSecrets() return m, nil + case "p": + // Open Performance Dashboard for selected destination + m.showDashboard = true + m.dashLoading = true + dest := "" + if it, ok := m.destList.SelectedItem().(destItem); ok { + dest = string(it) + } + return m, tea.Batch(dashFetch(dest), dashTick()) // Direct shortcuts (without opening menu) case "d": return m.handleActionByKey("d") @@ -603,6 +674,33 @@ func (m model) View() string { return "loading…" } + // ── Performance Dashboard overlay ────────────────────────────────────── + if m.showDashboard { + var content string + if m.dashLoading && len(m.dashStats) == 0 { + dest := "" + if it, ok := m.destList.SelectedItem().(destItem); ok { + dest = string(it) + } + destLabel := "default" + if dest != "" { + destLabel = dest + } + content = titleStyle.Render(fmt.Sprintf("󰐿 Container Performance [dest: %s]", destLabel)) + + "\n\n" + helpStyle.Render(" SSH-ing into remote servers and fetching docker stats…") + } else { + dest := "" + if it, ok := m.destList.SelectedItem().(destItem); ok { + dest = string(it) + } + content = renderDashboard(m.dashStats, m.dashErr, m.width, dest) + } + return activePanelStyle. + Width(m.width - 4). + Height(m.height - 4). + Render(content) + } + // Menu overlay (highest priority after add-secret) if m.addingSecret { content := lipgloss.JoinVertical(lipgloss.Left, @@ -711,7 +809,7 @@ func (m model) footerView() string { if m.statusLine != "" { actionHint += m.statusLine + " " } - left = actionHint + "d:deploy x:menu s:secrets tab:panel q:quit" + left = actionHint + "d:deploy p:dashboard x:menu s:secrets tab:panel q:quit" return statusBarStyle.Width(m.width).Render(left) }