Files
porthole/internal/store/store.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

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
}