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.
This commit is contained in:
OpenCode Test
2025-12-24 13:03:08 -08:00
parent c2c03fd664
commit 1421b4659e
40 changed files with 5941 additions and 0 deletions

886
internal/ui/app.go Normal file
View File

@@ -0,0 +1,886 @@
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
}

105
internal/ui/details.go Normal file
View File

@@ -0,0 +1,105 @@
package ui
import (
"fmt"
"sort"
"strings"
"time"
"tower/internal/model"
)
// getRollupSamples extracts sample IDs from a rollup issue's evidence.
func getRollupSamples(iss model.Issue) []string {
samplesStr := iss.Evidence["samples"]
if samplesStr == "" {
return nil
}
parts := strings.Split(samplesStr, " | ")
result := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
result = append(result, p)
}
}
return result
}
// isRollupIssue checks if an issue is a rollup issue.
func isRollupIssue(iss model.Issue) bool {
if strings.HasPrefix(iss.ID, "k8s:rollup:") {
return true
}
if iss.Category == model.CategoryKubernetes && strings.Contains(strings.ToLower(iss.Title), "rollup") {
return true
}
return false
}
func renderIssueDetails(now time.Time, mode AgeMode, iss model.Issue) string {
var b strings.Builder
fmt.Fprintf(&b, "Title: %s\n", oneLine(iss.Title))
fmt.Fprintf(&b, "Priority: %s Category: %s State: %s\n", iss.Priority, iss.Category, iss.State)
fmt.Fprintf(&b, "FirstSeen: %s\n", fmtTime(iss.FirstSeen))
fmt.Fprintf(&b, "LastSeen: %s\n", fmtTime(iss.LastSeen))
fmt.Fprintf(&b, "Age: %s\n", formatAgeWithMode(iss.Age(now), mode))
if strings.TrimSpace(iss.Details) != "" {
b.WriteString("\nDetails\n")
b.WriteString(indentBlock(strings.TrimSpace(iss.Details), " "))
b.WriteString("\n")
}
// Show affected issues for rollup issues
if isRollupIssue(iss) {
samples := getRollupSamples(iss)
if len(samples) > 0 {
b.WriteString("\nAffected Issues\n")
// Show up to 10 samples
maxSamples := 10
if len(samples) > maxSamples {
samples = samples[:maxSamples]
}
for _, sample := range samples {
fmt.Fprintf(&b, " • %s\n", sample)
}
}
}
if len(iss.Evidence) > 0 {
b.WriteString("\nEvidence\n")
keys := make([]string, 0, len(iss.Evidence))
for k := range iss.Evidence {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(&b, " %s: %s\n", k, iss.Evidence[k])
}
}
if strings.TrimSpace(iss.SuggestedFix) != "" {
b.WriteString("\nSuggested Fix\n")
b.WriteString(indentBlock(strings.TrimSpace(iss.SuggestedFix), " "))
b.WriteString("\n")
}
return strings.TrimRight(b.String(), "\n")
}
func fmtTime(t time.Time) string {
if t.IsZero() {
return "-"
}
return t.Local().Format("2006-01-02 15:04:05")
}
func indentBlock(s, prefix string) string {
lines := strings.Split(s, "\n")
for i := range lines {
lines[i] = prefix + lines[i]
}
return strings.Join(lines, "\n")
}

152
internal/ui/help.go Normal file
View File

