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.
887 lines
19 KiB
Go
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
|
|
}
|