Skip to content

Commit a6460a4

Browse files
committed
feat(report): clean header + adaptive columns + geeky progress; refresh demo GIF
- Header: short banner (`kubetidy · <context>`) with the data source moved to an aligned hero "Source" line using a friendly tier name (e.g. "kubetidy operator (Tier 0, no Prometheus)") instead of the bare "0 (kubetidy operator)". Waste line now also carries the workload count. - Adaptive columns: avg/p99 columns appear only when there's data, so an older operator (p95/peak only) renders a clean table, not a column of "—". - Geeky progress: while the (now concurrent) scan runs, show a live bar + count + a phrase that advances with progress — "⟦▰▰▰▰▰▱▱▱▱▱⟧ 51/102 · pricing the waste…". - Demo GIF re-recorded against the new card UX with real cpu+mem load generators. Full gate + lint green.
1 parent de4e265 commit a6460a4

6 files changed

Lines changed: 103 additions & 26 deletions

File tree

docs/assets/demo.gif

-74.7 KB
Loading

internal/cli/progress.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cli
33
import (
44
"fmt"
55
"os"
6+
"strings"
67
"sync"
78
"time"
89

@@ -71,6 +72,37 @@ func (s *spinner) update(status string) {
7172
s.mu.Unlock()
7273
}
7374

75+
// scanPhrases narrate the scan as it advances — a little personality for the wait. The phrase
76+
// is chosen by progress fraction, so it reads like a story from start to finish.
77+
var scanPhrases = []string{
78+
"reading usage from the cluster…",
79+
"crunching p50 / p95 / p99…",
80+
"hunting over-provisioned pods…",
81+
"pricing the waste in dollars…",
82+
"checking memory for OOM traps…",
83+
"scoring cluster efficiency…",
84+
"tidying up…",
85+
}
86+
87+
// scanProgress renders a catchy progress line: a mini bar, the count, and a phrase that advances
88+
// with the scan, e.g. "⟦▰▰▰▰▰▱▱▱▱▱⟧ 51/102 · pricing the waste in dollars…".
89+
func scanProgress(done, total int) string {
90+
const w = 14
91+
if total < 1 {
92+
total = 1
93+
}
94+
if done > total {
95+
done = total
96+
}
97+
filled := done * w / total
98+
bar := strings.Repeat("▰", filled) + strings.Repeat("▱", w-filled)
99+
idx := done * len(scanPhrases) / total
100+
if idx >= len(scanPhrases) {
101+
idx = len(scanPhrases) - 1
102+
}
103+
return fmt.Sprintf("⟦%s⟧ %d/%d · %s", bar, done, total, scanPhrases[idx])
104+
}
105+
74106
// finish halts the animation and clears the spinner line so the report renders cleanly.
75107
func (s *spinner) finish() {
76108
if !s.enabled {

internal/cli/progress_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package cli
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestScanProgress(t *testing.T) {
9+
start := scanProgress(0, 102)
10+
if !strings.Contains(start, "0/102") || !strings.Contains(start, scanPhrases[0]) {
11+
t.Errorf("start = %q", start)
12+
}
13+
end := scanProgress(102, 102)
14+
if !strings.Contains(end, "102/102") || !strings.Contains(end, scanPhrases[len(scanPhrases)-1]) {
15+
t.Errorf("end = %q", end)
16+
}
17+
// Robust to edge inputs (no panic / out-of-range).
18+
_ = scanProgress(0, 0)
19+
_ = scanProgress(5, 1) // done > total clamps
20+
}

internal/cli/scan.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ func runEngineWithClients(ctx context.Context, f *scanFlags, clients *kube.Clien
114114
}
115115
if sp != nil {
116116
engine.Progress = func(done, total int) {
117-
sp.update(fmt.Sprintf("analyzing workloads… %d/%d", done, total))
117+
sp.update(scanProgress(done, total))
118118
}
119119
}
120120
result, err := engine.Run(ctx)

internal/report/report.go

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -98,31 +98,23 @@ func Table(w io.Writer, result model.ScanResult, opts Options) error {
9898
return err
9999
}
100100

101-
// writeBanner emits the top context line, e.g.
102-
// "kubetidy · prod-us-east · data: 1 (Prometheus)".
101+
// writeBanner emits a short top line: "kubetidy · <context>". The data source and provenance
102+
// move to an aligned "Source" line in the hero, so this stays clean even with a long context.
103103
func writeBanner(b *strings.Builder, result model.ScanResult, opts Options) {
104104
ctx := result.Context
105105
if ctx == "" {
106106
ctx = "(no context)"
107107
}
108-
banner := fmt.Sprintf("kubetidy · %s · data: %s", ctx, result.Tier.String())
109-
// Window + typical sample count are uniform across a scan, so state them ONCE here rather
110-
// than repeating on every row. Exact per-workload samples live in --explain.
111-
if w := observedWindow(result); w > 0 {
112-
banner += fmt.Sprintf(" · %s history", formatWindow(w))
113-
if s := typicalSamples(result); s > 0 {
114-
banner += fmt.Sprintf(", ~%s samples/workload", formatCount(s))
115-
}
116-
}
108+
banner := "kubetidy · " + ctx
117109
if opts.Color {
118110
fmt.Fprintf(b, "%s%s%s\n", ansiBold+ansiCyan, banner, ansiReset)
119111
} else {
120112
b.WriteString(banner + "\n")
121113
}
122114
}
123115

124-
// writeHero emits the hero summary: efficiency score (with a bar when colored), the dollar
125-
// waste, and a one-line takeaway counting the rightsizing opportunities.
116+
// writeHero emits the hero block: efficiency score (with a bar when colored), the dollar waste
117+
// + workload count, and an aligned Source line naming the data tier, window and sample count.
126118
func writeHero(b *strings.Builder, result model.ScanResult, opts Options) {
127119
if opts.Color {
128120
fmt.Fprintf(b, " Efficiency %d/100 %s\n", result.EfficiencyScore, scoreBar(result.EfficiencyScore))
@@ -132,24 +124,53 @@ func writeHero(b *strings.Builder, result model.ScanResult, opts Options) {
132124

133125
waste := formatDollars(result.TotalMonthlyWaste)
134126
if opts.Color {
135-
fmt.Fprintf(b, " Waste %s%s/mo%s potential savings\n", ansiBold, waste, ansiReset)
127+
fmt.Fprintf(b, " Waste %s%s/mo%s potential savings · %s\n", ansiBold, waste, ansiReset, takeaway(result))
136128
} else {
137-
fmt.Fprintf(b, " Waste %s/mo potential savings\n", waste)
129+
fmt.Fprintf(b, " Waste %s/mo potential savings · %s\n", waste, takeaway(result))
138130
}
139131

140-
b.WriteString(" " + takeaway(result) + "\n")
132+
fmt.Fprintf(b, " Source %s\n", sourceLine(result))
141133
}
142134

143-
// takeaway is the one-line human summary under the hero numbers.
135+
// takeaway is the short workload-count phrase shown beside the waste figure.
144136
func takeaway(result model.ScanResult) string {
145-
n := len(result.Recommendations)
146-
switch n {
137+
switch n := len(result.Recommendations); n {
147138
case 0:
148-
return "no workloads need rightsizing"
139+
return "nothing to rightsize"
149140
case 1:
150-
return "1 workload can be rightsized"
141+
return "1 workload to rightsize"
142+
default:
143+
return fmt.Sprintf("%d workloads to rightsize", n)
144+
}
145+
}
146+
147+
// sourceLine describes where the numbers came from: the data tier (friendly name), and — for
148+
// time-series tiers — the observation window and typical sample count.
149+
func sourceLine(result model.ScanResult) string {
150+
s := tierSource(result.Tier)
151+
if w := observedWindow(result); w > 0 {
152+
s += fmt.Sprintf(" · %s history", formatWindow(w))
153+
if n := typicalSamples(result); n > 0 {
154+
s += fmt.Sprintf(", ~%s samples/workload", formatCount(n))
155+
}
156+
}
157+
return s
158+
}
159+
160+
// tierSource is the human-friendly data-source name for a tier (without the bare tier number
161+
// that EvidenceTier.String carries, which reads oddly in prose).
162+
func tierSource(t model.EvidenceTier) string {
163+
switch t {
164+
case model.TierOperator:
165+
return "kubetidy operator (Tier 0, no Prometheus)"
166+
case model.TierHistorical:
167+
return "Prometheus (Tier 1)"
168+
case model.TierAllocated:
169+
return "OpenCost (Tier 2)"
170+
case model.TierSnapshot:
171+
return "metrics-server snapshot (limited — single reading)"
151172
default:
152-
return fmt.Sprintf("%d workloads can be rightsized", n)
173+
return "spec only (no usage data)"
153174
}
154175
}
155176

internal/report/report_test.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func TestTableBanner(t *testing.T) {
9191
}
9292
out := buf.String()
9393

94-
wantBanner := "kubetidy · prod-us-east · data: 1 (Prometheus)"
94+
wantBanner := "kubetidy · prod-us-east"
9595
if !strings.Contains(out, wantBanner) {
9696
t.Errorf("missing banner line %q\n--- got ---\n%s", wantBanner, out)
9797
}
@@ -118,9 +118,13 @@ func TestTableHeroSummary(t *testing.T) {
118118
if !strings.Contains(out, "Waste $7,420/mo potential savings") {
119119
t.Errorf("missing hero waste line\n--- got ---\n%s", out)
120120
}
121-
if !strings.Contains(out, "2 workloads can be rightsized") {
121+
if !strings.Contains(out, "2 workloads to rightsize") {
122122
t.Errorf("missing one-line takeaway\n--- got ---\n%s", out)
123123
}
124+
// Source line names the tier in friendly prose (not the bare "1 (Prometheus)").
125+
if !strings.Contains(out, "Source") || !strings.Contains(out, "Prometheus (Tier 1)") {
126+
t.Errorf("missing/non-friendly Source line\n--- got ---\n%s", out)
127+
}
124128
}
125129

126130
func TestTakeawaySingular(t *testing.T) {
@@ -130,7 +134,7 @@ func TestTakeawaySingular(t *testing.T) {
130134
if err := Table(&buf, r, Options{Color: false}); err != nil {
131135
t.Fatalf("Table: %v", err)
132136
}
133-
if !strings.Contains(buf.String(), "1 workload can be rightsized") {
137+
if !strings.Contains(buf.String(), "1 workload to rightsize") {
134138
t.Errorf("singular takeaway wrong\n--- got ---\n%s", buf.String())
135139
}
136140
}

0 commit comments

Comments
 (0)