@@ -0,0 +1,152 @@
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/key"
)
// HelpModel is the help overlay model.
type HelpModel struct {
visible bool
}
// NewHelp creates a new help model.
func NewHelp() HelpModel {
return HelpModel{
visible: false,
}
}
// Show displays the help overlay.
func (m *HelpModel) Show() {
m.visible = true
}
// Hide hides the help overlay.
func (m *HelpModel) Hide() {
m.visible = false
}
// Toggle toggles the help overlay visibility.
func (m *HelpModel) Toggle() {
m.visible = !m.visible
}
// IsVisible returns true if the help overlay is visible.
func (m HelpModel) IsVisible() bool {
return m.visible
}
// Render renders the help overlay.
func (m HelpModel) Render(keys KeyMap, styles Styles) string {
if !m.visible {
return ""
}
var b strings.Builder
// Title
title := styles.HeaderBar.Render("Keybindings - Press ? or esc to close")
b.WriteString(title)
b.WriteString("\n\n")
// Define keybinding groups
groups := []struct {
name string
binds []keyHelp
}{
{
name: "Global",
binds: []keyHelp{
{keys.Help, "Show/hide this help"},
{keys.Quit, "Quit the application"},
{keys.RefreshNow, "Refresh data now"},
},
},
{
name: "Filters",
binds: []keyHelp{
{keys.Search, "Search by title/details"},
{keys.Priority, "Cycle priority filter"},
{keys.Category, "Cycle category filter"},
},
},
{
name: "Navigation",
binds: []keyHelp{
{keys.FocusNext, "Toggle focus (table/details)"},
{keys.Sort, "Cycle sort order"},
{keys.JumpToTop, "Jump to top (g)"},
{keys.JumpToBottom, "Jump to bottom (G)"},
{keys.Down, "Move down (j)"},
{keys.Up, "Move up (k)"},
},
},
{
name: "Actions",
binds: []keyHelp{
{keys.AckToggle, "Acknowledge/unacknowledge issue"},
{keys.Export, "Export issues to JSON"},
},
},
}
// Render each group
for i, group := range groups {
if i > 0 {
b.WriteString("\n")
}
// Group header
groupTitle := styles.HeaderKey.Render(group.name + ":")
b.WriteString(groupTitle)
b.WriteString("\n")
// Keybindings in this group
for _, kb := range group.binds {
line := renderKeyHelp(kb, styles)
b.WriteString(line)
b.WriteString("\n")
}
}
// Render collector health icon legend
b.WriteString("\n")
legendTitle := styles.HeaderKey.Render("Legend:")
b.WriteString(legendTitle)
b.WriteString("\n")
legendText := styles.HeaderVal.Render(" Collector health: ✓ (OK), ⚠ (DEGRADED), ✗ (ERROR)")
b.WriteString(legendText)
b.WriteString("\n")
return b.String()
}
type keyHelp struct {
binding key.Binding
help string
}
func renderKeyHelp(kb keyHelp, styles Styles) string {
// Get key names from the binding
keys := kb.binding.Keys()
if len(keys) == 0 {
return ""
}
// Format key names
keyStr := strings.Join(keys, ", ")
keyStyled := styles.HeaderVal.Render(keyStr)
// Format help text
helpStyled := styles.HeaderVal.Render(kb.help)
// Combine with padding
padding := ""
if needed := 10 - len(keyStr); needed > 0 {
padding = strings.Repeat(" ", needed)
}
return fmt.Sprintf(" %s%s%s", keyStyled, padding, helpStyled)
}

141
internal/ui/keys.go Normal file
View File

@@ -0,0 +1,141 @@
package ui
import "github.com/charmbracelet/bubbles/key"
// KeyMap defines UI keybindings.
//
// Note: Bubble Tea will also handle ctrl+c; we additionally bind q for quit.
type KeyMap struct {
Quit key.Binding
RefreshNow key.Binding
Search key.Binding
Priority key.Binding
PriorityP0 key.Binding
PriorityP1 key.Binding
PriorityP2 key.Binding
PriorityP3 key.Binding
Category key.Binding
Sort key.Binding
FocusNext key.Binding
AckToggle key.Binding
AckAll key.Binding
Export key.Binding
ToggleTheme key.Binding
Help key.Binding
JumpToTop key.Binding
JumpToBottom key.Binding
Down key.Binding
Up key.Binding
Copy key.Binding
ToggleWideTitle key.Binding
ToggleAgeFormat key.Binding
ClearFilters key.Binding
Cancel key.Binding
Apply key.Binding
}
func defaultKeyMap() KeyMap {
return KeyMap{
Quit: key.NewBinding(
key.WithKeys("q"),
key.WithHelp("q", "quit"),
),
RefreshNow: key.NewBinding(
key.WithKeys("r"),
key.WithHelp("r", "refresh now"),
),
Search: key.NewBinding(
key.WithKeys("/"),
key.WithHelp("/", "search"),
),
Priority: key.NewBinding(
key.WithKeys("p"),
key.WithHelp("p", "priority filter"),
),
PriorityP0: key.NewBinding(
key.WithKeys("0"),
key.WithHelp("0", "P0 only"),
),
PriorityP1: key.NewBinding(
key.WithKeys("1"),
key.WithHelp("1", "P1 only"),
),
PriorityP2: key.NewBinding(
key.WithKeys("2"),
key.WithHelp("2", "P2 only"),
),
PriorityP3: key.NewBinding(
key.WithKeys("3"),
key.WithHelp("3", "P3 only"),
),
Category: key.NewBinding(
key.WithKeys("c"),
key.WithHelp("c", "category filter"),
),
Sort: key.NewBinding(
key.WithKeys("s"),
key.WithHelp("s", "cycle sort"),
),
FocusNext: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "focus"),
),
AckToggle: key.NewBinding(
key.WithKeys("a"),
key.WithHelp("a", "ack/unack"),
),
AckAll: key.NewBinding(
key.WithKeys("A", "shift+a"),
key.WithHelp("A", "ack all visible"),
),
Export: key.NewBinding(
key.WithKeys("E"),
key.WithHelp("E", "export"),
),
ToggleTheme: key.NewBinding(
key.WithKeys("T", "shift+t"),
key.WithHelp("T", "toggle theme"),
),
Help: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "show help"),
),
JumpToTop: key.NewBinding(
key.WithKeys("g"),
key.WithHelp("g", "jump to top"),
),
JumpToBottom: key.NewBinding(
key.WithKeys("G", "shift+g"),
key.WithHelp("G", "jump to bottom"),
),
Down: key.NewBinding(
key.WithKeys("j"),
key.WithHelp("j", "down"),
),
Up: key.NewBinding(
key.WithKeys("k"),
key.WithHelp("k", "up"),
),
Copy: key.NewBinding(
key.WithKeys("y"),
key.WithHelp("y", "copy fix"),
),
ToggleWideTitle: key.NewBinding(
key.WithKeys("t"),
key.WithHelp("t", "wide title"),
),
ToggleAgeFormat: key.NewBinding(
key.WithKeys("d"),
key.WithHelp("d", "age format"),
),
ClearFilters: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "clear filters"),
),
Cancel: key.NewBinding(key.WithKeys("esc")),
Apply: key.NewBinding(key.WithKeys("enter")),
}
}

