Skip to content

Commit 3dc1d84

Browse files
committed
feat: waste badge component, risk colors, show all categories, sort by pending count
- Shared status.Waste/WasteShort components for dot + percentage - Risk level as domain enum with colored text (green/amber/red) - Show all categories in drawer, not just pending - Sort categories by pending count descending - Benefits parsed from JSON arrays, show all per category - GROUP_CONCAT(DISTINCT benefits) in SQL query
1 parent 09dad5e commit 3dc1d84

6 files changed

Lines changed: 146 additions & 42 deletions

File tree

‎internal/app/statusbar/policystatus/policystatus.go‎

Lines changed: 80 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/usetero/cli/internal/domain"
1515
"github.com/usetero/cli/internal/sqlite"
1616
"github.com/usetero/cli/internal/styles"
17+
"github.com/usetero/cli/internal/tea/components/status"
1718
"github.com/usetero/cli/internal/tea/components/table"
1819
)
1920

@@ -180,12 +181,9 @@ func (m *Model) renderHeadline() string {
180181

181182
var parts []string
182183

183-
text := lipgloss.NewStyle().Foreground(colors.Page.Text)
184-
185184
// Waste percentage with colored dot.
186-
if wp := wastePercent(s); wp > 0 {
187-
dot := lipgloss.NewStyle().Foreground(colors.Warning.Fg).Render("●")
188-
parts = append(parts, dot+" "+text.Render(fmt.Sprintf("%d%% waste", wp)))
185+
if w := status.Waste(m.theme, wastePercent(s)); w != "" {
186+
parts = append(parts, w)
189187
}
190188

191189
// Observed savings.
@@ -196,6 +194,7 @@ func (m *Model) renderHeadline() string {
196194

197195
// Pending count.
198196
if s.PendingPolicyCount > 0 {
197+
text := lipgloss.NewStyle().Foreground(colors.Page.Text)
199198
est := formatEstimated(s)
200199
if est != "" {
201200
parts = append(parts, text.Render(fmt.Sprintf("%d pending", s.PendingPolicyCount))+sep+muted.Render("~"+est))
@@ -213,36 +212,68 @@ func (m *Model) renderHeadline() string {
213212

214213
// renderCategoryTable renders the per-category breakdown.
215214
func (m *Model) renderCategoryTable(width int) string {
216-
// Only show categories with pending policies.
217-
var pending []domain.PolicyCategoryStatus
218-
for _, c := range m.categories {
219-
if c.PendingCount > 0 {
220-
pending = append(pending, c)
221-
}
222-
}
223-
224-
if len(pending) == 0 {
215+
if len(m.categories) == 0 {
225216
muted := lipgloss.NewStyle().Foreground(m.theme.Colors.Page.TextMuted)
226-
return muted.Render("No pending policies")
217+
return muted.Render("No policy data")
227218
}
228219

229220
tbl := table.New(m.theme, table.WithMaxValueWidth(30))
230-
tbl.Headers("Category", "Pending", "Impact", "Benefit", "Risk")
221+
tbl.Headers("Category", "Pending", "Waste", "Impact", "Risk", "Benefit")
231222
tbl.SetWidth(width)
232223

233-
for _, c := range pending {
224+
for _, c := range m.categories {
234225
tbl.Row(
235226
c.Category,
236227
fmt.Sprintf("%d", c.PendingCount),
228+
m.formatWaste(c),
237229
formatCategoryImpact(c),
230+
m.renderRisk(c.RiskLevel),
238231
formatBenefitLabel(c.Benefit),
239-
c.RiskLevel,
240232
)
241233
}
242234

243235
return tbl.View()
244236
}
245237

238+
// categoryWastePercent computes a single category's waste as a percentage of total.
239+
func (m *Model) categoryWastePercent(c domain.PolicyCategoryStatus) int {
240+
s := m.summary
241+
if s.TotalBytesPerHour > 0 && c.EstimatedBytesPerHour > 0 {
242+
return int(math.Round(c.EstimatedBytesPerHour / s.TotalBytesPerHour * 100))
243+
}
244+
if s.TotalVolumePerHour > 0 && c.EstimatedVolumePerHour > 0 {
245+
return int(math.Round(c.EstimatedVolumePerHour / s.TotalVolumePerHour * 100))
246+
}
247+
return 0
248+
}
249+
250+
// formatWaste renders the waste % with a colored dot, matching the statusbar style.
251+
func (m *Model) formatWaste(c domain.PolicyCategoryStatus) string {
252+
if w := status.WasteShort(m.theme, m.categoryWastePercent(c)); w != "" {
253+
return w
254+
}
255+
if c.PendingCount == 0 {
256+
return lipgloss.NewStyle().Foreground(m.theme.Colors.Success.Fg).Render("●")
257+
}
258+
return "—"
259+
}
260+
261+
// renderRisk returns a color-coded risk label.
262+
func (m *Model) renderRisk(level domain.RiskLevel) string {
263+
colors := m.theme.Colors
264+
s := level.String()
265+
switch level {
266+
case domain.RiskLevelHigh:
267+
return lipgloss.NewStyle().Foreground(colors.Error.Fg).Render(s)
268+
case domain.RiskLevelMedium:
269+
return lipgloss.NewStyle().Foreground(colors.Warning.Fg).Render(s)
270+
case domain.RiskLevelLow:
271+
return lipgloss.NewStyle().Foreground(colors.Success.Fg).Render(s)
272+
default:
273+
return lipgloss.NewStyle().Foreground(colors.Page.TextMuted).Render(s)
274+
}
275+
}
276+
246277
// wastePercent computes the estimated waste as a percentage.
247278
// Prefers bytes (where most waste lives), then cost, then volume.
248279
func wastePercent(s domain.PolicySummary) int {
@@ -270,22 +301,38 @@ func formatCategoryImpact(c domain.PolicyCategoryStatus) string {
270301
return "—"
271302
}
272303

273-
// formatBenefitLabel returns a human-readable benefit label.
274-
func formatBenefitLabel(benefit string) string {
275-
switch {
276-
case strings.Contains(benefit, "volume_reduction"):
277-
return "cost"
278-
case strings.Contains(benefit, "bytes_reduction"):
279-
return "bytes"
280-
case strings.Contains(benefit, "signal_quality"):
281-
return "signal quality"
282-
case strings.Contains(benefit, "compliance"):
283-
return "compliance"
284-
case strings.Contains(benefit, "resilience"):
285-
return "resilience"
286-
default:
287-
return benefit
304+
// formatBenefitLabel returns human-readable benefit labels.
305+
// The input may contain JSON arrays like '["volume_reduction","signal_quality"]'
306+
// concatenated by GROUP_CONCAT.
307+
func formatBenefitLabel(benefits string) string {
308+
labelFor := map[string]string{
309+
"volume_reduction": "cost",
310+
"bytes_reduction": "bytes",
311+
"signal_quality": "signal",
312+
"compliance": "compliance",
313+
"resilience": "resilience",
314+
}
315+
316+
// Strip JSON array noise: brackets, quotes, whitespace
317+
cleaned := strings.NewReplacer("[", "", "]", "", "\"", "").Replace(benefits)
318+
319+
seen := make(map[string]bool)
320+
var labels []string
321+
for _, part := range strings.Split(cleaned, ",") {
322+
key := strings.TrimSpace(part)
323+
label, ok := labelFor[key]
324+
if !ok {
325+
continue
326+
}
327+
if !seen[label] {
328+
seen[label] = true
329+
labels = append(labels, label)
330+
}
331+
}
332+
if len(labels) == 0 {
333+
return ""
288334
}
335+
return strings.Join(labels, ", ")
289336
}
290337

291338
// formatEstimated returns the estimated impact of pending policies.
Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
package domain
22

3+
// RiskLevel represents the risk level of a policy.
4+
type RiskLevel string
5+
6+
const (
7+
RiskLevelLow RiskLevel = "low"
8+
RiskLevelMedium RiskLevel = "medium"
9+
RiskLevelHigh RiskLevel = "high"
10+
)
11+
12+
func (r RiskLevel) String() string { return string(r) }
13+
314
// PolicyCategoryStatus is the per-category policy breakdown.
415
type PolicyCategoryStatus struct {
516
Category string
@@ -8,6 +19,6 @@ type PolicyCategoryStatus struct {
819
DismissedCount int64
920
EstimatedVolumePerHour float64
1021
EstimatedBytesPerHour float64
11-
RiskLevel string // predominant risk level
12-
Benefit string // predominant benefit type
22+
RiskLevel RiskLevel
23+
Benefit string
1324
}

‎internal/sqlite/gen/log_event_policies.sql.go‎

Lines changed: 2 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎internal/sqlite/log_event_policies.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ func (l *logEventPoliciesImpl) ListCategoryStatuses(ctx context.Context) ([]doma
4343
DismissedCount: row.DismissedCount,
4444
EstimatedVolumePerHour: derefFloat(row.EstimatedVolumePerHour),
4545
EstimatedBytesPerHour: derefFloat(row.EstimatedBytesPerHour),
46-
RiskLevel: row.RiskLevel,
46+
RiskLevel: domain.RiskLevel(row.RiskLevel),
4747
Benefit: row.Benefits,
4848
}
4949
}

‎internal/sqlite/queries/log_event_policies.sql‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,9 @@ SELECT
2020
WHEN 1 THEN 'low'
2121
ELSE ''
2222
END AS risk_level,
23-
CAST(COALESCE(MAX(benefits), '') AS TEXT) AS benefits
23+
CAST(COALESCE(GROUP_CONCAT(DISTINCT benefits), '') AS TEXT) AS benefits
2424
FROM log_event_policy_statuses_cache
2525
WHERE category IS NOT NULL AND category != ''
2626
GROUP BY category
2727
ORDER BY
28-
CASE WHEN SUM(CASE WHEN status = 'PENDING' THEN 1 ELSE 0 END) > 0 THEN 0 ELSE 1 END,
29-
SUM(CASE WHEN status = 'PENDING' THEN estimated_volume_reduction_per_hour ELSE 0 END) DESC;
28+
SUM(CASE WHEN status = 'PENDING' THEN 1 ELSE 0 END) DESC;

‎internal/tea/components/status/status.go‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
package status
77

88
import (
9+
"fmt"
910
"image/color"
1011

1112
"charm.land/lipgloss/v2"
@@ -75,6 +76,53 @@ func logEventColor(theme *styles.Theme, s domain.LogEventStatus) color.Color {
7576
}
7677
}
7778

79+
// --- Waste badge: ● N% ---
80+
81+
// Waste renders a colored dot with a waste percentage.
82+
// Returns empty string if pct is 0.
83+
func Waste(theme *styles.Theme, pct int) string {
84+
if pct <= 0 {
85+
return ""
86+
}
87+
colors := theme.Colors
88+
dot := lipgloss.NewStyle().Foreground(colors.Warning.Fg).Render("●")
89+
text := lipgloss.NewStyle().Foreground(colors.Page.Text).Render(fmt.Sprintf("%d%% waste", pct))
90+
return dot + " " + text
91+
}
92+
93+
// WasteShort renders a colored dot with just the percentage (no "waste" label).
94+
// Useful for table cells.
95+
func WasteShort(theme *styles.Theme, pct int) string {
96+
if pct <= 0 {
97+
return ""
98+
}
99+
colors := theme.Colors
100+
dot := lipgloss.NewStyle().Foreground(colors.Warning.Fg).Render("●")
101+
text := lipgloss.NewStyle().Foreground(colors.Page.Text).Render(fmt.Sprintf("%d%%", pct))
102+
return dot + " " + text
103+
}
104+
105+
// --- Risk levels: HIGH > MEDIUM > LOW ---
106+
107+
// Risk renders a colored status badge for a risk level.
108+
func Risk(theme *styles.Theme, r domain.RiskLevel, showLabel bool) string {
109+
return badge(riskColor(theme, r), r.String(), showLabel)
110+
}
111+
112+
func riskColor(theme *styles.Theme, r domain.RiskLevel) color.Color {
113+
colors := theme.Colors
114+
switch r {
115+
case domain.RiskLevelHigh:
116+
return colors.Error.Fg
117+
case domain.RiskLevelMedium:
118+
return colors.Warning.Fg
119+
case domain.RiskLevelLow:
120+
return colors.Success.Fg
121+
default:
122+
return colors.Page.TextMuted
123+
}
124+
}
125+
78126
// --- Policy statuses: PENDING > APPROVED > DISMISSED ---
79127

80128
// Policy renders a colored status badge for a policy status.

0 commit comments

Comments
 (0)