Files
porthole/internal/ui/app.go
OpenCode Test 1421b4659e feat: implement ControlTower TUI for cluster and host monitoring
Add complete TUI application for monitoring Kubernetes clusters and host
systems. Features include:

Core features:
- Collector framework with concurrent scheduling
- Host collectors: disk, memory, load, network
- Kubernetes collectors: pods, nodes, workloads, events with informers
- Issue deduplication, state management, and resolve-after logic
- Bubble Tea TUI with table view, details pane, and filtering
- JSON export functionality

UX improvements:
- Help overlay with keybindings
- Priority/category filters with visual indicators
- Direct priority jump (0/1/2/3)
- Bulk acknowledge (Shift+A)
- Clipboard copy (y)
- Theme toggle (T)
- Age format toggle (d)
- Wide title toggle (t)
- Vi-style navigation (j/k)
- Home/End jump (g/G)
- Rollup drill-down in details

Robustness:
- Grace period for unreachable clusters
- Rollups for high-volume issues
- Flap suppression
- RBAC error handling

Files: All core application code with tests for host collectors,
engine, store, model, and export packages.
2025-12-24 13:29:51 -08:00

887 lines
19 KiB
Go

package ui
import (
"fmt"
"os"
"sort"
"strings"
"time"
"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
bubbletea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"tower/internal/engine"
"tower/internal/model"
)
type Focus int
const (
focusTable Focus = iota
focusDetails
focusSearch
)
type SortMode int
const (
sortDefault SortMode = iota // Priority desc, LastSeen desc
sortRecency // LastSeen desc
sortCategory // Category asc, Priority desc, LastSeen desc
)
type AgeMode int
const (
AgeCompact AgeMode = iota // 0s, Xds, Xdm, Xdh, Xdd
AgeRelative // Xm ago, Xh ago, Xd ago
)
type AckFunc func(id string)
type UnackFunc func(id string)
type RefreshNowFunc func()
type ExportFunc func(path string, issues []model.Issue) error
// Model is the Bubble Tea model for the ControlTower UI.
//
// It intentionally keeps rendering cheap:
// - Table rows are only rebuilt when snapshot or filters/sort change.
// - A 1s tick updates header time/age counters without rebuilding rows.
//
//nolint:structcheck // (fields used conditionally based on callbacks)
type Model struct {
host string
styles Styles
keys KeyMap
showHelp bool
focus Focus
snap engine.Snapshot
now time.Time
// Cached view state.
filterPri model.Priority
filterCat model.Category
search string
sortMode SortMode
wideTitle bool
ageMode AgeMode
themeMode ThemeMode
issueByID map[string]model.Issue
rowsIDs []string
table table.Model
details viewport.Model
searchIn textinput.Model
w int
h int
// callbacks
refreshNow RefreshNowFunc
ack AckFunc
unack UnackFunc
export ExportFunc
lastExportPath string
snapshots <-chan engine.Snapshot
lastP0Count int
noBell bool
loaded bool
exporting bool
err error
}
type snapshotMsg engine.Snapshot
type tickMsg time.Time
type exportDoneMsg struct{ err error }
type helpRequestedMsg struct{}
func New(host string, snapshots <-chan engine.Snapshot, refresh RefreshNowFunc, ack AckFunc, unack UnackFunc, export ExportFunc) Model {
if host == "" {
if h, err := os.Hostname(); err == nil {
host = h
}
}
t := newIssueTable()
vp := viewport.New(0, 0)
vp.YPosition = 0
ti := textinput.New()
ti.Placeholder = "search title/details"
ti.Prompt = "/ "
ti.CharLimit = 256
ti.Width = 40
m := Model{
host: host,
styles: defaultStylesForMode(ThemeAuto),
keys: defaultKeyMap(),
focus: focusTable,
sortMode: sortDefault,
themeMode: ThemeAuto,
issueByID: map[string]model.Issue{},
table: t,
details: vp,
searchIn: ti,
snapshots: snapshots,
refreshNow: refresh,
ack: ack,
unack: unack,
export: export,
lastExportPath: "issues.json",
noBell: os.Getenv("NO_BELL") == "1",
loaded: false,
}
m.now = time.Now()
return m
}
func (m Model) Init() bubbletea.Cmd {
return bubbletea.Batch(
waitForSnapshot(m.snapshots),
tickCmd(),
)
}
func waitForSnapshot(ch <-chan engine.Snapshot) bubbletea.Cmd {
return func() bubbletea.Msg {
s, ok := <-ch
if !ok {
return snapshotMsg(engine.Snapshot{})
}
return snapshotMsg(s)
}
}
func tickCmd() bubbletea.Cmd {
return bubbletea.Tick(1*time.Second, func(t time.Time) bubbletea.Msg { return tickMsg(t) })
}
func (m Model) Update(msg bubbletea.Msg) (bubbletea.Model, bubbletea.Cmd) {
switch msg := msg.(type) {
case tickMsg:
m.now = time.Time(msg)
// Keep ticking for header time and details age, but avoid rebuilding rows.
m.setDetailsToSelected()
return m, tickCmd()
case snapshotMsg:
s := engine.Snapshot(msg)
// Channel closed: stop listening.
if s.At.IsZero() && s.Collectors == nil && s.Issues == nil {
return m, nil
}
m.snap = s
m.now = time.Now()
m.loaded = true
// Count P0 before applying to detect new critical issues
newP0Count := 0
for _, iss := range s.Issues {
if iss.Priority == model.PriorityP0 {
newP0Count++
}
}
m.applyViewFromSnapshot()
// Send bell if new P0 issues appeared (check NO_BELL env var to disable)
if newP0Count > m.lastP0Count && !m.noBell {
// Update counter and send bell
m.lastP0Count = newP0Count
// Print bell character to emit terminal bell
fmt.Fprint(os.Stdout, "\a")
}
m.lastP0Count = newP0Count
return m, waitForSnapshot(m.snapshots)
case bubbletea.WindowSizeMsg:
m.w, m.h = msg.Width, msg.Height
m.layout()
return m, nil
}
// Search input mode.
if m.focus == focusSearch {
switch {
case keyMatch(msg, m.keys.Cancel):
m.focus = focusTable
m.searchIn.Blur()
m.searchIn.SetValue(m.search)
return m, nil
case keyMatch(msg, m.keys.ClearFilters):
m.focus = focusTable
m.searchIn.Blur()
m.search = ""
m.applyViewFromSnapshot()
return m, nil
case keyMatch(msg, m.keys.Apply):
m.search = strings.TrimSpace(m.searchIn.Value())
m.focus = focusTable
m.searchIn.Blur()
m.applyViewFromSnapshot()
return m, nil
}
var cmd bubbletea.Cmd
m.searchIn, cmd = m.searchIn.Update(msg)
return m, cmd
}
// Help overlay mode - only help-related keys are processed.
if m.showHelp {
switch {
case keyMatch(msg, m.keys.Help), keyMatch(msg, m.keys.Cancel):
m.showHelp = false
return m, nil
}
// Ignore all other keys while help is shown
return m, nil
}
// Global keybindings.
switch {
case keyMatch(msg, m.keys.Quit):
return m, bubbletea.Quit
case keyMatch(msg, m.keys.RefreshNow):
if m.refreshNow != nil {
m.refreshNow()
}
return m, nil
case keyMatch(msg, m.keys.Search):
m.focus = focusSearch
m.searchIn.SetValue(m.search)
m.searchIn.CursorEnd()
m.searchIn.Focus()
return m, nil
case keyMatch(msg, m.keys.Priority):
m.cyclePriorityFilter()
m.applyViewFromSnapshot()
return m, nil
case keyMatch(msg, m.keys.PriorityP0):
m.filterPri = model.PriorityP0
m.applyViewFromSnapshot()
return m, nil
case keyMatch(msg, m.keys.PriorityP1):
m.filterPri = model.PriorityP1
m.applyViewFromSnapshot()
return m, nil
case keyMatch(msg, m.keys.PriorityP2):
m.filterPri = model.PriorityP2
m.applyViewFromSnapshot()
return m, nil
case keyMatch(msg, m.keys.PriorityP3):
m.filterPri = model.PriorityP3
m.applyViewFromSnapshot()
return m, nil
case keyMatch(msg, m.keys.Category):
m.cycleCategoryFilter()
m.applyViewFromSnapshot()
return m, nil
case keyMatch(msg, m.keys.Sort):
m.sortMode = (m.sortMode + 1) % 3
m.applyViewFromSnapshot()
return m, nil
case keyMatch(msg, m.keys.FocusNext):
if m.focus == focusTable {
m.focus = focusDetails
m.table.Blur()
// viewport has no Focus/Blur; we just route keys.
return m, nil
}
m.focus = focusTable
m.table.Focus()
return m, nil
case keyMatch(msg, m.keys.AckToggle):
m.toggleAckSelected()
return m, nil
case keyMatch(msg, m.keys.AckAll):
m.ackAllVisible()
return m, nil
case keyMatch(msg, m.keys.Export):
if m.export != nil {
m.exporting = true
path := m.lastExportPath
issues := m.snap.Issues
return m, func() bubbletea.Msg {
err := m.export(path, issues)
return exportDoneMsg{err: err}
}
}
return m, nil
case keyMatch(msg, m.keys.Help):
m.showHelp = !m.showHelp
return m, nil
case keyMatch(msg, m.keys.JumpToTop):
if len(m.rowsIDs) > 0 {
m.table.SetCursor(0)
m.setDetailsToSelected()
}
return m, nil
case keyMatch(msg, m.keys.JumpToBottom):
if len(m.rowsIDs) > 0 {
m.table.SetCursor(len(m.rowsIDs) - 1)
m.setDetailsToSelected()
}
return m, nil
case keyMatch(msg, m.keys.Copy):
m.copySelectedToClipboard()
return m, nil
case keyMatch(msg, m.keys.ToggleWideTitle):
m.wideTitle = !m.wideTitle
m.layout()
// Rebuild rows to apply new title width
m.applyViewFromSnapshot()
return m, nil
case keyMatch(msg, m.keys.ToggleAgeFormat):
m.ageMode = (m.ageMode + 1) % 2
m.applyViewFromSnapshot()
return m, nil
case keyMatch(msg, m.keys.ToggleTheme):
// Cycle through theme modes: Auto -> Light -> Dark -> Auto
m.themeMode = (m.themeMode + 1) % 3
m.styles = defaultStylesForMode(m.themeMode)
// Refresh the view with new styles
m.applyViewFromSnapshot()
return m, nil
case keyMatch(msg, m.keys.ClearFilters):
m.filterPri = ""
m.filterCat = ""
m.search = ""
m.applyViewFromSnapshot()
return m, nil
}
// Focus-specific updates.
// Note: bubbles/table already handles page navigation keys (PgUp/PgDn, Ctrl+u/Ctrl+d, Home/End)
// natively, so we don't need to override them here.
switch m.focus {
case focusTable:
var cmd bubbletea.Cmd
m.table, cmd = m.table.Update(msg)
// When selection changes, update details content.
m.setDetailsToSelected()
return m, cmd
case focusDetails:
var cmd bubbletea.Cmd
m.details, cmd = m.details.Update(msg)
return m, cmd
}
switch msg := msg.(type) {
case exportDoneMsg:
m.exporting = false
m.err = msg.err
return m, nil
}
return m, nil
}
func (m *Model) layout() {
if m.w <= 0 || m.h <= 0 {
return
}
// Header: 1 line.
headerH := 1
// Search bar: 1 line (shown only in search focus).
searchH := 0
if m.focus == focusSearch {
searchH = 1
}
bodyH := m.h - headerH - searchH
if bodyH < 4 {
bodyH = 4
}
detailsH := bodyH / 3
tableH := bodyH - detailsH
if tableH < 3 {
tableH = 3
}
// Table width includes 2-character padding from bubbles/table.
// Allocate Title to consume remaining width.
priW, catW, ageW, stateW := 3, 12, 7, 13
fixed := priW + catW + ageW + stateW + 4 // separators/padding
titleW := m.w - fixed
if titleW < 20 {
titleW = 20
}
if m.wideTitle {
// Wide mode: allocate more space to Title column (up to 2x)
titleW = titleW * 2
// Ensure other columns still have minimum space
maxTitle := m.w - fixed
if titleW > maxTitle {
titleW = maxTitle
}
}
cols := m.table.Columns()
for i := range cols {
switch cols[i].Title {
case colPri:
cols[i].Width = priW
case colCat:
cols[i].Width = catW
case colTitle:
cols[i].Width = titleW
case colAge:
cols[i].Width = ageW
case colState:
cols[i].Width = stateW
}
}
m.table.SetColumns(cols)
m.table.SetHeight(tableH)
m.details.Width = m.w
m.details.Height = detailsH
}
func (m *Model) applyViewFromSnapshot() {
// Build ID index for O(1) selection lookup.
m.issueByID = make(map[string]model.Issue, len(m.snap.Issues))
for _, iss := range m.snap.Issues {
m.issueByID[iss.ID] = iss
}
// Show loading state before first snapshot arrives
if !m.loaded {
msg := "Loading collector data... Please wait."
m.details.SetContent(m.styles.Muted.Render(msg))
return
}
// Filter.
filtered := make([]model.Issue, 0, len(m.snap.Issues))
for _, iss := range m.snap.Issues {
if m.filterPri != "" && iss.Priority != m.filterPri {
continue
}
if m.filterCat != "" && iss.Category != m.filterCat {
continue
}
if m.search != "" {
q := strings.ToLower(m.search)
hit := strings.Contains(strings.ToLower(iss.Title), q) || strings.Contains(strings.ToLower(iss.Details), q)
if !hit {
continue
}
}
filtered = append(filtered, iss)
}
// Sort.
sort.SliceStable(filtered, func(i, j int) bool {
a, b := filtered[i], filtered[j]
switch m.sortMode {
case sortRecency:
if !a.LastSeen.Equal(b.LastSeen) {
return a.LastSeen.After(b.LastSeen)
}
return a.ID < b.ID
case sortCategory:
if a.Category != b.Category {
return a.Category < b.Category
}
aw, bw := a.Priority.Weight(), b.Priority.Weight()
if aw != bw {
return aw > bw
}
if !a.LastSeen.Equal(b.LastSeen) {
return a.LastSeen.After(b.LastSeen)
}
return a.ID < b.ID
default:
aw, bw := a.Priority.Weight(), b.Priority.Weight()
if aw != bw {
return aw > bw
}
if !a.LastSeen.Equal(b.LastSeen) {
return a.LastSeen.After(b.LastSeen)
}
return a.ID < b.ID
}
})
rows, ids := buildRows(m.snap.At, m.ageMode, filtered)
m.rowsIDs = ids
prevSelID := m.selectedIssueID()
m.table.SetRows(rows)
if len(rows) == 0 {
m.table.SetCursor(0)
msg := "All systems healthy. No issues detected.\n\nPress r to refresh, / to search past logs"
m.details.SetContent(m.styles.Muted.Render(msg))
return
}
// Try to keep selection stable.
if prevSelID != "" {
for i, id := range ids {
if id == prevSelID {
m.table.SetCursor(i)
break
}
}
}
m.setDetailsToSelected()
m.layout()
}
func (m *Model) selectedIssueID() string {
idx := m.table.Cursor()
if idx < 0 || idx >= len(m.rowsIDs) {
return ""
}
return m.rowsIDs[idx]
}
func (m *Model) setDetailsToSelected() {
id := m.selectedIssueID()
iss, ok := m.issueByID[id]
if !ok {
m.details.SetContent(m.styles.Muted.Render("No issue selected."))
return
}
m.details.SetContent(renderIssueDetails(m.now, m.ageMode, iss))
}
func (m *Model) toggleAckSelected() {
id := m.selectedIssueID()
if id == "" {
return
}
iss, ok := m.issueByID[id]
if !ok {
return
}
if iss.State == model.StateResolved {
return
}
newState := model.StateAcknowledged
if iss.State == model.StateAcknowledged {
newState = model.StateOpen
}
// Callbacks (store-backed if wired).
if newState == model.StateAcknowledged {
if m.ack != nil {
m.ack(id)
}
} else {
if m.unack != nil {
m.unack(id)
}
}
// Optimistic local update (store will correct on next snapshot).
iss.State = newState
m.issueByID[id] = iss
// Update state column cheaply.
idx := m.table.Cursor()
rows := m.table.Rows()
if idx >= 0 && idx < len(rows) {
rows[idx][4] = iss.State.String() // State column index
m.table.SetRows(rows)
}
m.setDetailsToSelected()
}
func (m *Model) ackAllVisible() {
if m.ack == nil {
return
}
// Track updates for table refresh.
updated := false
rows := m.table.Rows()
// Iterate through all visible issues and acknowledge them.
for idx, id := range m.rowsIDs {
iss, ok := m.issueByID[id]
if !ok {
continue
}
// Only acknowledge open issues, not already acked or resolved.
if iss.State == model.StateOpen {
m.ack(id)
// Optimistic local update.
iss.State = model.StateAcknowledged
m.issueByID[id] = iss
// Update state column cheaply.
if idx < len(rows) {
rows[idx][4] = iss.State.String() // State column index
updated = true
}
}
}
if updated {
m.table.SetRows(rows)
m.setDetailsToSelected()
}
}
func (m *Model) copySelectedToClipboard() {
id := m.selectedIssueID()
if id == "" {
return
}
iss, ok := m.issueByID[id]
if !ok {
return
}
// Copy SuggestedFix if available, otherwise fallback to Title
text := iss.SuggestedFix
if text == "" {
text = iss.Title
}
if err := clipboard.WriteAll(text); err != nil {
m.err = fmt.Errorf("Failed to copy to clipboard: %w. Is xclip/xsel installed?", err)
return
}
// Show confirmation in details pane
m.details.SetContent(m.styles.Muted.Render("Copied to clipboard\n\n") + renderIssueDetails(m.now, m.ageMode, iss))
}
func (m *Model) cyclePriorityFilter() {
order := []model.Priority{"", model.PriorityP0, model.PriorityP1, model.PriorityP2, model.PriorityP3}
m.filterPri = cycle(order, m.filterPri)
}
func (m *Model) cycleCategoryFilter() {
order := []model.Category{
"",
model.CategoryPerformance,
model.CategoryMemory,
model.CategoryStorage,
model.CategoryNetwork,
model.CategoryThermals,
model.CategoryProcesses,
model.CategoryServices,
model.CategoryLogs,
model.CategoryUpdates,
model.CategorySecurity,
model.CategoryKubernetes,
}
m.filterCat = cycle(order, m.filterCat)
}
func cycle[T comparable](order []T, cur T) T {
for i := range order {
if order[i] == cur {
return order[(i+1)%len(order)]
}
}
return order[0]
}
func (m Model) View() string {
// Show help overlay when active
if m.showHelp {
return renderHelp(m.keys, m.styles)
}
header := m.renderHeader()
searchLine := ""
if m.focus == focusSearch {
searchLine = m.searchIn.View()
}
tableView := m.table.View()
detailsView := m.renderDetailsPane()
parts := []string{header}
if searchLine != "" {
parts = append(parts, searchLine)
}
parts = append(parts, tableView, detailsView)
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
func (m Model) renderHeader() string {
now := m.now
if now.IsZero() {
now = time.Now()
}
age := "-"
if !m.snap.At.IsZero() {
age = formatAge(now.Sub(m.snap.At))
}
p0, p1, p2, p3 := 0, 0, 0, 0
for _, iss := range m.snap.Issues {
switch iss.Priority {
case model.PriorityP0:
p0++
case model.PriorityP1:
p1++
case model.PriorityP2:
p2++
case model.PriorityP3:
p3++
}
}
okC, degC, errC := 0, 0, 0
for _, h := range m.snap.Collectors {
switch h.Status.Health {
case "OK":
okC++
case "DEGRADED":
degC++
case "ERROR":
errC++
}
}
priFilter := "all"
if m.filterPri != "" {
priFilter = m.filterPri.String()
}
catFilter := "all"
if m.filterCat != "" {
catFilter = m.filterCat.String()
}
sortLabel := map[SortMode]string{sortDefault: "pri→recent", sortRecency: "recent", sortCategory: "cat"}[m.sortMode]
left := fmt.Sprintf(
"host=%s time=%s age=%s P0=%d P1=%d P2=%d P3=%d collectors: ✓%d ⚠%d ✗%d",
m.host,
now.Local().Format("15:04:05"),
age,
p0, p1, p2, p3,
okC, degC, errC,
)
// Add count warning when approaching 200 issues cap (90% = 180)
total := p0 + p1 + p2 + p3
if total >= 180 {
warning := fmt.Sprintf(" [~%d/200]", total)
left += m.styles.Error.Render(warning)
}
// Small right-side indicator for filters.
priStr := fmt.Sprintf("pri=%s", priFilter)
catStr := fmt.Sprintf("cat=%s", catFilter)
if m.filterPri != "" {
priStr = m.styles.FilterActive.Render(priStr)
}
if m.filterCat != "" {
catStr = m.styles.FilterActive.Render(catStr)
}
right := fmt.Sprintf("filter %s %s q=%q sort=%s", priStr, catStr, m.search, sortLabel)
if m.w > 0 {
// Truncate right if needed.
space := m.w - lipgloss.Width(left) - 1
if space < 0 {
space = 0
}
if lipgloss.Width(right) > space {
right = lipgloss.NewStyle().MaxWidth(space).Render(right)
}
padLen := 0
if space > 0 {
padLen = max(1, space-lipgloss.Width(right))
}
pad := strings.Repeat(" ", padLen)
return m.styles.HeaderBar.Render(left + pad + right)
}
return m.styles.HeaderBar.Render(left + " " + right)
}
func (m Model) renderDetailsPane() string {
title := "Details"
if m.focus == focusDetails {
title = title + " (focus)"
}
body := m.details.View()
if m.exporting {
body = "Exporting issues to " + m.lastExportPath + "..."
}
if m.err != nil {
body = body + "\n" + m.styles.Error.Render(m.err.Error())
}
// Keep the details title cheap and avoid borders (can be expensive).
return m.styles.DetailsTitle.Render(title) + "\n" + body
}
func renderHelp(keys KeyMap, styles Styles) string {
// Create a temporary help model and render it
help := NewHelp()
help.Show()
return help.Render(keys, styles)
}
func keyMatch(msg bubbletea.Msg, b key.Binding) bool {
km, ok := msg.(bubbletea.KeyMsg)
if !ok {
return false
}
return key.Matches(km, b)
}
func max(a, b int) int {
if a > b {
return a
}
return b
}