Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions client/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ type FleetScore struct {
Score int `json:"score"`
// Grade is the letter grade A through F.
Grade string `json:"grade"`
// Graded is true when the scan observed enough of the fleet to grade it.
Graded bool `json:"graded"`
// Coverage counts the scanner runs behind the score.
Coverage FleetCoverage `json:"coverage"`
// Headline summarizes the fleet state.
Headline string `json:"headline"`
// Drivers lists the largest contributions to the score.
Expand All @@ -56,6 +60,20 @@ type FleetScore struct {
ExcludedClusters int `json:"excluded_clusters"`
}

// FleetCoverage counts the scanner runs behind a fleet score.
type FleetCoverage struct {
// ExpectedRuns is the listed clusters times the scanners that ran.
ExpectedRuns int `json:"expected_runs"`
// ObservedRuns is how many expected runs observed their cluster.
ObservedRuns int `json:"observed_runs"`
// BlindRuns is how many expected runs errored or never ran.
BlindRuns int `json:"blind_runs"`
// BlindClusters is how many clusters have no observed run.
BlindClusters int `json:"blind_clusters"`
// Fraction is ObservedRuns over ExpectedRuns.
Fraction float64 `json:"fraction"`
}

// FleetScoreForecast projects a future fleet score from history.
type FleetScoreForecast struct {
// Predicted is the projected score.
Expand Down Expand Up @@ -179,6 +197,8 @@ type ClusterHealth struct {
// DegradedScanners is how many scanner runs did not return complete data for
// this cluster. Nonzero means the cluster's report rests on partial coverage.
DegradedScanners int `json:"degraded_scanners,omitempty"`
// BlindScanners is how many expected scanner runs errored or never ran on this cluster.
BlindScanners int `json:"blind_scanners,omitempty"`
// FindingCounts maps severity to the number of findings.
FindingCounts map[string]int `json:"finding_counts,omitempty"`
}
Expand Down Expand Up @@ -240,18 +260,18 @@ type CapacityAnalysis struct {
Cluster string `json:"cluster,omitempty"`
// Status summarizes capacity pressure.
Status string `json:"status,omitempty"`
// CPUUtilization is the current CPU utilization.
CPUUtilization float64 `json:"cpu_utilization,omitempty"`
// MemoryUtilization is the current memory utilization.
MemoryUtilization float64 `json:"memory_utilization,omitempty"`
// CPUUtilization is the current CPU utilization, nil when no scanner observed it.
CPUUtilization *float64 `json:"cpu_utilization,omitempty"`
// MemoryUtilization is the current memory utilization, nil when no scanner observed it.
MemoryUtilization *float64 `json:"memory_utilization,omitempty"`
// NodeCount is the number of nodes.
NodeCount int `json:"node_count,omitempty"`
// HealthyNodes is the number of ready nodes.
HealthyNodes int `json:"healthy_nodes,omitempty"`
// HeadroomCPU is the remaining CPU headroom.
HeadroomCPU float64 `json:"headroom_cpu,omitempty"`
// HeadroomMemory is the remaining memory headroom.
HeadroomMemory float64 `json:"headroom_memory,omitempty"`
// HeadroomCPU is the remaining CPU headroom, nil when utilization was not observed.
HeadroomCPU *float64 `json:"headroom_cpu,omitempty"`
// HeadroomMemory is the remaining memory headroom, nil when utilization was not observed.
HeadroomMemory *float64 `json:"headroom_memory,omitempty"`
// HasMemoryPressure reports memory pressure on the cluster.
HasMemoryPressure bool `json:"has_memory_pressure,omitempty"`
// HasDiskPressure reports disk pressure on the cluster.
Expand Down
20 changes: 17 additions & 3 deletions cmd/cmd_textfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,23 @@ func runExportMetrics(cmd *cobra.Command, args []string) error {
// Metric names follow the fleetsweeper_<noun>_<unit> convention so they
// blend with the rest of the metrics surface exposed at /metrics.
func writeProm(w io.Writer, rpt *report.Report, scanID string, now time.Time) error {
fmt.Fprintln(w, "# HELP fleetsweeper_fleet_score Overall fleet health score in [0, 100].")
fmt.Fprintln(w, "# TYPE fleetsweeper_fleet_score gauge")
fmt.Fprintf(w, "fleetsweeper_fleet_score{scan_id=%q} %d\n", scanID, rpt.FleetScore.Score)
if rpt.FleetScore.Graded {
fmt.Fprintln(w, "# HELP fleetsweeper_fleet_score Overall fleet health score in [0, 100].")
fmt.Fprintln(w, "# TYPE fleetsweeper_fleet_score gauge")
fmt.Fprintf(w, "fleetsweeper_fleet_score{scan_id=%q} %d\n", scanID, rpt.FleetScore.Score)
}

graded := 0
if rpt.FleetScore.Graded {
graded = 1
}
fmt.Fprintln(w, "# HELP fleetsweeper_fleet_score_graded 1 when the scan observed enough of the fleet to grade it.")
fmt.Fprintln(w, "# TYPE fleetsweeper_fleet_score_graded gauge")
fmt.Fprintf(w, "fleetsweeper_fleet_score_graded{scan_id=%q} %d\n", scanID, graded)

fmt.Fprintln(w, "# HELP fleetsweeper_coverage_ratio Share of expected scanner runs that observed the fleet.")
fmt.Fprintln(w, "# TYPE fleetsweeper_coverage_ratio gauge")
fmt.Fprintf(w, "fleetsweeper_coverage_ratio{scan_id=%q} %g\n", scanID, rpt.FleetScore.Coverage.Fraction)

fmt.Fprintln(w, "# HELP fleetsweeper_fleet_score_sample_clusters Clusters that contributed scanner data to the Fleet Score.")
fmt.Fprintln(w, "# TYPE fleetsweeper_fleet_score_sample_clusters gauge")
Expand Down
39 changes: 38 additions & 1 deletion cmd/cmd_textfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"bytes"
"fmt"
"strings"
"testing"
"time"
Expand All @@ -22,7 +23,7 @@ func TestWriteProm_AllSectionsRendered(t *testing.T) {
WarningCount: 5,
},
FleetScore: report.FleetScore{
Score: 82, SampleSize: 2, Dimensions: 7, ExcludedClusters: 1,
Score: 82, Graded: true, SampleSize: 2, Dimensions: 7, ExcludedClusters: 1,
},
ClusterHealths: []report.ClusterHealth{
{Name: "a", Status: "healthy"},
Expand Down Expand Up @@ -93,3 +94,39 @@ func TestEscapeLabel(t *testing.T) {
}
}
}

// TestWriteProm_Graded verifies the score series follows the Graded flag and the graded and
// coverage series are always written.
func TestWriteProm_Graded(t *testing.T) {
t.Parallel()
tests := []struct {
Graded bool
WantScore bool
WantGraded string
}{{ // Test 0: Graded emits the score.
Graded: true, WantScore: true, WantGraded: `fleetsweeper_fleet_score_graded{scan_id="s"} 1`,
}, { // Test 1: Ungraded skips the score.
WantGraded: `fleetsweeper_fleet_score_graded{scan_id="s"} 0`,
}}
for testNum, test := range tests {
t.Run(fmt.Sprintf("test %d", testNum), func(t *testing.T) {
t.Parallel()
rpt := &report.Report{FleetScore: report.FleetScore{
Score: 82, Graded: test.Graded, Coverage: report.FleetCoverage{Fraction: 0.5},
}}
buf := &bytes.Buffer{}
if err := writeProm(buf, rpt, "s", time.Unix(0, 0).UTC()); err != nil {
t.Fatalf("writeProm: %v", err)
}
out := buf.String()
if got := strings.Contains(out, "fleetsweeper_fleet_score{"); got != test.WantScore {
t.Errorf("fleet_score present = %t, want %t", got, test.WantScore)
}
for _, want := range []string{test.WantGraded, `fleetsweeper_coverage_ratio{scan_id="s"} 0.5`} {
if !strings.Contains(out, want) {
t.Errorf("missing %q in:\n%s", want, out)
}
}
})
}
}
25 changes: 18 additions & 7 deletions cmd/cmd_top.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ func (s *topState) lessFn(rows []topRow) func(i, j int) bool {
case sortByName:
return func(i, j int) bool { return rows[i].Cluster < rows[j].Cluster }
case sortByStatus:
order := map[string]int{"critical": 0, "degraded": 1, "busy": 2, "healthy": 3}
order := map[string]int{"critical": 0, "degraded": 1, "busy": 2, "unknown": 3, "healthy": 4}
return func(i, j int) bool {
if order[rows[i].Status] != order[rows[j].Status] {
return order[rows[i].Status] < order[rows[j].Status]
Expand All @@ -295,8 +295,9 @@ type topReport struct {

// topFleetScore mirrors the wire shape.
type topFleetScore struct {
Score int `json:"score"`
Grade string `json:"grade"`
Score int `json:"score"`
Grade string `json:"grade"`
Graded bool `json:"graded"`
}

// topClusterHealth mirrors the wire shape.
Expand Down Expand Up @@ -352,9 +353,11 @@ func (s *topState) fetchReport(ctx context.Context, id string) (*topReport, erro
return nil, err
}
out := &topReport{
Timestamp: rpt.Timestamp,
Clusters: rpt.Clusters,
FleetScore: topFleetScore{Score: rpt.FleetScore.Score, Grade: rpt.FleetScore.Grade},
Timestamp: rpt.Timestamp,
Clusters: rpt.Clusters,
FleetScore: topFleetScore{
Score: rpt.FleetScore.Score, Grade: rpt.FleetScore.Grade, Graded: rpt.FleetScore.Graded,
},
}
for _, h := range rpt.ClusterHealths {
out.ClusterHealths = append(out.ClusterHealths, topClusterHealth{
Expand Down Expand Up @@ -405,14 +408,20 @@ func (s *topState) printHeader(rpt, prev *topReport) {
}
fmt.Fprintf(s.out, "fleetsweeper top - %s%s\r\n", s.server, pausedNote)
delta := ""
if prev != nil && prev.FleetScore.Score != 0 {
if prev != nil && prev.FleetScore.Graded && rpt.FleetScore.Graded && prev.FleetScore.Score != 0 {
d := rpt.FleetScore.Score - prev.FleetScore.Score
sign := ""
if d > 0 {
sign = "+"
}
delta = fmt.Sprintf(" (%s%d vs previous)", sign, d)
}
if !rpt.FleetScore.Graded {
fmt.Fprintf(s.out, "Fleet Score: not graded %d clusters sorted by %s\r\n",
len(rpt.Clusters), s.sortLabel())
fmt.Fprintln(s.out)
return
}
fmt.Fprintf(s.out, "Fleet Score: %s%d (%s)%s%s %d clusters sorted by %s\r\n",
s.color3(s.fleetColor(rpt.FleetScore.Score)),
rpt.FleetScore.Score, rpt.FleetScore.Grade, s.resetCode(),
Expand Down Expand Up @@ -507,6 +516,8 @@ func statusColor(status string) string {
return "33"
case "busy":
return "34"
case "unknown":
return "90"
default:
return "32"
}
Expand Down
44 changes: 37 additions & 7 deletions cmd/cmd_top_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func TestColorCodeHelpers(t *testing.T) {
// Test 0: statusColor mapping.
statusCases := map[string]string{
"critical": "31", "degraded": "33", "strained": "33",
"busy": "34", "healthy": "32", "unknown": "32",
"busy": "34", "healthy": "32", "unknown": "90",
}
for status, want := range statusCases {
if got := statusColor(status); got != want {
Expand Down Expand Up @@ -214,15 +214,19 @@ func TestLessFn(t *testing.T) {
{Cluster: "beta", Status: "healthy", Score: 90},
{Cluster: "alpha", Status: "critical", Score: 40},
{Cluster: "gamma", Status: "busy", Score: 70},
{Cluster: "delta", Status: "unknown", Score: 95},
}

tests := []struct {
WantOrder []string
Mode topSortMode
}{
{WantOrder: []string{"alpha", "gamma", "beta"}, Mode: sortByScore}, // Test 0: Worst score first.
{WantOrder: []string{"alpha", "beta", "gamma"}, Mode: sortByName}, // Test 1: Alphabetical.
{WantOrder: []string{"alpha", "gamma", "beta"}, Mode: sortByStatus}, // Test 2: critical<busy<healthy.
// Test 0: Worst score first.
{WantOrder: []string{"alpha", "gamma", "beta", "delta"}, Mode: sortByScore},
// Test 1: Alphabetical.
{WantOrder: []string{"alpha", "beta", "delta", "gamma"}, Mode: sortByName},
// Test 2: critical<busy<unknown<healthy.
{WantOrder: []string{"alpha", "gamma", "delta", "beta"}, Mode: sortByStatus},
}
for testNum, test := range tests {
t.Run(test.WantOrder[0], func(t *testing.T) {
Expand Down Expand Up @@ -272,7 +276,7 @@ func topTestReport(fleet int, grade, bStatus string, bFindings ...report.Finding
return report.Report{
Timestamp: "2026-01-01T00:00:00Z",
Clusters: []string{"a", "b"},
FleetScore: report.FleetScore{Score: fleet, Grade: grade},
FleetScore: report.FleetScore{Score: fleet, Grade: grade, Graded: true},
ClusterHealths: []report.ClusterHealth{
{Name: "a", Status: "healthy", AvgCPU: 12, AvgMemory: 30},
{Name: "b", Status: bStatus, AvgCPU: 88, AvgMemory: 91},
Expand Down Expand Up @@ -446,8 +450,8 @@ func TestPrintHeaderShowsDelta(t *testing.T) {
t.Parallel()
buf := &bytes.Buffer{}
s := &topState{out: buf, color: false, server: "http://x"}
cur := &topReport{Clusters: []string{"a"}, FleetScore: topFleetScore{Score: 80, Grade: "B"}}
prev := &topReport{FleetScore: topFleetScore{Score: 70}}
cur := &topReport{Clusters: []string{"a"}, FleetScore: topFleetScore{Score: 80, Grade: "B", Graded: true}}
prev := &topReport{FleetScore: topFleetScore{Score: 70, Graded: true}}
s.printHeader(cur, prev)
out := buf.String()
if !strings.Contains(out, "(+10 vs previous)") {
Expand Down Expand Up @@ -479,3 +483,29 @@ func TestTopPausedNote(t *testing.T) {
t.Errorf("expected paused marker, got:\n%s", buf.String())
}
}

// TestPrintHeader_Graded verifies the header prints the grade only for a graded fleet.
func TestPrintHeader_Graded(t *testing.T) {
t.Parallel()
tests := []struct {
Score topFleetScore
WantLine string
}{{ // Test 0: A graded fleet prints score and grade.
Score: topFleetScore{Score: 82, Grade: "B", Graded: true},
WantLine: "Fleet Score: 82 (B)",
}, { // Test 1: An ungraded fleet prints not graded.
Score: topFleetScore{Score: 100},
WantLine: "Fleet Score: not graded",
}}
for testNum, test := range tests {
t.Run(fmt.Sprintf("test %d", testNum), func(t *testing.T) {
t.Parallel()
buf := &bytes.Buffer{}
s := &topState{out: buf}
s.printHeader(&topReport{FleetScore: test.Score}, nil)
if !strings.Contains(buf.String(), test.WantLine) {
t.Errorf("header missing %q:\n%s", test.WantLine, buf.String())
}
})
}
}
2 changes: 1 addition & 1 deletion cmd/cmd_watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,5 +149,5 @@ func diffHasChanges(d compare.ScanDiff) bool {
return len(d.New) > 0 || len(d.Resolved) > 0 ||
len(d.ClusterStatusChanges) > 0 ||
len(d.AddedClusters) > 0 || len(d.RemovedClusters) > 0 ||
d.ScoreAfter != d.ScoreBefore
d.GradedBefore != d.GradedAfter || (d.ScoreComparable() && d.ScoreAfter != d.ScoreBefore)
}
14 changes: 7 additions & 7 deletions cmd/cmd_watch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ func TestDiffHasChanges(t *testing.T) {
In compare.ScanDiff
WantBool bool
}{
{In: compare.ScanDiff{}, WantBool: false}, // Test 0: Empty diff.
{In: compare.ScanDiff{New: []report.Finding{finding}}, WantBool: true}, // Test 1: New finding.
{In: compare.ScanDiff{Resolved: []report.Finding{finding}}, WantBool: true}, // Test 2: Resolved finding.
{In: compare.ScanDiff{AddedClusters: []string{"a"}}, WantBool: true}, // Test 3: Added cluster.
{In: compare.ScanDiff{RemovedClusters: []string{"a"}}, WantBool: true}, // Test 4: Removed cluster.
{In: compare.ScanDiff{ScoreBefore: 80, ScoreAfter: 70}, WantBool: true}, // Test 5: Score moved.
{In: compare.ScanDiff{ClusterStatusChanges: []compare.ClusterStatusChange{{Cluster: "a"}}}, WantBool: true}, // Test 6: Status change.
{In: compare.ScanDiff{}, WantBool: false}, // Test 0: Empty diff.
{In: compare.ScanDiff{New: []report.Finding{finding}}, WantBool: true}, // Test 1: New finding.
{In: compare.ScanDiff{Resolved: []report.Finding{finding}}, WantBool: true}, // Test 2: Resolved finding.
{In: compare.ScanDiff{AddedClusters: []string{"a"}}, WantBool: true}, // Test 3: Added cluster.
{In: compare.ScanDiff{RemovedClusters: []string{"a"}}, WantBool: true}, // Test 4: Removed cluster.
{In: compare.ScanDiff{ScoreBefore: 80, ScoreAfter: 70, GradedBefore: true, GradedAfter: true}, WantBool: true}, // Test 5: Score moved.
{In: compare.ScanDiff{ClusterStatusChanges: []compare.ClusterStatusChange{{Cluster: "a"}}}, WantBool: true}, // Test 6: Status change.
}
for testNum, test := range tests {
t.Run("case", func(t *testing.T) {
Expand Down
19 changes: 17 additions & 2 deletions cmd/cmd_whatchanged.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ type whatChangedDiff struct {
ScanB string `json:"scan_b"`
// FleetScoreDelta is ScanB.FleetScore - ScanA.FleetScore.
FleetScoreDelta int `json:"fleet_score_delta"`
// GradedA is true when the older scan was graded.
GradedA bool `json:"graded_a"`
// GradedB is true when the newer scan was graded.
GradedB bool `json:"graded_b"`
// FleetScoreA is the fleet score from the older scan.
FleetScoreA int `json:"fleet_score_a"`
// FleetScoreB is the fleet score from the newer scan.
Expand Down Expand Up @@ -200,6 +204,8 @@ func computeWhatChanged(a, b *report.Report, idA, idB, minSeverity string) whatC
ScanB: idB,
FleetScoreA: a.FleetScore.Score,
FleetScoreB: b.FleetScore.Score,
GradedA: a.FleetScore.Graded,
GradedB: b.FleetScore.Graded,
}
diff.FleetScoreDelta = diff.FleetScoreB - diff.FleetScoreA

Expand Down Expand Up @@ -287,8 +293,17 @@ func writeWhatChanged(w io.Writer, diff whatChangedDiff, jsonOut bool) error {
return enc.Encode(diff)
}
fmt.Fprintf(w, "Comparing %s -> %s\n", diff.ScanA, diff.ScanB)
fmt.Fprintf(w, "Fleet score: %d -> %d (%+d)\n",
diff.FleetScoreA, diff.FleetScoreB, diff.FleetScoreDelta)
switch {
case diff.GradedA && diff.GradedB:
fmt.Fprintf(w, "Fleet score: %d -> %d (%+d)\n",
diff.FleetScoreA, diff.FleetScoreB, diff.FleetScoreDelta)
case !diff.GradedA && !diff.GradedB:
fmt.Fprintln(w, "Fleet score: not graded in either scan.")
case !diff.GradedA:
fmt.Fprintf(w, "Fleet score: not graded -> %d\n", diff.FleetScoreB)
default:
fmt.Fprintf(w, "Fleet score: %d -> not graded\n", diff.FleetScoreA)
}
fmt.Fprintln(w)

fmt.Fprintf(w, "Drifted clusters (%d):\n", len(diff.Drift.Clusters))
Expand Down
Loading
Loading