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.
183 lines
4.2 KiB
Go
183 lines
4.2 KiB
Go
package store
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
|
|
"tower/internal/model"
|
|
)
|
|
|
|
const defaultResolveAfter = 30 * time.Second
|
|
|
|
// Store is an in-memory IssueStore.
|
|
//
|
|
// Responsibilities (per PLAN.md):
|
|
// - Dedupe by Issue.ID
|
|
// - Track FirstSeen/LastSeen
|
|
// - Maintain State (Open/Acknowledged/Resolved)
|
|
// - Resolve issues only after resolveAfter duration of continuous absence
|
|
// - Acknowledgements are in-memory only (not persisted)
|
|
// - Safe for concurrent use
|
|
type Store struct {
|
|
mu sync.RWMutex
|
|
|
|
resolveAfter time.Duration
|
|
|
|
// issues holds the latest known version of each issue keyed by stable ID.
|
|
issues map[string]model.Issue
|
|
|
|
// ack is an in-memory toggle keyed by issue ID.
|
|
// If true and the issue is currently present, its state is Acknowledged.
|
|
ack map[string]bool
|
|
}
|
|
|
|
// New returns a new Store.
|
|
// If resolveAfter <= 0, a default of 30s is used.
|
|
func New(resolveAfter time.Duration) *Store {
|
|
if resolveAfter <= 0 {
|
|
resolveAfter = defaultResolveAfter
|
|
}
|
|
return &Store{
|
|
resolveAfter: resolveAfter,
|
|
issues: map[string]model.Issue{},
|
|
ack: map[string]bool{},
|
|
}
|
|
}
|
|
|
|
// Upsert merges "currently true" issues for this tick.
|
|
//
|
|
// Incoming is deduped by Issue.ID; the first instance wins for non-timestamp fields.
|
|
// Timestamps/state are managed by the store.
|
|
func (s *Store) Upsert(now time.Time, incoming []model.Issue) {
|
|
// Pre-dedupe without locking to keep lock hold times small.
|
|
seen := make(map[string]model.Issue, len(incoming))
|
|
for _, iss := range incoming {
|
|
if iss.ID == "" {
|
|
// Ignore invalid issues. ID is the stable dedupe key.
|
|
continue
|
|
}
|
|
if _, ok := seen[iss.ID]; ok {
|
|
continue
|
|
}
|
|
seen[iss.ID] = iss
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for id, in := range seen {
|
|
existing, ok := s.issues[id]
|
|
if !ok || existing.State == model.StateResolved {
|
|
// New issue (or a previously resolved one reappearing): start a new "episode".
|
|
in.FirstSeen = now
|
|
in.LastSeen = now
|
|
in.State = model.StateOpen
|
|
if s.ack[id] {
|
|
in.State = model.StateAcknowledged
|
|
}
|
|
s.issues[id] = in
|
|
continue
|
|
}
|
|
|
|
// Existing open/acked issue: update all fields from incoming, but preserve FirstSeen.
|
|
in.FirstSeen = existing.FirstSeen
|
|
in.LastSeen = now
|
|
in.State = model.StateOpen
|
|
if s.ack[id] {
|
|
in.State = model.StateAcknowledged
|
|
}
|
|
s.issues[id] = in
|
|
}
|
|
|
|
// Update resolved state for issues not present this tick.
|
|
s.applyResolutionsLocked(now, seen)
|
|
}
|
|
|
|
// Snapshot returns a point-in-time copy of all known issues with their states updated
|
|
// according to resolveAfter.
|
|
func (s *Store) Snapshot(now time.Time) []model.Issue {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// Apply resolutions based on time. We don't know which IDs are present "this tick"
|
|
// from Snapshot alone, so we only resolve by absence window (LastSeen age).
|
|
s.applyResolutionsLocked(now, nil)
|
|
|
|
out := make([]model.Issue, 0, len(s.issues))
|
|
for _, iss := range s.issues {
|
|
out = append(out, deepCopyIssue(iss))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Acknowledge marks an issue acknowledged (in-memory only).
|
|
func (s *Store) Acknowledge(id string) {
|
|
if id == "" {
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.ack[id] = true
|
|
iss, ok := s.issues[id]
|
|
if !ok {
|
|
return
|
|
}
|
|
if iss.State != model.StateResolved {
|
|
iss.State = model.StateAcknowledged
|
|
s.issues[id] = iss
|
|
}
|
|
}
|
|
|
|
// Unacknowledge clears the acknowledgement toggle (in-memory only).
|
|
func (s *Store) Unacknowledge(id string) {
|
|
if id == "" {
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
delete(s.ack, id)
|
|
iss, ok := s.issues[id]
|
|
if !ok {
|
|
return
|
|
}
|
|
if iss.State != model.StateResolved {
|
|
iss.State = model.StateOpen
|
|
s.issues[id] = iss
|
|
}
|
|
}
|
|
|
|
func (s *Store) applyResolutionsLocked(now time.Time, present map[string]model.Issue) {
|
|
for id, iss := range s.issues {
|
|
// If caller provided a present set and the ID is present, it cannot be resolved.
|
|
if present != nil {
|
|
if _, ok := present[id]; ok {
|
|
continue
|
|
}
|
|
}
|
|
|
|
if iss.State == model.StateResolved {
|
|
continue
|
|
}
|
|
if s.resolveAfter > 0 && now.Sub(iss.LastSeen) >= s.resolveAfter {
|
|
iss.State = model.StateResolved
|
|
s.issues[id] = iss
|
|
}
|
|
}
|
|
}
|
|
|
|
func deepCopyIssue(in model.Issue) model.Issue {
|
|
out := in
|
|
if in.Evidence != nil {
|
|
m := make(map[string]string, len(in.Evidence))
|
|
for k, v := range in.Evidence {
|
|
m[k] = v
|
|
}
|
|
out.Evidence = m
|
|
}
|
|
return out
|
|
}
|