mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-03-17 20:46:58 +00:00
355 lines
9.5 KiB
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
|
|
}
|