Files
porthole/internal/collectors/k8s/rollup.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

129 lines
3.2 KiB
Go

package k8s
import (
"fmt"
"sort"
"strings"
"tower/internal/model"
)
// RollupKey groups similar issues to reduce UI noise.
// Required grouping per prompt: (namespace, reason, kind).
type RollupKey struct {
Namespace string
Reason string
Kind string
}
// Rollup groups issues by (namespace, reason, kind). For any group with size >=
// threshold, it emits a single rollup issue and removes the individual issues
// from the output.
//
// Rollup issues use Priority of the max priority in the group.
func Rollup(issues []model.Issue, threshold int, sampleN int) []model.Issue {
if threshold <= 0 {
threshold = 20
}
if sampleN <= 0 {
sampleN = 5
}
groups := make(map[RollupKey][]model.Issue, 32)
ungrouped := make([]model.Issue, 0, len(issues))
for _, iss := range issues {
kind := strings.TrimSpace(iss.Evidence["kind"])
reason := strings.TrimSpace(iss.Evidence["reason"])
ns := strings.TrimSpace(iss.Evidence["namespace"])
if kind == "" || reason == "" {
ungrouped = append(ungrouped, iss)
continue
}
k := RollupKey{Namespace: ns, Reason: reason, Kind: kind}
groups[k] = append(groups[k], iss)
}
rolled := make([]model.Issue, 0, len(issues))
rolled = append(rolled, ungrouped...)
// Stable order for determinism.
keys := make([]RollupKey, 0, len(groups))
for k := range groups {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].Namespace != keys[j].Namespace {
return keys[i].Namespace < keys[j].Namespace
}
if keys[i].Kind != keys[j].Kind {
return keys[i].Kind < keys[j].Kind
}
return keys[i].Reason < keys[j].Reason
})
for _, k := range keys {
grp := groups[k]
if len(grp) < threshold {
rolled = append(rolled, grp...)
continue
}
// determine max priority
maxP := model.PriorityP3
for _, iss := range grp {
if iss.Priority.Weight() > maxP.Weight() {
maxP = iss.Priority
}
}
titleNS := ""
if k.Namespace != "" {
titleNS = fmt.Sprintf(" (ns=%s)", k.Namespace)
}
title := fmt.Sprintf("%d %ss %s%s", len(grp), strings.ToLower(k.Kind), k.Reason, titleNS)
samples := make([]string, 0, sampleN)
for i := 0; i < len(grp) && i < sampleN; i++ {
s := grp[i].Title
if s == "" {
s = grp[i].ID
}
samples = append(samples, s)
}
rolled = append(rolled, model.Issue{
ID: fmt.Sprintf("k8s:rollup:%s:%s:%s", k.Namespace, k.Kind, k.Reason),
Category: model.CategoryKubernetes,
Priority: maxP,
Title: title,
Details: "Many similar Kubernetes issues were aggregated into this rollup.",
Evidence: map[string]string{
"kind": k.Kind,
"reason": k.Reason,
"namespace": k.Namespace,
"count": fmt.Sprintf("%d", len(grp)),
"samples": strings.Join(samples, " | "),
},
SuggestedFix: "Filter events/pods and inspect samples with kubectl describe.",
})
}
return rolled
}
// CapIssues enforces a hard cap after rollups. This should be applied after
// sorting by default sort order (priority desc, recency desc), but we keep this
// helper pure and simple.
func CapIssues(issues []model.Issue, max int) []model.Issue {
if max <= 0 {
max = 200
}
if len(issues) <= max {
return issues
}
out := make([]model.Issue, max)
copy(out, issues[:max])
return out
}