122
internal/ui/styles.go Normal file
View File

@@ -0,0 +1,122 @@
package ui
import "github.com/charmbracelet/lipgloss"
// ThemeMode represents the UI theme mode.
type ThemeMode int
const (
ThemeAuto ThemeMode = iota
ThemeLight
ThemeDark
)
// Styles centralizes all lipgloss styling.
// Keep these simple: excessive styling can slow rendering at high row counts.
type Styles struct {
HeaderBar lipgloss.Style
HeaderKey lipgloss.Style
HeaderVal lipgloss.Style
FilterActive lipgloss.Style
TableHeader lipgloss.Style
TableCell lipgloss.Style
P0 lipgloss.Style
P1 lipgloss.Style
P2 lipgloss.Style
P3 lipgloss.Style
StateOpen lipgloss.Style
StateAck lipgloss.Style
StateRes lipgloss.Style
DetailsTitle lipgloss.Style
DetailsBody lipgloss.Style
Muted lipgloss.Style
Error lipgloss.Style
}
// LightTheme returns light theme styles.
func LightTheme() Styles {
base := lipgloss.NewStyle()
muted := base.Foreground(lipgloss.Color("8"))
return Styles{
HeaderBar: base.
Background(lipgloss.Color("236")).
Foreground(lipgloss.Color("252")).
Padding(0, 1),
HeaderKey: base.Foreground(lipgloss.Color("250")).Bold(true),
HeaderVal: base.Foreground(lipgloss.Color("254")),
FilterActive: base.Bold(true).Foreground(lipgloss.Color("46")),
TableHeader: base.Foreground(lipgloss.Color("252")).Bold(true),
TableCell: base.Foreground(lipgloss.Color("252")),
P0: base.Foreground(lipgloss.Color("9")).Bold(true),
P1: base.Foreground(lipgloss.Color("208")).Bold(true),
P2: base.Foreground(lipgloss.Color("11")),
P3: base.Foreground(lipgloss.Color("10")),
StateOpen: base.Foreground(lipgloss.Color("252")),
StateAck: base.Foreground(lipgloss.Color("14")),
StateRes: muted,
DetailsTitle: base.Bold(true).Foreground(lipgloss.Color("252")),
DetailsBody: base.Foreground(lipgloss.Color("252")),
Muted: muted,
Error: base.Foreground(lipgloss.Color("9")),
}
}
// DarkTheme returns dark theme styles with better contrast.
func DarkTheme() Styles {
base := lipgloss.NewStyle()
muted := base.Foreground(lipgloss.Color("245"))
return Styles{
HeaderBar: base.
Background(lipgloss.Color("238")).
Foreground(lipgloss.Color("231")).
Padding(0, 1),
HeaderKey: base.Foreground(lipgloss.Color("159")).Bold(true),
HeaderVal: base.Foreground(lipgloss.Color("231")),
FilterActive: base.Bold(true).Foreground(lipgloss.Color("84")),
TableHeader: base.Foreground(lipgloss.Color("231")).Bold(true),
TableCell: base.Foreground(lipgloss.Color("231")),
P0: base.Foreground(lipgloss.Color("203")).Bold(true),
P1: base.Foreground(lipgloss.Color("229")).Bold(true),
P2: base.Foreground(lipgloss.Color("48")),
P3: base.Foreground(lipgloss.Color("42")),
StateOpen: base.Foreground(lipgloss.Color("231")),
StateAck: base.Foreground(lipgloss.Color("48")),
StateRes: muted,
DetailsTitle: base.Bold(true).Foreground(lipgloss.Color("231")),
DetailsBody: base.Foreground(lipgloss.Color("231")),
Muted: muted,
Error: base.Foreground(lipgloss.Color("203")),
}
}
func defaultStyles() Styles {
// Default to light theme for backwards compatibility
return LightTheme()
}
func defaultStylesForMode(themeMode ThemeMode) Styles {
switch themeMode {
case ThemeLight:
return LightTheme()
case ThemeDark:
return DarkTheme()
default:
// Auto mode defaults to light theme
return LightTheme()
}
}

