016f93ab4f
Add a monorepo for small single-purpose CLI tools that share a common library, so tools reuse TUI primitives and Kubernetes client bootstrapping instead of duplicating them. - common/tui: direction markers, style palette, width-aware padding/layout - common/kube: in-cluster→kubeconfig client loading + core/metrics clients - podgap: live TUI comparing pod requests/limits vs actual usage, with up/down markers showing per-pod change since the last refresh - Makefile auto-discovers tool directories: make <tool> | all | install
311 lines
6.9 KiB
Go
311 lines
6.9 KiB
Go
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, " ")
|
|
}
|