package main import ( "context" "fmt" "strings" "time" ctui "git.unkin.net/unkin/benvin-utils/common/tui" tea "github.com/charmbracelet/bubbletea" "k8s.io/client-go/kubernetes" metricsv "k8s.io/metrics/pkg/client/clientset/versioned" ) // runTUI launches the interactive, self-refreshing table. func runTUI(core kubernetes.Interface, mc metricsv.Interface, opts options) error { if opts.interval <= 0 { opts.interval = 3 * time.Second } m := &model{ core: core, mc: mc, opts: opts, sortKey: opts.sortKey, prev: map[string]row{}, dirCPU: map[string]int{}, dirMem: map[string]int{}, } _, err := tea.NewProgram(m, tea.WithAltScreen()).Run() return err } type model struct { core kubernetes.Interface mc metricsv.Interface opts options rows []row prev map[string]row // previous usage per pod, for direction markers dirCPU map[string]int // -1 down, 0 flat, +1 up (since last refresh) dirMem map[string]int sortKey string offset int width int height int lastUpdate time.Time paused bool err error } type rowsMsg struct { rows []row err error } type tickMsg struct{} func (m *model) Init() tea.Cmd { return tea.Batch(m.refresh(), m.tick()) } func (m *model) refresh() tea.Cmd { return func() tea.Msg { rows, err := collect(context.Background(), m.core, m.mc, m.opts.namespace) if err != nil { return rowsMsg{err: err} } rows = filterRows(rows, m.opts) sortRows(rows, m.sortKey) if m.opts.top > 0 && len(rows) > m.opts.top { rows = rows[:m.opts.top] } return rowsMsg{rows: rows} } } func (m *model) tick() tea.Cmd { return tea.Tick(m.opts.interval, func(time.Time) tea.Msg { return tickMsg{} }) } func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height return m, nil case tickMsg: if m.paused { return m, m.tick() } return m, tea.Batch(m.refresh(), m.tick()) case rowsMsg: if msg.err != nil { m.err = msg.err return m, nil } m.err = nil m.computeDirections(msg.rows) m.rows = msg.rows m.lastUpdate = time.Now() m.clampOffset() return m, nil case tea.KeyMsg: return m.handleKey(msg) } return m, nil } // computeDirections compares each pod's new usage against the last snapshot. func (m *model) computeDirections(next []row) { for _, r := range next { k := r.Namespace + "/" + r.Pod if p, ok := m.prev[k]; ok { m.dirCPU[k] = ctui.Sign(r.CPUUseM - p.CPUUseM) m.dirMem[k] = ctui.Sign(r.MemUseB - p.MemUseB) } m.prev[k] = r } } func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "ctrl+c", "esc": return m, tea.Quit case "r": return m, m.refresh() case "p", " ": m.paused = !m.paused case "up", "k": if m.offset > 0 { m.offset-- } case "down", "j": m.offset++ m.clampOffset() case "pgup": m.offset -= m.visibleRows() m.clampOffset() case "pgdown": m.offset += m.visibleRows() m.clampOffset() case "g", "home": m.offset = 0 case "G", "end": m.offset = len(m.rows) m.clampOffset() case "c": m.reSort("cpu-waste") case "C": m.reSort("cpu-util") case "m": m.reSort("mem-waste") case "M": m.reSort("mem-util") case "n": m.reSort("name") } return m, nil } func (m *model) reSort(key string) { m.sortKey = key m.offset = 0 sortRows(m.rows, m.sortKey) } func (m *model) visibleRows() int { if v := m.height - 4; v >= 1 { // title + header + footer + margin return v } return 1 } func (m *model) clampOffset() { if max := len(m.rows) - m.visibleRows(); m.offset > max { m.offset = max } if m.offset < 0 { m.offset = 0 } } // column widths (POD absorbs the remaining width) const ( wNS = 14 wCPU = 20 wPct = 5 wWst = 9 wArr = 1 nGaps = 9 // one space between each of the 10 columns ) func (m *model) View() string { if m.width == 0 { return "starting…" } var b strings.Builder scope := "all namespaces" if m.opts.namespace != "" { scope = "ns=" + m.opts.namespace } state := "live" if m.paused { state = "PAUSED" } ts := "—" if !m.lastUpdate.IsZero() { ts = m.lastUpdate.Format("15:04:05") } title := fmt.Sprintf(" podgap %s sort=%s %s updated %s %d pods ", scope, m.sortKey, state, ts, len(m.rows)) b.WriteString(ctui.Title.Render(title)) b.WriteString("\n") if m.err != nil { b.WriteString(ctui.Err.Render("error: " + m.err.Error())) b.WriteString("\n") } podW := m.podWidth() header := layout(podW, "NAMESPACE", "POD", "CPU req→use/lim", "", "CPU%", "MEM req→use/lim", "", "MEM%", "CPU wst", "MEM wst") b.WriteString(ctui.Header.Render(ctui.PadVisible(header, m.width))) b.WriteString("\n") vis := m.visibleRows() end := m.offset + vis if end > len(m.rows) { end = len(m.rows) } for i := m.offset; i < end; i++ { b.WriteString(m.renderRow(m.rows[i], podW)) b.WriteString("\n") } for i := end - m.offset; i < vis; i++ { b.WriteString("\n") } b.WriteString(ctui.Dim.Render(m.footer())) return b.String() } func (m *model) footer() string { more := "" if len(m.rows) > m.visibleRows() { more = fmt.Sprintf(" rows %d-%d/%d", m.offset+1, min(m.offset+m.visibleRows(), len(m.rows)), len(m.rows)) } return " ↑↓ scroll · c/C cpu · m/M mem · n name · p pause · r refresh · q quit" + more + " ▲ usage up ▼ usage down" } func (m *model) podWidth() int { fixed := wNS + wCPU + wArr + wPct + wCPU + wArr + wPct + wWst + wWst + nGaps if w := m.width - fixed; w >= 12 { return w } return 12 } func (m *model) renderRow(r row, podW int) string { k := r.Namespace + "/" + r.Pod return layout(podW, r.Namespace, ctui.Truncate(r.Pod, podW), fmt.Sprintf("%s→%s/%s", cpu(r.CPUReqM), cpu(r.CPUUseM), cpu(r.CPULimM)), ctui.Arrow(m.dirCPU[k]), colorPct(r.cpuUtil(), r.CPUReqM), fmt.Sprintf("%s→%s/%s", mem(r.MemReqB), mem(r.MemUseB), mem(r.MemLimB)), ctui.Arrow(m.dirMem[k]), colorPct(r.memUtil(), r.MemReqB), cpu(r.cpuWasteM()), mem(r.memWasteB()), ) } // colorPct formats a utilization percentage and tints it by pressure. func colorPct(util float64, req int64) string { if req == 0 { return ctui.Dim.Render(ctui.Pad("n/a", wPct)) } s := ctui.Pad(fmt.Sprintf("%.0f%%", util*100), wPct) switch { case util >= 1.0: return ctui.Hot.Render(s) case util >= 0.8: return ctui.Warn.Render(s) default: return s } } // layout pads each cell to its fixed width (POD uses podW) and joins with spaces. // Cells may contain ANSI styling, so widths are measured on the visible text. func layout(podW int, ns, pod, cpuc, cpuArr, cpuPct, memc, memArr, memPct, cpuWst, memWst string) string { cells := []string{ ctui.Pad(ns, wNS), ctui.Pad(pod, podW), ctui.Pad(cpuc, wCPU), ctui.PadVisible(cpuArr, wArr), ctui.PadVisible(cpuPct, wPct), ctui.Pad(memc, wCPU), ctui.PadVisible(memArr, wArr), ctui.PadVisible(memPct, wPct), ctui.Pad(cpuWst, wWst), ctui.Pad(memWst, wWst), } return strings.Join(cells, " ") }