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

99 lines
2.5 KiB
Go

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
}