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.
99 lines
2.5 KiB
Go
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
|
|
}
|