131
internal/ui/table.go Normal file
View File

@@ -0,0 +1,131 @@
package ui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/table"
"tower/internal/model"
)
// Column keys, used for future sort expansions.
const (
colPri = "Pri"
colCat = "Cat"
colTitle = "Title"
colAge = "Age"
colState = "State"
)
func newIssueTable() table.Model {
cols := []table.Column{
{Title: colPri, Width: 3},
{Title: colCat, Width: 12},
{Title: colTitle, Width: 0}, // widened on resize
{Title: colAge, Width: 7},
{Title: colState, Width: 13},
}
t := table.New(
table.WithColumns(cols),
table.WithFocused(true),
table.WithHeight(10),
)
// Keep built-in styles minimal.
s := table.DefaultStyles()
s.Header = s.Header.Bold(true)
s.Selected = s.Selected.Bold(false)
t.SetStyles(s)
return t
}
// BuildRows returns table rows and a parallel issue ID slice (row index -> issue ID).
func buildRows(now time.Time, mode AgeMode, issues []model.Issue) ([]table.Row, []string) {
rows := make([]table.Row, 0, len(issues))
ids := make([]string, 0, len(issues))
for _, iss := range issues {
age := formatAgeWithMode(iss.Age(now), mode)
rows = append(rows, table.Row{
iss.Priority.String(),
shortCat(iss.Category.String()),
oneLine(iss.Title),
age,
iss.State.String(),
})
ids = append(ids, iss.ID)
}
return rows, ids
}
func shortCat(cat string) string {
if cat == "" {
return "-"
}
if len(cat) <= 12 {
return cat
}
// Keep category compact; table has limited width.
s := cat
if i := strings.IndexByte(cat, ' '); i > 0 {
s = cat[:i]
}
if len(s) > 12 {
return s[:12]
}
return s
}
func oneLine(s string) string {
s = strings.ReplaceAll(s, "\n", " ")
s = strings.TrimSpace(s)
return s
}
func formatAge(d time.Duration) string {
return formatAgeWithMode(d, AgeCompact)
}
func formatAgeWithMode(d time.Duration, mode AgeMode) string {
if d <= 0 {
if mode == AgeRelative {
return "0m ago"
}
return "0s"
}
if mode == AgeRelative {
// Relative format: Xm ago, Xh ago, Xd ago
if d < time.Minute {
s := int(d / time.Second)
return fmt.Sprintf("%ds ago", s)
}
if d < time.Hour {
m := int(d / time.Minute)
return fmt.Sprintf("%dm ago", m)
}
if d < 24*time.Hour {
h := int(d / time.Hour)
return fmt.Sprintf("%dh ago", h)
}
days := int(d / (24 * time.Hour))
return fmt.Sprintf("%dd ago", days)
}
// Compact format: 0s, Xds, Xdm, Xdh, Xdd
if d < time.Minute {
s := int(d / time.Second)
return fmt.Sprintf("%ds", s)
}
if d < time.Hour {
m := int(d / time.Minute)
return fmt.Sprintf("%dm", m)
}
if d < 24*time.Hour {
h := int(d / time.Hour)
return fmt.Sprintf("%dh", h)
}
days := int(d / (24 * time.Hour))
return fmt.Sprintf("%dd", days)
}