From 7c960d60c4025a5f4be4989e175b6a8256de0780 Mon Sep 17 00:00:00 2001 From: stawan15 Date: Sun, 19 Jul 2026 17:11:43 +0700 Subject: [PATCH] feat: add container performance dashboard with remote SSH stats - 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) }