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 }