Files
porthole/internal/model/issue.go
OpenCode Test 1421b4659e 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.
2025-12-24 13:29:51 -08:00

218 lines
4.8 KiB
Go

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
})
}