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:
886
internal/ui/app.go
Normal file
886
internal/ui/app.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user