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
|
||||
}
|
||||
105
internal/ui/details.go
Normal file
105
internal/ui/details.go
Normal 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
152
internal/ui/help.go
Normal 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
141
internal/ui/keys.go
Normal 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
122
internal/ui/styles.go
Normal 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
131
internal/ui/table.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user