diff --git a/internal/evaluator/cache.go b/internal/evaluator/cache.go index ac68e9a..8212b3e 100644 --- a/internal/evaluator/cache.go +++ b/internal/evaluator/cache.go @@ -13,7 +13,7 @@ func EvaluateCacheHit(rule rules.Rule, metrics parser.MetricsData, loadLevel rul result := rules.EvaluationResult{ RuleName: rule.DisplayName, Status: rules.StatusGreen, - Details: make(map[string]interface{}), + Details: []string{}, Timestamp: time.Now(), } @@ -80,12 +80,13 @@ func EvaluateCacheHit(rule rules.Rule, metrics parser.MetricsData, loadLevel rul "hits": hits, "misses": misses, }) - if result.Status == rules.StatusYellow { + switch result.Status { + case rules.StatusYellow: result.Message = interpolate(rule.Messages.Yellow, hitRate, map[string]interface{}{ "hits": hits, "misses": misses, }) - } else if result.Status == rules.StatusRed { + case rules.StatusRed: result.Message = interpolate(rule.Messages.Red, hitRate, map[string]interface{}{ "hits": hits, "misses": misses, diff --git a/internal/evaluator/composite.go b/internal/evaluator/composite.go index 6edce04..2d2d4c4 100644 --- a/internal/evaluator/composite.go +++ b/internal/evaluator/composite.go @@ -14,7 +14,7 @@ func EvaluateComposite(rule rules.Rule, metrics parser.MetricsData, loadLevel ru result := rules.EvaluationResult{ RuleName: rule.DisplayName, Status: rules.StatusGreen, - Details: make(map[string]interface{}), + Details: []string{}, Timestamp: time.Now(), } @@ -33,7 +33,7 @@ func EvaluateComposite(rule rules.Rule, metrics parser.MetricsData, loadLevel ru } value, _ := metric.GetSingleValue() metricValues[metricDef.Name] = value - result.Details[metricDef.Name] = value + result.Details = append(result.Details, fmt.Sprintf("%s: %.3f", metricDef.Name, value)) } // Evaluate checks in order diff --git a/internal/evaluator/evaluator_common.go b/internal/evaluator/evaluator_common.go index f9abae4..ef14a36 100644 --- a/internal/evaluator/evaluator_common.go +++ b/internal/evaluator/evaluator_common.go @@ -108,8 +108,9 @@ func EvaluateAllRules(rulesList []rules.Rule, metrics parser.MetricsData, loadLe result = EvaluateCorrelation(rule, metrics, result) } - // Add remediation + // Add potential actions (user-facing) result.Remediation = getRemediation(rule, result.Status) + result.PotentialActionUser = result.Remediation result.Timestamp = time.Now() report.Results = append(report.Results, result) @@ -125,6 +126,22 @@ func EvaluateAllRules(rulesList []rules.Rule, metrics parser.MetricsData, loadLe } } + // Apply general histogram +Inf overflow rule to all histogram metrics + infOverflowResults := EvaluateHistogramInfOverflow(metrics, loadLevel) + for _, result := range infOverflowResults { + report.Results = append(report.Results, result) + + // Update summary + switch result.Status { + case rules.StatusRed: + report.Summary.RedCount++ + case rules.StatusYellow: + report.Summary.YellowCount++ + case rules.StatusGreen: + report.Summary.GreenCount++ + } + } + report.Summary.TotalAnalyzed = len(report.Results) return report diff --git a/internal/evaluator/evaluator_test.go b/internal/evaluator/evaluator_test.go index 43554ce..5746c9b 100644 --- a/internal/evaluator/evaluator_test.go +++ b/internal/evaluator/evaluator_test.go @@ -1,6 +1,7 @@ package evaluator import ( + "strings" "testing" "github.com/stackrox/sensor-metrics-analyzer/internal/parser" @@ -403,3 +404,140 @@ func TestIsRuleApplicable(t *testing.T) { }) } } + +func TestEvaluateHistogramInfOverflow(t *testing.T) { + tests := map[string]struct { + metrics parser.MetricsData + wantStatus map[string]rules.Status // metric name -> expected status + wantCount int // expected number of results + }{ + "should return red status when >50% in +Inf": { + metrics: parser.MetricsData{ + "test_histogram_bucket": &parser.Metric{ + Name: "test_histogram_bucket", + Type: "histogram", + Values: []parser.MetricValue{ + {Value: 10, Labels: map[string]string{"le": "0.1"}}, + {Value: 20, Labels: map[string]string{"le": "0.5"}}, + {Value: 30, Labels: map[string]string{"le": "1.0"}}, + {Value: 100, Labels: map[string]string{"le": "+Inf"}}, // 70 in +Inf (70%) + }, + }, + }, + wantStatus: map[string]rules.Status{ + "test_histogram (+Inf overflow check)": rules.StatusRed, + }, + wantCount: 1, + }, + "should return yellow status when >25% but <=50% in +Inf": { + metrics: parser.MetricsData{ + "test_histogram_bucket": &parser.Metric{ + Name: "test_histogram_bucket", + Type: "histogram", + Values: []parser.MetricValue{ + {Value: 10, Labels: map[string]string{"le": "0.1"}}, + {Value: 20, Labels: map[string]string{"le": "0.5"}}, + {Value: 30, Labels: map[string]string{"le": "1.0"}}, + {Value: 50, Labels: map[string]string{"le": "+Inf"}}, // 20 in +Inf (40%) + }, + }, + }, + wantStatus: map[string]rules.Status{ + "test_histogram (+Inf overflow check)": rules.StatusYellow, + }, + wantCount: 1, + }, + "should return green status when <=25% in +Inf": { + metrics: parser.MetricsData{ + "test_histogram_bucket": &parser.Metric{ + Name: "test_histogram_bucket", + Type: "histogram", + Values: []parser.MetricValue{ + {Value: 10, Labels: map[string]string{"le": "0.1"}}, + {Value: 20, Labels: map[string]string{"le": "0.5"}}, + {Value: 30, Labels: map[string]string{"le": "1.0"}}, + {Value: 35, Labels: map[string]string{"le": "+Inf"}}, // 5 in +Inf (14.3%) + }, + }, + }, + wantStatus: map[string]rules.Status{ + "test_histogram (+Inf overflow check)": rules.StatusGreen, + }, + wantCount: 1, + }, + "should skip histogram without +Inf bucket": { + metrics: parser.MetricsData{ + "test_histogram_bucket": &parser.Metric{ + Name: "test_histogram_bucket", + Type: "histogram", + Values: []parser.MetricValue{ + {Value: 10, Labels: map[string]string{"le": "0.1"}}, + {Value: 20, Labels: map[string]string{"le": "0.5"}}, + }, + }, + }, + wantStatus: map[string]rules.Status{}, + wantCount: 0, + }, + "should handle multiple histograms": { + metrics: parser.MetricsData{ + "hist1_bucket": &parser.Metric{ + Name: "hist1_bucket", + Type: "histogram", + Values: []parser.MetricValue{ + {Value: 10, Labels: map[string]string{"le": "1.0"}}, + {Value: 100, Labels: map[string]string{"le": "+Inf"}}, // 90 in +Inf (90%) + }, + }, + "hist2_bucket": &parser.Metric{ + Name: "hist2_bucket", + Type: "histogram", + Values: []parser.MetricValue{ + {Value: 10, Labels: map[string]string{"le": "1.0"}}, + {Value: 15, Labels: map[string]string{"le": "+Inf"}}, // 5 in +Inf (33%) + }, + }, + }, + wantStatus: map[string]rules.Status{ + "hist1 (+Inf overflow check)": rules.StatusRed, + "hist2 (+Inf overflow check)": rules.StatusYellow, + }, + wantCount: 2, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + results := EvaluateHistogramInfOverflow(tt.metrics, rules.LoadLevelMedium) + + if len(results) != tt.wantCount { + t.Errorf("EvaluateHistogramInfOverflow() returned %d results, want %d", len(results), tt.wantCount) + } + + for _, result := range results { + wantStatus, exists := tt.wantStatus[result.RuleName] + if !exists { + t.Errorf("Unexpected result for metric %s", result.RuleName) + continue + } + + if result.Status != wantStatus { + t.Errorf("EvaluateHistogramInfOverflow() for %s = %v, want %v", result.RuleName, result.Status, wantStatus) + } + + // Verify message contains expected information + if result.Status != rules.StatusGreen { + if result.Message == "" { + t.Error("Message should not be empty for non-green status") + } + if !strings.Contains(result.Message, "Highest non-infinity bucket") { + t.Error("Message should contain 'Highest non-infinity bucket'") + } + if !strings.Contains(result.Message, "didn't expect") { + t.Error("Message should contain explanation about designer expectations") + } + } + } + }) + } +} diff --git a/internal/evaluator/gauge.go b/internal/evaluator/gauge.go index ddd8578..15366d0 100644 --- a/internal/evaluator/gauge.go +++ b/internal/evaluator/gauge.go @@ -15,7 +15,7 @@ func EvaluateGauge(rule rules.Rule, metrics parser.MetricsData, loadLevel rules. result := rules.EvaluationResult{ RuleName: rule.MetricName, Status: rules.StatusGreen, - Details: make(map[string]interface{}), + Details: []string{}, Timestamp: time.Now(), } @@ -98,20 +98,18 @@ func interpolate(template string, value float64, extras map[string]interface{}) }) // Replace other placeholders from extras map - if extras != nil { - for key, val := range extras { - // Handle format specifiers - keyRe := regexp.MustCompile(`\{` + regexp.QuoteMeta(key) + `(?::[^}]+)?\}`) - result = keyRe.ReplaceAllStringFunc(result, func(match string) string { - if strings.Contains(match, ":") { - formatMatch := regexp.MustCompile(`:([^}]+)`) - if fm := formatMatch.FindStringSubmatch(match); len(fm) == 2 { - return fmt.Sprintf("%"+fm[1], val) - } + for key, val := range extras { + // Handle format specifiers + keyRe := regexp.MustCompile(`\{` + regexp.QuoteMeta(key) + `(?::[^}]+)?\}`) + result = keyRe.ReplaceAllStringFunc(result, func(match string) string { + if strings.Contains(match, ":") { + formatMatch := regexp.MustCompile(`:([^}]+)`) + if fm := formatMatch.FindStringSubmatch(match); len(fm) == 2 { + return fmt.Sprintf("%"+fm[1], val) } - return fmt.Sprintf("%v", val) - }) - } + } + return fmt.Sprintf("%v", val) + }) } return result diff --git a/internal/evaluator/histogram.go b/internal/evaluator/histogram.go index 0011a3c..fed7ace 100644 --- a/internal/evaluator/histogram.go +++ b/internal/evaluator/histogram.go @@ -3,6 +3,8 @@ package evaluator import ( "fmt" "sort" + "strconv" + "strings" "time" "github.com/stackrox/sensor-metrics-analyzer/internal/parser" @@ -14,7 +16,7 @@ func EvaluateHistogram(rule rules.Rule, metrics parser.MetricsData, loadLevel ru result := rules.EvaluationResult{ RuleName: rule.MetricName, Status: rules.StatusGreen, - Details: make(map[string]interface{}), + Details: []string{}, Timestamp: time.Now(), } @@ -63,9 +65,11 @@ func EvaluateHistogram(rule rules.Rule, metrics parser.MetricsData, loadLevel ru } result.Value = p95 - result.Details["p95"] = p95 - result.Details["p99"] = p99 - result.Details["count"] = totalCount + result.Details = append(result.Details, + fmt.Sprintf("p95: %.3f", p95), + fmt.Sprintf("p99: %.3f", p99), + fmt.Sprintf("count: %.0f", totalCount), + ) // Select thresholds based on load level thresholds := selectThresholds(rule, loadLevel) @@ -93,3 +97,186 @@ func EvaluateHistogram(rule rules.Rule, metrics parser.MetricsData, loadLevel ru return result } + +// EvaluateHistogramInfOverflow evaluates all histogram metrics for +Inf bucket overflow +// This is a general rule that applies to any histogram metric +func EvaluateHistogramInfOverflow(metrics parser.MetricsData, loadLevel rules.LoadLevel) []rules.EvaluationResult { + var results []rules.EvaluationResult + + // Get all histogram base names + histogramBases := metrics.GetHistogramBaseNames() + + for _, baseName := range histogramBases { + result := evaluateSingleHistogramInfOverflow(baseName, metrics) + if result != nil { + results = append(results, *result) + } + } + + return results +} + +// evaluateSingleHistogramInfOverflow evaluates a single histogram metric for +Inf overflow +// Handles multiple label combinations (series) by evaluating each separately and reporting the worst case +func evaluateSingleHistogramInfOverflow(baseName string, metrics parser.MetricsData) *rules.EvaluationResult { + result := &rules.EvaluationResult{ + RuleName: baseName + " (+Inf overflow check)", + Status: rules.StatusGreen, + Details: []string{}, + Timestamp: time.Now(), + } + + // Get histogram buckets + bucketMetricName := baseName + "_bucket" + bucketMetric, exists := metrics.GetMetric(bucketMetricName) + if !exists || len(bucketMetric.Values) == 0 { + return nil // Skip if no buckets found + } + + // Group buckets by label combination (excluding "le" label) + // Each label combination represents a separate time series + seriesBuckets := make(map[string][]parser.MetricValue) + for _, v := range bucketMetric.Values { + // Create a key from all labels except "le" + seriesKey := getSeriesKey(v.Labels) + seriesBuckets[seriesKey] = append(seriesBuckets[seriesKey], v) + } + + // Track worst case across all series + var worstInfPercentage float64 + var worstInfObservations float64 + var worstTotalCount float64 + var worstHighestFiniteLe float64 + var worstStatus rules.Status + + hasAnyData := false + + // Evaluate each series separately + for _, buckets := range seriesBuckets { + // Find +Inf bucket and highest finite bucket for this series + var infCount float64 + var highestFiniteLe float64 + var highestFiniteCount float64 + hasInf := false + hasFinite := false + + for _, v := range buckets { + if leStr, exists := v.Labels["le"]; exists { + if leStr == "+Inf" { + infCount = v.Value + hasInf = true + } else if le, err := strconv.ParseFloat(leStr, 64); err == nil { + if !hasFinite || le > highestFiniteLe { + highestFiniteLe = le + highestFiniteCount = v.Value + hasFinite = true + } + } + } + } + + if !hasInf || !hasFinite || infCount == 0 { + continue + } + + hasAnyData = true + + // Calculate the percentage of observations in +Inf bucket for this series + // Observations in +Inf = totalCount - highestFiniteCount + infObservations := infCount - highestFiniteCount + if infObservations < 0 { + // This can happen if there are data inconsistencies, skip this series + continue + } + infPercentage := (infObservations / infCount) * 100.0 + + // Track worst case + if infPercentage > worstInfPercentage { + worstInfPercentage = infPercentage + worstInfObservations = infObservations + worstTotalCount = infCount + worstHighestFiniteLe = highestFiniteLe + + // Determine status for this series + if infPercentage > 50.0 { + worstStatus = rules.StatusRed + } else if infPercentage > 25.0 { + worstStatus = rules.StatusYellow + } else { + worstStatus = rules.StatusGreen + } + } + } + + if !hasAnyData { + return nil + } + + result.Status = worstStatus + if baseMetric, ok := metrics.GetMetric(baseName); ok && baseMetric.Help != "" { + result.Details = append(result.Details, "Metric Description: "+baseMetric.Help) + } else if bucketMetric, ok := metrics.GetMetric(baseName + "_bucket"); ok && bucketMetric.Help != "" { + result.Details = append(result.Details, "Metric Description: "+bucketMetric.Help) + } + result.Details = append(result.Details, + "Total Number of Observations: "+formatHumanNumber(worstTotalCount)+" unit", + "Observations in +Inf bucket: "+formatHumanNumber(worstInfObservations)+" unit", + "Percentage of observations in +Inf bucket: "+formatHumanNumber(worstInfPercentage)+" %", + "Highest non-infinity bucket: "+formatHumanNumber(worstHighestFiniteLe)+" unit", + ) + + // Build message based on worst case + result.Message = fmt.Sprintf("%s%% of observations in +Inf bucket (acceptable). Highest non-infinity bucket: %s", + formatHumanNumber(worstInfPercentage), formatHumanNumber(worstHighestFiniteLe)) + if worstInfPercentage > 25.0 { + result.Message = fmt.Sprintf("%s%% of observations are in +Inf bucket (%s out of %s). "+ + "This indicates the metric designer likely didn't expect processing durations to be so high. "+ + "Highest non-infinity bucket: %s", + formatHumanNumber(worstInfPercentage), + formatHumanNumber(worstInfObservations), + formatHumanNumber(worstTotalCount), + formatHumanNumber(worstHighestFiniteLe)) + result.PotentialActionUser = fmt.Sprintf("Further investigation is required to understand why values exceed %s. "+ + "Check if there are other alerts for this specific metric with more precise context.", formatHumanNumber(worstHighestFiniteLe)) + result.PotentialActionDeveloper = "Review code paths and metric instrumentation to confirm whether observed latencies are expected." + } + return result +} + +// getSeriesKey creates a key from labels excluding "le" to group buckets by series +func getSeriesKey(labels map[string]string) string { + var keys []string + for k, v := range labels { + if k != "le" { + keys = append(keys, k+"="+v) + } + } + sort.Strings(keys) + return strings.Join(keys, ",") +} + +func formatHumanNumber(value float64) string { + raw := strconv.FormatFloat(value, 'f', 2, 64) + sign := "" + if strings.HasPrefix(raw, "-") { + sign = "-" + raw = strings.TrimPrefix(raw, "-") + } + parts := strings.SplitN(raw, ".", 2) + intPart := parts[0] + var fracPart string + if len(parts) == 2 && parts[1] != "" { + fracPart = parts[1] + } + var grouped strings.Builder + for i, r := range intPart { + if i > 0 && (len(intPart)-i)%3 == 0 { + grouped.WriteString(" ") + } + grouped.WriteRune(r) + } + if fracPart != "" { + return sign + grouped.String() + "." + fracPart + } + return sign + grouped.String() +} diff --git a/internal/evaluator/percentage.go b/internal/evaluator/percentage.go index 54b15f2..873358b 100644 --- a/internal/evaluator/percentage.go +++ b/internal/evaluator/percentage.go @@ -13,7 +13,7 @@ func EvaluatePercentage(rule rules.Rule, metrics parser.MetricsData, loadLevel r result := rules.EvaluationResult{ RuleName: rule.DisplayName, Status: rules.StatusGreen, - Details: make(map[string]interface{}), + Details: []string{}, Timestamp: time.Now(), } diff --git a/internal/evaluator/queue.go b/internal/evaluator/queue.go index ab905aa..c887d23 100644 --- a/internal/evaluator/queue.go +++ b/internal/evaluator/queue.go @@ -13,7 +13,7 @@ func EvaluateQueue(rule rules.Rule, metrics parser.MetricsData, loadLevel rules. result := rules.EvaluationResult{ RuleName: rule.MetricName, Status: rules.StatusGreen, - Details: make(map[string]interface{}), + Details: []string{}, Timestamp: time.Now(), } diff --git a/internal/parser/prometheus.go b/internal/parser/prometheus.go index 75b75a3..2d03043 100644 --- a/internal/parser/prometheus.go +++ b/internal/parser/prometheus.go @@ -4,6 +4,7 @@ import ( "bufio" "os" "regexp" + "sort" "strconv" "strings" ) @@ -195,6 +196,32 @@ func (m *Metric) GetHistogramBuckets() []HistogramBucket { return buckets } +// GetHistogramInfBucketCount returns the count from the +Inf bucket +func (m *Metric) GetHistogramInfBucketCount() (float64, bool) { + for _, v := range m.Values { + if leStr, exists := v.Labels["le"]; exists && leStr == "+Inf" { + return v.Value, true + } + } + return 0, false +} + +// GetHistogramHighestFiniteBucket returns the highest finite bucket's le value and count +func (m *Metric) GetHistogramHighestFiniteBucket() (le float64, count float64, found bool) { + buckets := m.GetHistogramBuckets() + if len(buckets) == 0 { + return 0, 0, false + } + + // Sort buckets by le value to find the highest + sort.Slice(buckets, func(i, j int) bool { + return buckets[i].Le < buckets[j].Le + }) + + highest := buckets[len(buckets)-1] + return highest.Le, highest.Count, true +} + // GetHistogramSum returns the _sum value for a histogram metric func (md MetricsData) GetHistogramSum(baseName string) (float64, bool) { sumMetric := baseName + "_sum" @@ -242,3 +269,32 @@ func (md MetricsData) DetectACSVersion() (string, bool) { return "", false } + +// GetHistogramBaseNames returns a list of base histogram metric names (without _bucket, _sum, _count suffixes) +func (md MetricsData) GetHistogramBaseNames() []string { + histogramBases := make(map[string]bool) + + for metricName, metric := range md { + // Check if this is a histogram type metric + if metric.Type == "histogram" { + // Extract base name by removing _bucket, _sum, _count suffixes + baseName := metricName + if strings.HasSuffix(baseName, "_bucket") { + baseName = strings.TrimSuffix(baseName, "_bucket") + } else if strings.HasSuffix(baseName, "_sum") { + baseName = strings.TrimSuffix(baseName, "_sum") + } else if strings.HasSuffix(baseName, "_count") { + baseName = strings.TrimSuffix(baseName, "_count") + } + histogramBases[baseName] = true + } + } + + // Convert map to slice and sort for consistent output + result := make([]string, 0, len(histogramBases)) + for baseName := range histogramBases { + result = append(result, baseName) + } + sort.Strings(result) + return result +} diff --git a/internal/reporter/console.go b/internal/reporter/console.go index 3b5bb1d..5f05075 100644 --- a/internal/reporter/console.go +++ b/internal/reporter/console.go @@ -65,8 +65,21 @@ func GenerateConsole(report rules.AnalysisReport) string { result.WriteString(color.New(color.Bold).Sprintf("%s\n", r.RuleName)) result.WriteString(color.RedString(" Status: RED\n")) result.WriteString(fmt.Sprintf(" Message: %s\n", r.Message)) - if r.Remediation != "" { - result.WriteString(color.New(color.FgYellow).Sprintf(" Recommended Action: %s\n", r.Remediation)) + if len(r.Details) > 0 { + result.WriteString(color.New(color.FgYellow).Sprint(" Details:\n")) + for _, detail := range r.Details { + result.WriteString(fmt.Sprintf(" %s\n", detail)) + } + } + if r.PotentialActionUser != "" { + result.WriteString(fmt.Sprintf(" %s %s\n", + color.New(color.FgYellow).Sprint("Potential action:"), + r.PotentialActionUser)) + } + if r.PotentialActionDeveloper != "" { + result.WriteString(fmt.Sprintf(" %s %s\n", + color.New(color.FgYellow).Sprint("Potential action (developer):"), + r.PotentialActionDeveloper)) } result.WriteString("\n") } @@ -80,8 +93,21 @@ func GenerateConsole(report rules.AnalysisReport) string { result.WriteString(color.New(color.Bold).Sprintf("%s\n", r.RuleName)) result.WriteString(color.YellowString(" Status: YELLOW\n")) result.WriteString(fmt.Sprintf(" Message: %s\n", r.Message)) - if r.Remediation != "" { - result.WriteString(color.New(color.FgYellow).Sprintf(" Recommended Action: %s\n", r.Remediation)) + if len(r.Details) > 0 { + result.WriteString(color.New(color.FgYellow).Sprint(" Details:\n")) + for _, detail := range r.Details { + result.WriteString(fmt.Sprintf(" %s\n", detail)) + } + } + if r.PotentialActionUser != "" { + result.WriteString(fmt.Sprintf(" %s %s\n", + color.New(color.FgYellow).Sprint("Potential action:"), + r.PotentialActionUser)) + } + if r.PotentialActionDeveloper != "" { + result.WriteString(fmt.Sprintf(" %s %s\n", + color.New(color.FgYellow).Sprint("Potential action (developer):"), + r.PotentialActionDeveloper)) } result.WriteString("\n") } diff --git a/internal/reporter/markdown.go b/internal/reporter/markdown.go index 0e743af..0c1e7dd 100644 --- a/internal/reporter/markdown.go +++ b/internal/reporter/markdown.go @@ -47,8 +47,17 @@ func generateMarkdownDefault(report rules.AnalysisReport) string { result += "### " + r.RuleName + "\n\n" result += "**Status:** RED\n" result += "**Message:** " + r.Message + "\n" - if r.Remediation != "" { - result += "**Recommended Action:** " + r.Remediation + "\n" + if len(r.Details) > 0 { + result += "**Details:**\n" + for _, detail := range r.Details { + result += "- " + detail + "\n" + } + } + if r.PotentialActionUser != "" { + result += "**Potential action:** " + r.PotentialActionUser + "\n" + } + if r.PotentialActionDeveloper != "" { + result += "**Potential action (developer):** " + r.PotentialActionDeveloper + "\n" } result += "\n" } @@ -62,8 +71,17 @@ func generateMarkdownDefault(report rules.AnalysisReport) string { result += "### " + r.RuleName + "\n\n" result += "**Status:** YELLOW\n" result += "**Message:** " + r.Message + "\n" - if r.Remediation != "" { - result += "**Recommended Action:** " + r.Remediation + "\n" + if len(r.Details) > 0 { + result += "**Details:**\n" + for _, detail := range r.Details { + result += "- " + detail + "\n" + } + } + if r.PotentialActionUser != "" { + result += "**Potential action:** " + r.PotentialActionUser + "\n" + } + if r.PotentialActionDeveloper != "" { + result += "**Potential action (developer):** " + r.PotentialActionDeveloper + "\n" } result += "\n" } diff --git a/internal/reporter/template.go b/internal/reporter/template.go index a7bb5f8..ae054ee 100644 --- a/internal/reporter/template.go +++ b/internal/reporter/template.go @@ -73,5 +73,17 @@ func GenerateMarkdownFromTemplate(report rules.AnalysisReport, templatePath stri return "", err } - return ExecuteTemplate(tmpl, report) + data := struct { + rules.AnalysisReport + RedResults []rules.EvaluationResult + YellowResults []rules.EvaluationResult + GreenResults []rules.EvaluationResult + }{ + AnalysisReport: report, + RedResults: filterByStatus(report.Results, rules.StatusRed), + YellowResults: filterByStatus(report.Results, rules.StatusYellow), + GreenResults: filterByStatus(report.Results, rules.StatusGreen), + } + + return ExecuteTemplate(tmpl, data) } diff --git a/internal/rules/types.go b/internal/rules/types.go index a0a4e3a..7eb8482 100644 --- a/internal/rules/types.go +++ b/internal/rules/types.go @@ -189,13 +189,15 @@ type LoadDetectionThreshold struct { // EvaluationResult represents the result of evaluating a rule type EvaluationResult struct { - RuleName string - Status Status - Message string - Value float64 - Details map[string]interface{} - Remediation string // Suggested remediation action (if available) - Timestamp time.Time + RuleName string + Status Status + Message string + Value float64 + Details []string + Remediation string // Legacy field (use PotentialActionUser/Developer) + PotentialActionUser string + PotentialActionDeveloper string + Timestamp time.Time } // AnalysisReport contains all evaluation results diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 78f4cfe..c364a28 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -122,8 +122,7 @@ var ( remediationStyle = lipgloss.NewStyle(). Foreground(colorYellow). - Italic(true). - MarginTop(1) + Italic(true) // Help bar helpStyle = lipgloss.NewStyle(). diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 4e708f0..5617823 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -25,8 +25,7 @@ func Run(report rules.AnalysisReport) error { p := tea.NewProgram( model, - tea.WithAltScreen(), // Use alternate screen buffer - tea.WithMouseCellMotion(), // Enable mouse support + // Avoid alternate screen and mouse capture so text is copyable ) if _, err := p.Run(); err != nil { @@ -43,8 +42,7 @@ func RunWithOutput(report rules.AnalysisReport) (*Model, error) { p := tea.NewProgram( model, - tea.WithAltScreen(), - tea.WithMouseCellMotion(), + // Avoid alternate screen and mouse capture so text is copyable ) finalModel, err := p.Run() diff --git a/internal/tui/view.go b/internal/tui/view.go index a3385e8..d95eeda 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -210,10 +210,21 @@ func (m Model) viewDetail() string { } detail.WriteString("\n\n") - // Message - detail.WriteString(detailLabelStyle.Render("Message:\n")) - detail.WriteString(detailValueStyle.Render(" " + result.Message)) - detail.WriteString("\n\n") + // Message (wrapped to screen width) + detail.WriteString(detailLabelStyle.Render("Message:")) + detail.WriteString("\n") + messageWidth := 70 + if m.width > 0 { + messageWidth = m.width - 10 + if messageWidth < 40 { + messageWidth = 40 + } + } + wrappedMessage := wordWrap(result.Message, messageWidth) + for _, line := range strings.Split(wrappedMessage, "\n") { + detail.WriteString(fmt.Sprintf(" %s\n", line)) + } + detail.WriteString("\n") // Value if result.Value != 0 { @@ -222,22 +233,34 @@ func (m Model) viewDetail() string { detail.WriteString("\n\n") } - // Details map + // Details map - display all details in plain text format for easy copying if len(result.Details) > 0 { - detail.WriteString(detailLabelStyle.Render("Details:\n")) - for k, v := range result.Details { - detail.WriteString(fmt.Sprintf(" %s: %v\n", k, v)) + detail.WriteString(detailLabelStyle.Render("Details:")) + detail.WriteString("\n") + + // Display details in plain text format (no ANSI codes) for easy selection/copying + for _, detailLine := range result.Details { + detail.WriteString(fmt.Sprintf(" %s\n", detailLine)) } detail.WriteString("\n") } - // Remediation (if available and status is RED or YELLOW) - if result.Remediation != "" && (result.Status == "RED" || result.Status == "YELLOW") { - detail.WriteString(detailLabelStyle.Render("💡 Remediation:\n")) - // Word wrap the remediation text - wrapped := wordWrap(result.Remediation, 60) + // Potential actions (user/developer) + if result.PotentialActionUser != "" && (result.Status == "RED" || result.Status == "YELLOW") { + detail.WriteString(detailLabelStyle.Render("Potential action:")) + detail.WriteString("\n") + wrapped := wordWrap(result.PotentialActionUser, 60) + for _, line := range strings.Split(wrapped, "\n") { + detail.WriteString(remediationStyle.Render(fmt.Sprintf(" %s", line))) + detail.WriteString("\n") + } + } + if result.PotentialActionDeveloper != "" && (result.Status == "RED" || result.Status == "YELLOW") { + detail.WriteString(detailLabelStyle.Render("Potential action (developer):")) + detail.WriteString("\n") + wrapped := wordWrap(result.PotentialActionDeveloper, 60) for _, line := range strings.Split(wrapped, "\n") { - detail.WriteString(remediationStyle.Render(" " + line)) + detail.WriteString(remediationStyle.Render(fmt.Sprintf(" %s", line))) detail.WriteString("\n") } } diff --git a/templates/markdown.tmpl b/templates/markdown.tmpl index 13192bb..a909ae2 100644 --- a/templates/markdown.tmpl +++ b/templates/markdown.tmpl @@ -11,47 +11,44 @@ - 🟡 **YELLOW:** {{.Summary.YellowCount}} metrics - 🟢 **GREEN:** {{.Summary.GreenCount}} metrics -{{if gt .Summary.RedCount 0}} +{{ if gt (len .RedResults) 0 }} + ## 🔴 Critical Issues -{{range .Results}} -{{if eq .Status "RED"}} +{{ range .RedResults }} ### {{.RuleName}} - **Status:** RED **Message:** {{.Message}} -{{if .Remediation}} -**Recommended Action:** {{.Remediation}} -{{end}} +{{ if .PotentialActionUser }} +**Potential action:** {{.PotentialActionUser}} +{{ end }} +{{ if .PotentialActionDeveloper }} +**Potential action (developer):** {{.PotentialActionDeveloper}} +{{ end }} + +{{ end }} -{{end}} -{{end}} -{{end}} +{{ if gt (len .YellowResults) 0 }} -{{if gt .Summary.YellowCount 0}} ## 🟡 Warnings -{{range .Results}} -{{if eq .Status "YELLOW"}} +{{ range .YellowResults }} ### {{.RuleName}} - **Status:** YELLOW **Message:** {{.Message}} -{{if .Remediation}} -**Recommended Action:** {{.Remediation}} -{{end}} +{{ if .PotentialActionUser }} +**Potential action:** {{.PotentialActionUser}} +{{ end }} +{{ if .PotentialActionDeveloper }} +**Potential action (developer):** {{.PotentialActionDeveloper}} +{{ end }} -{{end}} -{{end}} -{{end}} +{{ end }} -{{if gt .Summary.GreenCount 0}} -## 🟢 Healthy Metrics +{{ if gt (len .GreenResults) 0 }} -{{range .Results}} -{{if eq .Status "GREEN"}} +## 🟢 Healthy Metrics +{{ range .GreenResults }} - **{{.RuleName}}:** {{.Message}} -{{end}} -{{end}} -{{end}} +{{ end }}