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:
217
internal/model/issue.go
Normal file
217
internal/model/issue.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Category is the top-level grouping for an Issue.
|
||||
//
|
||||
// It is a string enum for JSON stability and friendliness.
|
||||
type Category string
|
||||
|
||||
const (
|
||||
CategoryPerformance Category = "Performance"
|
||||
CategoryMemory Category = "Memory"
|
||||
CategoryStorage Category = "Storage"
|
||||
CategoryNetwork Category = "Network"
|
||||
CategoryThermals Category = "Thermals"
|
||||
CategoryProcesses Category = "Processes"
|
||||
CategoryServices Category = "Services"
|
||||
CategoryLogs Category = "Logs"
|
||||
CategoryUpdates Category = "Updates"
|
||||
CategorySecurity Category = "Security"
|
||||
CategoryKubernetes Category = "Kubernetes"
|
||||
)
|
||||
|
||||
func (c Category) String() string { return string(c) }
|
||||
|
||||
func (c Category) valid() bool {
|
||||
switch c {
|
||||
case "",
|
||||
CategoryPerformance,
|
||||
CategoryMemory,
|
||||
CategoryStorage,
|
||||
CategoryNetwork,
|
||||
CategoryThermals,
|
||||
CategoryProcesses,
|
||||
CategoryServices,
|
||||
CategoryLogs,
|
||||
CategoryUpdates,
|
||||
CategorySecurity,
|
||||
CategoryKubernetes:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c Category) MarshalJSON() ([]byte, error) {
|
||||
if !c.valid() {
|
||||
return nil, fmt.Errorf("invalid category %q", string(c))
|
||||
}
|
||||
return json.Marshal(string(c))
|
||||
}
|
||||
|
||||
func (c *Category) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := Category(s)
|
||||
if !tmp.valid() {
|
||||
return fmt.Errorf("invalid category %q", s)
|
||||
}
|
||||
*c = tmp
|
||||
return nil
|
||||
}
|
||||
|
||||
// Priority is the urgency of an Issue.
|
||||
//
|
||||
// Priorities are string enums P0..P3 where P0 is most urgent.
|
||||
type Priority string
|
||||
|
||||
const (
|
||||
PriorityP0 Priority = "P0"
|
||||
PriorityP1 Priority = "P1"
|
||||
PriorityP2 Priority = "P2"
|
||||
PriorityP3 Priority = "P3"
|
||||
)
|
||||
|
||||
func (p Priority) String() string { return string(p) }
|
||||
|
||||
// Weight returns a numeric weight used for sorting.
|
||||
// Higher weight means more urgent.
|
||||
func (p Priority) Weight() int {
|
||||
switch p {
|
||||
case PriorityP0:
|
||||
return 4
|
||||
case PriorityP1:
|
||||
return 3
|
||||
case PriorityP2:
|
||||
return 2
|
||||
case PriorityP3:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (p Priority) valid() bool {
|
||||
switch p {
|
||||
case "", PriorityP0, PriorityP1, PriorityP2, PriorityP3:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (p Priority) MarshalJSON() ([]byte, error) {
|
||||
if !p.valid() {
|
||||
return nil, fmt.Errorf("invalid priority %q", string(p))
|
||||
}
|
||||
return json.Marshal(string(p))
|
||||
}
|
||||
|
||||
func (p *Priority) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := Priority(s)
|
||||
if !tmp.valid() {
|
||||
return fmt.Errorf("invalid priority %q", s)
|
||||
}
|
||||
*p = tmp
|
||||
return nil
|
||||
}
|
||||
|
||||
// State is the lifecycle state of an Issue.
|
||||
//
|
||||
// - Open: currently active
|
||||
// - Acknowledged: active but acknowledged in-memory
|
||||
// - Resolved: not observed for some time (resolve-after handled by store)
|
||||
type State string
|
||||
|
||||
const (
|
||||
StateOpen State = "Open"
|
||||
StateAcknowledged State = "Acknowledged"
|
||||
StateResolved State = "Resolved"
|
||||
)
|
||||
|
||||
func (s State) String() string { return string(s) }
|
||||
|
||||
func (s State) valid() bool {
|
||||
switch s {
|
||||
case "", StateOpen, StateAcknowledged, StateResolved:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s State) MarshalJSON() ([]byte, error) {
|
||||
if !s.valid() {
|
||||
return nil, fmt.Errorf("invalid state %q", string(s))
|
||||
}
|
||||
return json.Marshal(string(s))
|
||||
}
|
||||
|
||||
func (s *State) UnmarshalJSON(b []byte) error {
|
||||
var str string
|
||||
if err := json.Unmarshal(b, &str); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := State(str)
|
||||
if !tmp.valid() {
|
||||
return fmt.Errorf("invalid state %q", str)
|
||||
}
|
||||
*s = tmp
|
||||
return nil
|
||||
}
|
||||
|
||||
// Issue is the single unit of information surfaced by ControlTower.
|
||||
type Issue struct {
|
||||
ID string `json:"id"`
|
||||
Category Category `json:"category"`
|
||||
Priority Priority `json:"priority"`
|
||||
Title string `json:"title"`
|
||||
Details string `json:"details,omitempty"`
|
||||
Evidence map[string]string `json:"evidence,omitempty"`
|
||||
SuggestedFix string `json:"suggested_fix,omitempty"`
|
||||
State State `json:"state"`
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
}
|
||||
|
||||
// Age returns how long the issue has existed (now - FirstSeen).
|
||||
// If FirstSeen is zero, Age returns 0.
|
||||
func (i Issue) Age(now time.Time) time.Duration {
|
||||
if i.FirstSeen.IsZero() {
|
||||
return 0
|
||||
}
|
||||
if now.Before(i.FirstSeen) {
|
||||
return 0
|
||||
}
|
||||
return now.Sub(i.FirstSeen)
|
||||
}
|
||||
|
||||
// SortIssuesDefault sorts issues in-place by Priority desc, then LastSeen desc.
|
||||
//
|
||||
// This matches the default view specified in PLAN.md.
|
||||
func SortIssuesDefault(issues []Issue) {
|
||||
sort.SliceStable(issues, func(i, j int) bool {
|
||||
a, b := issues[i], issues[j]
|
||||
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)
|
||||
}
|
||||
// Deterministic tie-breaker.
|
||||
return a.ID < b.ID
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user