orama/pkg/inspector/results_writer.go

355 lines
9.5 KiB
Go

package inspector
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// FailureGroup groups identical check failures/warnings across nodes.
type FailureGroup struct {
ID string
Name string // from first check in group
Status Status
Severity Severity
Subsystem string
Nodes []string // affected node names (deduplicated)
Messages []string // unique messages (capped at 5)
Count int // total raw occurrence count (before dedup)
}
// GroupFailures collapses CheckResults into unique failure groups keyed by (ID, Status).
// Only failures and warnings are grouped; passes and skips are ignored.
func GroupFailures(results *Results) []FailureGroup {
type groupKey struct {
ID string
Status Status
}
seen := map[groupKey]*FailureGroup{}
nodesSeen := map[groupKey]map[string]bool{}
var order []groupKey
for _, c := range results.Checks {
if c.Status != StatusFail && c.Status != StatusWarn {
continue
}
k := groupKey{ID: c.ID, Status: c.Status}
g, exists := seen[k]
if !exists {
g = &FailureGroup{
ID: c.ID,
Name: c.Name,
Status: c.Status,
Severity: c.Severity,
Subsystem: c.Subsystem,
}
seen[k] = g
nodesSeen[k] = map[string]bool{}
order = append(order, k)
}
g.Count++
node := c.Node
if node == "" {
node = "cluster-wide"
}
// Deduplicate nodes (a node may appear for multiple targets)
if !nodesSeen[k][node] {
nodesSeen[k][node] = true
g.Nodes = append(g.Nodes, node)
}
// Track unique messages (cap at 5 to avoid bloat)
if len(g.Messages) < 5 {
found := false
for _, m := range g.Messages {
if m == c.Message {
found = true
break
}
}
if !found {
g.Messages = append(g.Messages, c.Message)
}
}
}
// Sort: failures before warnings, then by severity (high first), then by ID
groups := make([]FailureGroup, 0, len(order))
for _, k := range order {
groups = append(groups, *seen[k])
}
sort.Slice(groups, func(i, j int) bool {
oi, oj := statusOrder(groups[i].Status), statusOrder(groups[j].Status)
if oi != oj {
return oi < oj
}
if groups[i].Severity != groups[j].Severity {
return groups[i].Severity > groups[j].Severity
}
return groups[i].ID < groups[j].ID
})
return groups
}
// WriteResults saves inspection results as markdown files to a timestamped directory.
// Returns the output directory path.
func WriteResults(baseDir, env string, results *Results, data *ClusterData, analysis *AnalysisResult) (string, error) {
ts := time.Now().Format("2006-01-02_150405")
dir := filepath.Join(baseDir, env, ts)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("create output directory: %w", err)
}
groups := GroupFailures(results)
// Build analysis lookup: groupID -> analysis text
analysisMap := map[string]string{}
if analysis != nil {
for _, sa := range analysis.Analyses {
key := sa.GroupID
if key == "" {
key = sa.Subsystem
}
if sa.Error == nil {
analysisMap[key] = sa.Analysis
}
}
}
// Write summary.md
if err := writeSummary(dir, env, ts, results, data, groups, analysisMap); err != nil {
return "", fmt.Errorf("write summary: %w", err)
}
// Group checks by subsystem for per-subsystem files
checksBySubsystem := map[string][]CheckResult{}
for _, c := range results.Checks {
checksBySubsystem[c.Subsystem] = append(checksBySubsystem[c.Subsystem], c)
}
groupsBySubsystem := map[string][]FailureGroup{}
for _, g := range groups {
groupsBySubsystem[g.Subsystem] = append(groupsBySubsystem[g.Subsystem], g)
}
// Write per-subsystem files
for sub, checks := range checksBySubsystem {
subGroups := groupsBySubsystem[sub]
if err := writeSubsystem(dir, sub, ts, checks, subGroups, analysisMap); err != nil {
return "", fmt.Errorf("write %s: %w", sub, err)
}
}
return dir, nil
}
func writeSummary(dir, env, ts string, results *Results, data *ClusterData, groups []FailureGroup, analysisMap map[string]string) error {
var b strings.Builder
passed, failed, warned, skipped := results.Summary()
b.WriteString(fmt.Sprintf("# %s Inspection Report\n\n", strings.ToUpper(env)))
b.WriteString(fmt.Sprintf("**Date:** %s \n", ts))
b.WriteString(fmt.Sprintf("**Nodes:** %d \n", len(data.Nodes)))
b.WriteString(fmt.Sprintf("**Total:** %d passed, %d failed, %d warnings, %d skipped \n\n", passed, failed, warned, skipped))
// Per-subsystem table
subStats := map[string][4]int{} // [pass, fail, warn, skip]
var subsystems []string
for _, c := range results.Checks {
if _, exists := subStats[c.Subsystem]; !exists {
subsystems = append(subsystems, c.Subsystem)
}
s := subStats[c.Subsystem]
switch c.Status {
case StatusPass:
s[0]++
case StatusFail:
s[1]++
case StatusWarn:
s[2]++
case StatusSkip:
s[3]++
}
subStats[c.Subsystem] = s
}
sort.Strings(subsystems)
// Count issue groups per subsystem
issueCountBySub := map[string]int{}
for _, g := range groups {
issueCountBySub[g.Subsystem]++
}
b.WriteString("## Subsystems\n\n")
b.WriteString("| Subsystem | Pass | Fail | Warn | Skip | Issues |\n")
b.WriteString("|-----------|------|------|------|------|--------|\n")
for _, sub := range subsystems {
s := subStats[sub]
issues := issueCountBySub[sub]
link := fmt.Sprintf("[%s](%s.md)", sub, sub)
b.WriteString(fmt.Sprintf("| %s | %d | %d | %d | %d | %d |\n", link, s[0], s[1], s[2], s[3], issues))
}
b.WriteString("\n")
// Critical issues section
critical := filterGroupsBySeverity(groups, High)
if len(critical) > 0 {
b.WriteString("## Critical Issues\n\n")
for i, g := range critical {
icon := "FAIL"
if g.Status == StatusWarn {
icon = "WARN"
}
nodeInfo := fmt.Sprintf("%d nodes", len(g.Nodes))
if g.Count > len(g.Nodes) {
nodeInfo = fmt.Sprintf("%d nodes (%d occurrences)", len(g.Nodes), g.Count)
}
b.WriteString(fmt.Sprintf("%d. **[%s]** %s — %s \n", i+1, icon, g.Name, nodeInfo))
b.WriteString(fmt.Sprintf(" *%s* → [details](%s.md#%s) \n",
g.Messages[0], g.Subsystem, anchorID(g.Name)))
}
b.WriteString("\n")
}
// Collection errors
var errs []string
for _, nd := range data.Nodes {
for _, e := range nd.Errors {
errs = append(errs, fmt.Sprintf("- **%s**: %s", nd.Node.Name(), e))
}
}
if len(errs) > 0 {
b.WriteString("## Collection Errors\n\n")
for _, e := range errs {
b.WriteString(e + "\n")
}
b.WriteString("\n")
}
return os.WriteFile(filepath.Join(dir, "summary.md"), []byte(b.String()), 0o644)
}
func writeSubsystem(dir, subsystem, ts string, checks []CheckResult, groups []FailureGroup, analysisMap map[string]string) error {
var b strings.Builder
// Count
var passed, failed, warned, skipped int
for _, c := range checks {
switch c.Status {
case StatusPass:
passed++
case StatusFail:
failed++
case StatusWarn:
warned++
case StatusSkip:
skipped++
}
}
b.WriteString(fmt.Sprintf("# %s\n\n", strings.ToUpper(subsystem)))
b.WriteString(fmt.Sprintf("**Date:** %s \n", ts))
b.WriteString(fmt.Sprintf("**Checks:** %d passed, %d failed, %d warnings, %d skipped \n\n", passed, failed, warned, skipped))
// Issues section
if len(groups) > 0 {
b.WriteString("## Issues\n\n")
for i, g := range groups {
icon := "FAIL"
if g.Status == StatusWarn {
icon = "WARN"
}
b.WriteString(fmt.Sprintf("### %d. %s\n\n", i+1, g.Name))
nodeInfo := fmt.Sprintf("%d nodes", len(g.Nodes))
if g.Count > len(g.Nodes) {
nodeInfo = fmt.Sprintf("%d nodes (%d occurrences)", len(g.Nodes), g.Count)
}
b.WriteString(fmt.Sprintf("**Status:** %s | **Severity:** %s | **Affected:** %s \n\n", icon, g.Severity, nodeInfo))
// Affected nodes
b.WriteString("**Affected nodes:**\n")
for _, n := range g.Nodes {
b.WriteString(fmt.Sprintf("- `%s`\n", n))
}
b.WriteString("\n")
// Messages
if len(g.Messages) == 1 {
b.WriteString(fmt.Sprintf("**Detail:** %s\n\n", g.Messages[0]))
} else {
b.WriteString("**Details:**\n")
for _, m := range g.Messages {
b.WriteString(fmt.Sprintf("- %s\n", m))
}
b.WriteString("\n")
}
// AI analysis (if available)
if ai, ok := analysisMap[g.ID]; ok {
b.WriteString(ai)
b.WriteString("\n\n")
}
b.WriteString("---\n\n")
}
}
// All checks table
b.WriteString("## All Checks\n\n")
b.WriteString("| Status | Severity | Check | Node | Detail |\n")
b.WriteString("|--------|----------|-------|------|--------|\n")
// Sort: failures first
sorted := make([]CheckResult, len(checks))
copy(sorted, checks)
sort.Slice(sorted, func(i, j int) bool {
oi, oj := statusOrder(sorted[i].Status), statusOrder(sorted[j].Status)
if oi != oj {
return oi < oj
}
if sorted[i].Severity != sorted[j].Severity {
return sorted[i].Severity > sorted[j].Severity
}
return sorted[i].ID < sorted[j].ID
})
for _, c := range sorted {
node := c.Node
if node == "" {
node = "cluster-wide"
}
msg := strings.ReplaceAll(c.Message, "|", "\\|")
b.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %s |\n",
statusIcon(c.Status), c.Severity, c.Name, node, msg))
}
return os.WriteFile(filepath.Join(dir, subsystem+".md"), []byte(b.String()), 0o644)
}
func filterGroupsBySeverity(groups []FailureGroup, minSeverity Severity) []FailureGroup {
var out []FailureGroup
for _, g := range groups {
if g.Severity >= minSeverity {
out = append(out, g)
}
}
return out
}
func anchorID(name string) string {
s := strings.ToLower(name)
s = strings.ReplaceAll(s, " ", "-")
s = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
return r
}
return -1
}, s)
return s
}