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:
OpenCode Test
2025-12-24 13:03:08 -08:00
parent c2c03fd664
commit 1421b4659e
40 changed files with 5941 additions and 0 deletions

98
internal/export/json.go Normal file
View File

@@ -0,0 +1,98 @@
package export
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"tower/internal/model"
)
// WriteIssues writes a JSON snapshot of issues to path.
//
// It attempts to be atomic by writing to a temporary file in the same directory
// and then renaming it into place.
func WriteIssues(path string, issues []model.Issue) error {
if path == "" {
return fmt.Errorf("export: path is empty")
}
cleanPath := filepath.Clean(path)
if strings.Contains(cleanPath, ".."+string(filepath.Separator)) {
return fmt.Errorf("export: path traversal not allowed: %s", path)
}
if filepath.IsAbs(cleanPath) {
return fmt.Errorf("export: absolute paths not allowed: %s", path)
}
// Ensure we always write a JSON array, even if caller passes a nil slice.
if issues == nil {
issues = []model.Issue{}
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("export: create dir %q: %w", dir, err)
}
base := filepath.Base(path)
tmp, err := os.CreateTemp(dir, base+".*.tmp")
if err != nil {
return fmt.Errorf("export: create temp file: %w", err)
}
// Make the resulting snapshot readable by default.
if err := tmp.Chmod(0o644); err != nil {
log.Printf("export: warning: failed to chmod temp file %q: %v", tmp.Name(), err)
}
tmpName := tmp.Name()
cleanup := func() {
if err := tmp.Close(); err != nil {
log.Printf("export: warning: failed to close temp file %q: %v", tmpName, err)
}
if err := os.Remove(tmpName); err != nil && !os.IsNotExist(err) {
log.Printf("export: warning: failed to remove temp file %q: %v", tmpName, err)
}
}
enc := json.NewEncoder(tmp)
enc.SetIndent("", " ")
// This is a snapshot file for humans; keep it readable.
enc.SetEscapeHTML(false)
if err := enc.Encode(issues); err != nil {
cleanup()
return fmt.Errorf("export: encode json: %w", err)
}
// Best effort durability before rename.
if err := tmp.Sync(); err != nil {
cleanup()
return fmt.Errorf("export: sync temp file: %w", err)
}
if err := tmp.Close(); err != nil {
cleanup()
return fmt.Errorf("export: close temp file: %w", err)
}
// On POSIX, rename is atomic when source and destination are on the same FS.
if err := os.Rename(tmpName, path); err != nil {
// Best-effort fallback for platforms where rename fails if destination exists.
if rmErr := os.Remove(path); rmErr == nil {
if err2 := os.Rename(tmpName, path); err2 == nil {
return nil
}
}
cleanup()
return fmt.Errorf("export: rename into place: %w", err)
}
return nil
}

View File

@@ -0,0 +1,47 @@
package export
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// Note: model.Issue fields are not validated here; this test ensures the writer
// creates valid JSON and writes atomically into place.
func TestWriteIssues_WritesIndentedJSON(t *testing.T) {
t.Parallel()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("get working dir: %v", err)
}
testDir := filepath.Join(wd, "testdata", t.Name())
if err := os.MkdirAll(testDir, 0o755); err != nil {
t.Fatalf("create test dir: %v", err)
}
defer os.RemoveAll(testDir)
outPath := filepath.Join("testdata", t.Name(), "issues.json")
// Use an empty slice to avoid depending on model.Issue definition.
if err := WriteIssues(outPath, nil); err != nil {
t.Fatalf("WriteIssues error: %v", err)
}
b, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("read file: %v", err)
}
// Ensure valid JSON.
var v any
if err := json.Unmarshal(b, &v); err != nil {
t.Fatalf("invalid json: %v\ncontent=%s", err, string(b))
}
// encoding/json.Encoder.Encode adds a trailing newline; and SetIndent should
// produce multi-line output for arrays/objects.
if len(b) == 0 || b[len(b)-1] != '\n' {
t.Fatalf("expected trailing newline")
}
}