瀏覽代碼

add diagnostics table

roman.nikolsky 1 天之前
父節點
當前提交
0cc0cffb3e
共有 7 個文件被更改,包括 566 次插入24 次删除
  1. 54 7
      internal/app/model.go
  2. 133 5
      internal/detail/view.go
  3. 59 0
      internal/diagview/view.go
  4. 7 2
      internal/footer/view.go
  5. 45 8
      internal/header/view.go
  6. 240 0
      internal/shared/diag.go
  7. 28 2
      internal/tableview/view.go

+ 54 - 7
internal/app/model.go

@@ -6,6 +6,7 @@ import (
 
 	"fbtop/internal/api"
 	"fbtop/internal/detail"
+	"fbtop/internal/diagview"
 	"fbtop/internal/footer"
 	"fbtop/internal/header"
 	"fbtop/internal/shared"
@@ -27,6 +28,7 @@ type Model struct {
 	cursor     int
 	humanize   bool
 	showDetail bool
+	showDiag   bool
 	sortCol    shared.SortCol
 	sortAsc    bool
 	filterKind shared.Kind
@@ -38,6 +40,8 @@ func New(url string, interval time.Duration, humanize bool, sortCol shared.SortC
 		url:        url,
 		interval:   interval,
 		humanize:   humanize,
+		showDetail: true,
+		showDiag:   true,
 		sortCol:    sortCol,
 		sortAsc:    sortAsc,
 		filterKind: filterKind,
@@ -107,6 +111,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.showDetail = !m.showDetail
 		case "esc":
 			m.showDetail = false
+		case "d":
+			m.showDiag = !m.showDiag
 		}
 	}
 	return m, nil
@@ -125,30 +131,62 @@ func (m Model) View() tea.View {
 		return v
 	}
 
+	showDetail := m.showDetail && len(m.filteredAndSorted()) > 0
+
+	tableW := m.width
+	rightW := 0
+	if showDetail && m.width > 100 {
+		rightW = m.width * 2 / 5
+		tableW = m.width - rightW
+	} else if showDetail {
+		rightW = m.width / 2
+		tableW = m.width - rightW
+	}
+
 	comps := m.filteredAndSorted()
 
-	parts := []string{
-		header.Render(m.state, m.url, m.width),
+	// Header
+	headerLine := header.Render(m.state, m.url, m.width)
+
+	// Left pane: table + diagnostics stacked vertically
+	leftParts := []string{
 		tableview.Render(tableview.Params{
 			Comps:     comps,
 			PrevComps: m.state.PrevComps,
-			Width:     m.width,
+			Width:     tableW,
 			Cursor:    m.cursor,
 			Humanize:  m.humanize,
 		}),
 	}
+	if m.showDiag {
+		diags := shared.AnalyzeDiagnostics(m.state, m.state.PrevComps)
+		leftParts = append(leftParts, diagview.Render(diags, tableW))
+	}
+	leftContent := lipgloss.JoinVertical(lipgloss.Top, leftParts...)
 
-	if m.showDetail && len(comps) > 0 {
+	// Right pane: detail only
+	rightContent := ""
+	if showDetail {
 		idx := m.cursor
 		if idx >= len(comps) {
 			idx = len(comps) - 1
 		}
-		parts = append(parts, detail.Render(comps[idx]))
+		prev := prevComp(comps[idx].ID, m.state.PrevComps)
+		rightContent = detail.Render(comps[idx], prev, rightW)
 	}
 
-	parts = append(parts, footer.Render(m.sortCol.String(), m.filterKind.String(), m.humanize, m.width))
+	// Main area
+	var main string
+	if rightContent != "" {
+		main = lipgloss.JoinHorizontal(lipgloss.Top, leftContent, rightContent)
+	} else {
+		main = leftContent
+	}
 
-	v.SetContent(lipgloss.JoinVertical(lipgloss.Top, parts...))
+	// Footer
+	foot := footer.Render(m.sortCol.String(), m.filterKind.String(), m.humanize, m.showDiag, m.width)
+
+	v.SetContent(lipgloss.JoinVertical(lipgloss.Top, headerLine, main, foot))
 	return v
 }
 
@@ -162,3 +200,12 @@ func (m Model) filteredAndSorted() []shared.ComponentMetrics {
 	shared.SortBy(comps, m.sortCol, m.sortAsc)
 	return comps
 }
+
+func prevComp(id string, prevComps []shared.ComponentMetrics) shared.ComponentMetrics {
+	for _, c := range prevComps {
+		if c.ID == id {
+			return c
+		}
+	}
+	return shared.ComponentMetrics{}
+}

+ 133 - 5
internal/detail/view.go

@@ -10,12 +10,30 @@ import (
 	"charm.land/lipgloss/v2"
 )
 
-var titleStyle = lipgloss.NewStyle().Bold(true)
+var (
+	titleStyle = lipgloss.NewStyle().Bold(true)
+	okBadge    = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
+	warnBadge  = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
+	errBadge   = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
+	idleBadge  = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Bold(true)
+	hintStyle  = lipgloss.NewStyle().Faint(true).Italic(true)
+	panelStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), true).Padding(0, 1)
+)
 
-func Render(c shared.ComponentMetrics) string {
+func Render(c shared.ComponentMetrics, prev shared.ComponentMetrics, width int) string {
 	var b strings.Builder
-	b.WriteString(fmt.Sprintf("\n %s\n", titleStyle.Render(c.ID+" ("+c.Kind.String()+")")))
 
+	// Title with health badge
+	h := shared.ComponentHealth(c, prev)
+	badge := healthBadgeStyled(h)
+	b.WriteString(fmt.Sprintf(" %s  %s\n", badge, titleStyle.Render(c.ID+" ("+c.Kind.String()+")")))
+
+	// Health explanation
+	b.WriteString(" ")
+	b.WriteString(healthExplanation(c, h))
+	b.WriteString("\n\n")
+
+	// Fields
 	keys := make([]string, 0, len(c.Fields))
 	for k := range c.Fields {
 		keys = append(keys, k)
@@ -23,8 +41,118 @@ func Render(c shared.ComponentMetrics) string {
 	sort.Strings(keys)
 
 	for _, k := range keys {
-		b.WriteString(fmt.Sprintf("   %-20s %d\n", k, c.Fields[k]))
+		v := c.Fields[k]
+		indicator := ""
+		if isProblemField(k, v, c.Kind) {
+			indicator = errBadge.Render(" ← check this")
+		}
+		b.WriteString(fmt.Sprintf("   %-22s %d%s\n", k, v, indicator))
+	}
+
+	// Diagnostic hints
+	hints := componentHints(c, h)
+	if len(hints) > 0 {
+		b.WriteString("\n")
+		for _, h := range hints {
+			b.WriteString(fmt.Sprintf("   %s\n", hintStyle.Render("💡 "+h)))
+		}
+	}
+
+	return panelStyle.Width(width - 2).Render(b.String())
+}
+
+func healthBadgeStyled(h shared.Health) string {
+	switch h {
+	case shared.HealthOK:
+		return okBadge.Render("● OK")
+	case shared.HealthWarning:
+		return warnBadge.Render("▲ WARNING")
+	case shared.HealthError:
+		return errBadge.Render("✖ ERROR")
+	case shared.HealthIdle:
+		return idleBadge.Render("○ IDLE")
+	default:
+		return "?"
+	}
+}
+
+func healthExplanation(c shared.ComponentMetrics, h shared.Health) string {
+	switch h {
+	case shared.HealthOK:
+		return okBadge.Render("Records flowing normally")
+	case shared.HealthError:
+		return errBadge.Render("Errors detected — see fields below")
+	case shared.HealthWarning:
+		return warnBadge.Render("Warnings detected — review below")
+	case shared.HealthIdle:
+		return idleBadge.Render("No records — check configuration")
+	default:
+		return ""
+	}
+}
+
+func isProblemField(key string, val int64, kind shared.Kind) bool {
+	if val <= 0 {
+		return false
+	}
+	problemFields := map[string]bool{
+		"errors":          true,
+		"retries":         true,
+		"retries_failed":  true,
+		"dropped_records": true,
+		"drop_records":    true,
+		"drop_bytes":      true,
+	}
+	return problemFields[key]
+}
+
+func componentHints(c shared.ComponentMetrics, h shared.Health) []string {
+	var hints []string
+
+	switch c.Kind {
+	case shared.KindOutput:
+		if c.Fields["errors"] > 0 {
+			hints = append(hints, "Check output endpoint: TLS certs, credentials, auth tokens")
+			hints = append(hints, "Verify destination host is reachable: check DNS and firewall")
+		}
+		if c.Fields["retries_failed"] > 0 {
+			hints = append(hints, "Increase retry_limit or retry_window in output config")
+			hints = append(hints, "Check if destination has rate limits or is rejecting connections")
+		}
+		if c.Fields["retries"] > 0 && c.Fields["errors"] == 0 {
+			hints = append(hints, "Transient failures occurred — monitor or increase retry_limit")
+		}
+		if c.Fields["dropped_records"] > 0 {
+			hints = append(hints, "Increase queue_limit or mem_buf_limit in output config")
+			hints = append(hints, "Consider adding a filesystem-backed buffer (fsync: true)")
+		}
+		if c.Records() == 0 && h == shared.HealthIdle {
+			hints = append(hints, "No records reaching this output — check filter match patterns")
+			hints = append(hints, "Verify upstream input tags match the output match directive")
+		}
+
+	case shared.KindFilter:
+		if c.Fields["drop_records"] > 0 {
+			total := c.Records() + c.Fields["drop_records"]
+			if total > 0 {
+				ratio := float64(c.Fields["drop_records"]) / float64(total) * 100
+				if ratio > 50 {
+					hints = append(hints, fmt.Sprintf("Dropping %.0f%% of records — verify filter conditions", ratio))
+				}
+			}
+		}
+		if c.Records() == 0 && h == shared.HealthIdle {
+			hints = append(hints, "Check filter match pattern: must match upstream output tags")
+			hints = append(hints, "Verify record fields referenced in filter conditions exist")
+		}
+
+	case shared.KindInput:
+		if c.Records() == 0 && h == shared.HealthIdle {
+			hints = append(hints, "Check file path in input config — file may not exist or be empty")
+			hints = append(hints, "Verify the input plugin is correctly configured (path, tag, parser)")
+			hints = append(hints, "Check Fluent Bit logs for input plugin errors")
+		}
 	}
 
-	return b.String()
+	return hints
 }

+ 59 - 0
internal/diagview/view.go

@@ -0,0 +1,59 @@
+package diagview
+
+import (
+	"fmt"
+	"strings"
+
+	"fbtop/internal/shared"
+
+	"charm.land/lipgloss/v2"
+)
+
+var (
+	errorStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
+	warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214"))
+	idleStyle    = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
+	okStyle      = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
+	panelStyle   = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), true).Padding(0, 1)
+	titleStyle   = lipgloss.NewStyle().Bold(true)
+	hintStyle    = lipgloss.NewStyle().Faint(true).Italic(true)
+)
+
+func Render(diags []shared.Diag, width int) string {
+	if len(diags) == 0 {
+		ok := okStyle.Render("● No issues detected — pipeline is healthy")
+		return panelStyle.Width(width - 2).Render(
+			titleStyle.Render("Diagnostics") + "\n" + ok,
+		)
+	}
+
+	var b strings.Builder
+	b.WriteString(titleStyle.Render("Diagnostics"))
+	b.WriteString("\n")
+
+	for _, d := range diags {
+		var badge string
+		switch d.Level {
+		case shared.HealthError:
+			badge = errorStyle.Render("✖ ERROR")
+		case shared.HealthWarning:
+			badge = warningStyle.Render("▲ WARN ")
+		case shared.HealthIdle:
+			badge = idleStyle.Render("○ IDLE ")
+		default:
+			badge = "  OK  "
+		}
+
+		comp := ""
+		if d.CompID != "" {
+			comp = fmt.Sprintf(" [%s]", d.CompID)
+		}
+
+		b.WriteString(fmt.Sprintf(" %s %s%s\n", badge, d.Message, comp))
+		if d.Hint != "" {
+			b.WriteString(fmt.Sprintf("        %s\n", hintStyle.Render("💡 "+d.Hint)))
+		}
+	}
+
+	return panelStyle.Width(width - 2).Render(b.String())
+}

+ 7 - 2
internal/footer/view.go

@@ -8,17 +8,22 @@ import (
 
 var style = lipgloss.NewStyle().Faint(true).PaddingLeft(1).PaddingTop(1)
 
-func Render(sortCol string, filterKind string, humanize bool, width int) string {
+func Render(sortCol string, filterKind string, humanize bool, showDiag bool, width int) string {
 	hum := "on"
 	if !humanize {
 		hum = "off"
 	}
+	diag := "off"
+	if showDiag {
+		diag = "on"
+	}
 
 	help := fmt.Sprintf(
-		"[Tab] section: %s | [s] sort: %s | [h] humanize: %s | [Enter] detail | [q] quit",
+		"[Tab] section: %s | [s] sort: %s | [h] humanize: %s | [d] diagnostics: %s | [Enter] detail | [q] quit",
 		filterKind,
 		sortCol,
 		hum,
+		diag,
 	)
 
 	return style.Width(width).Render(help)

+ 45 - 8
internal/header/view.go

@@ -11,21 +11,58 @@ import (
 var (
 	titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63"))
 	urlStyle   = lipgloss.NewStyle().Faint(true)
-	dotStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
+	okDot      = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
+	warnDot    = lipgloss.NewStyle().Foreground(lipgloss.Color("214"))
+	errDot     = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
+	flowStyle  = lipgloss.NewStyle().Faint(true)
+	issueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
 )
 
 func Render(state shared.State, url string, width int) string {
-	dot := "● Disconnected"
-	if state.Connected {
-		dot = dotStyle.Render("● Connected")
+	var status string
+	if !state.Connected {
+		status = errDot.Render("✖ Disconnected")
+	} else {
+		ps := shared.SummarizePipeline(state.Comps)
+		if ps.HasErrors {
+			status = errDot.Render("✖ Pipeline errors")
+		} else if ps.HasWarnings {
+			status = warnDot.Render("▲ Warnings")
+		} else if ps.Flowing {
+			status = okDot.Render("● Healthy")
+		} else if ps.InputRec == 0 && ps.OutputRec == 0 {
+			status = warnDot.Render("○ Idle")
+		} else {
+			status = okDot.Render("● Connected")
+		}
 	}
 
-	title := titleStyle.Render("fbtop")
+	title := titleStyle.Render("fluent-bit-top")
 	urlStr := urlStyle.Render(url)
 	updated := state.UpdatedAt.Format("15:04:05")
 
-	header := fmt.Sprintf(" %s    %s    %s\n Components: %d   Updated: %s",
-		title, urlStr, dot, len(state.Comps), updated)
+	line1 := fmt.Sprintf(" %s    %s    %s    Updated: %s", title, urlStr, status, updated)
+
+	// Pipeline flow summary
+	line2 := ""
+	if state.Connected {
+		ps := shared.SummarizePipeline(state.Comps)
+		inStr := shared.FormatCount(ps.InputRec)
+		outStr := shared.FormatCount(ps.OutputRec)
+		line2 = fmt.Sprintf(" Flow: %s → %d comp → %s    ", inStr, len(state.Comps), outStr)
+
+		if ps.TotalErrors > 0 {
+			line2 += issueStyle.Render(fmt.Sprintf("errors:%d ", ps.TotalErrors))
+		}
+		if ps.TotalRetries > 0 {
+			line2 += issueStyle.Render(fmt.Sprintf("retries:%d ", ps.TotalRetries))
+		}
+		if ps.TotalDropped > 0 {
+			line2 += issueStyle.Render(fmt.Sprintf("dropped:%d ", ps.TotalDropped))
+		}
+
+		line2 = flowStyle.Render(line2)
+	}
 
-	return lipgloss.NewStyle().Width(width).Render(header)
+	return lipgloss.NewStyle().Width(width).Render(line1 + "\n" + line2)
 }

+ 240 - 0
internal/shared/diag.go

@@ -0,0 +1,240 @@
+package shared
+
+import "fmt"
+
+// Health represents per-component pipeline health.
+type Health int8
+
+const (
+	HealthOK      Health = iota // records flowing, no errors
+	HealthWarning               // retries, throughput anomaly
+	HealthError                 // errors, dropped records, retries_failed
+	HealthIdle                  // zero throughput — possible misconfig
+)
+
+func (h Health) String() string {
+	return [...]string{"ok", "warn", "error", "idle"}[h]
+}
+
+// Badge returns a short colored indicator for table display.
+func (h Health) Badge() string {
+	switch h {
+	case HealthOK:
+		return "●"
+	case HealthWarning:
+		return "▲"
+	case HealthError:
+		return "✖"
+	case HealthIdle:
+		return "○"
+	default:
+		return "?"
+	}
+}
+
+// Diag is a single diagnostic message for the warnings panel.
+type Diag struct {
+	Level   Health
+	CompID  string // component ID, or "" for global
+	Kind    Kind   // component kind
+	Message string
+	Hint    string // suggested config area to check
+}
+
+// AnalyzeDiagnostics inspects a State and produces actionable diagnostics.
+func AnalyzeDiagnostics(s State, prevComps []ComponentMetrics) []Diag {
+	var diags []Diag
+
+	prevMap := make(map[string]ComponentMetrics, len(prevComps))
+	for _, c := range prevComps {
+		prevMap[c.ID] = c
+	}
+
+	for _, c := range s.Comps {
+		diags = append(diags, componentDiags(c, prevMap[c.ID])...)
+	}
+
+	// Global diagnostics
+	totalOut, totalIn := flowTotals(s.Comps)
+	if totalIn > 0 && totalOut == 0 {
+		diags = append(diags, Diag{
+			Level:   HealthError,
+			Message: "No output records — pipeline is blocked or outputs misconfigured",
+			Hint:    "Check output plugin destination, auth, and network connectivity",
+		})
+	}
+
+	return diags
+}
+
+// ComponentHealth returns the health status for a single component.
+func ComponentHealth(c ComponentMetrics, prev ComponentMetrics) Health {
+	switch c.Kind {
+	case KindOutput:
+		if c.Fields["errors"] > 0 || c.Fields["retries_failed"] > 0 {
+			return HealthError
+		}
+		if c.Fields["retries"] > 0 || c.Fields["dropped_records"] > 0 {
+			return HealthWarning
+		}
+		if c.Records() == 0 {
+			return HealthIdle
+		}
+	case KindFilter:
+		total := c.Records() + c.Fields["drop_records"]
+		if total > 0 {
+			dropRatio := float64(c.Fields["drop_records"]) / float64(total)
+			if dropRatio > 0.5 {
+				return HealthWarning
+			}
+		}
+		if c.Records() == 0 {
+			return HealthIdle
+		}
+	case KindInput:
+		if c.Records() == 0 {
+			return HealthIdle
+		}
+	}
+	return HealthOK
+}
+
+// PipelineSummary is a high-level summary for the header.
+type PipelineSummary struct {
+	TotalInputs  int
+	TotalFilters int
+	TotalOutputs int
+	TotalErrors  int64
+	TotalRetries int64
+	TotalDropped int64
+	InputRec     int64
+	OutputRec    int64
+	HasErrors    bool
+	HasWarnings  bool
+	Flowing      bool
+}
+
+func SummarizePipeline(comps []ComponentMetrics) PipelineSummary {
+	var ps PipelineSummary
+	for _, c := range comps {
+		switch c.Kind {
+		case KindInput:
+			ps.TotalInputs++
+			ps.InputRec += c.Records()
+		case KindFilter:
+			ps.TotalFilters++
+		case KindOutput:
+			ps.TotalOutputs++
+			ps.OutputRec += c.Records()
+			ps.TotalErrors += c.Fields["errors"]
+			ps.TotalRetries += c.Fields["retries"]
+			ps.TotalDropped += c.Fields["dropped_records"]
+		}
+	}
+	ps.HasErrors = ps.TotalErrors > 0
+	ps.HasWarnings = ps.TotalRetries > 0 || ps.TotalDropped > 0
+	ps.Flowing = ps.InputRec > 0 && ps.OutputRec > 0
+	return ps
+}
+
+func componentDiags(c, prev ComponentMetrics) []Diag {
+	var diags []Diag
+	h := ComponentHealth(c, prev)
+
+	switch h {
+	case HealthError:
+		if c.Kind == KindOutput {
+			if e := c.Fields["errors"]; e > 0 {
+				diags = append(diags, Diag{
+					Level:   HealthError,
+					CompID:  c.ID,
+					Kind:    c.Kind,
+					Message: fmt.Sprintf("%d processing errors", e),
+					Hint:    "Check output destination availability, TLS certs, and credentials",
+				})
+			}
+			if rf := c.Fields["retries_failed"]; rf > 0 {
+				diags = append(diags, Diag{
+					Level:   HealthError,
+					CompID:  c.ID,
+					Kind:    c.Kind,
+					Message: fmt.Sprintf("%d failed retries", rf),
+					Hint:    "Destination may be unreachable — check network, DNS, and firewall rules",
+				})
+			}
+		}
+	case HealthWarning:
+		if c.Kind == KindOutput {
+			if r := c.Fields["retries"]; r > 0 {
+				diags = append(diags, Diag{
+					Level:   HealthWarning,
+					CompID:  c.ID,
+					Kind:    c.Kind,
+					Message: fmt.Sprintf("%d retries", r),
+					Hint:    "Transient failures — monitor or increase retry_limit in output config",
+				})
+			}
+			if d := c.Fields["dropped_records"]; d > 0 {
+				diags = append(diags, Diag{
+					Level:   HealthWarning,
+					CompID:  c.ID,
+					Kind:    c.Kind,
+					Message: fmt.Sprintf("%d records dropped", d),
+					Hint:    "Check queue_limit / mem_buf_limit in output config",
+				})
+			}
+		}
+		if c.Kind == KindFilter {
+			total := c.Records() + c.Fields["drop_records"]
+			if total > 0 && float64(c.Fields["drop_records"])/float64(total) > 0.5 {
+				diags = append(diags, Diag{
+					Level:   HealthWarning,
+					CompID:  c.ID,
+					Kind:    c.Kind,
+					Message: fmt.Sprintf("dropping >%d%% of records", int(float64(c.Fields["drop_records"])/float64(total)*100)),
+					Hint:    "Verify filter conditions — high drop rate may indicate misconfigured rules",
+				})
+			}
+		}
+	case HealthIdle:
+		switch c.Kind {
+		case KindInput:
+			diags = append(diags, Diag{
+				Level:   HealthIdle,
+				CompID:  c.ID,
+				Kind:    c.Kind,
+				Message: "No records collected",
+				Hint:    "Check path/tag/source in input config — file may not exist or be empty",
+			})
+		case KindOutput:
+			diags = append(diags, Diag{
+				Level:   HealthIdle,
+				CompID:  c.ID,
+				Kind:    c.Kind,
+				Message: "No records processed",
+				Hint:    "Upstream may be blocked — verify filters are not dropping all records",
+			})
+		case KindFilter:
+			diags = append(diags, Diag{
+				Level:   HealthIdle,
+				CompID:  c.ID,
+				Kind:    c.Kind,
+				Message: "No records passed through",
+				Hint:    "Check filter match pattern and upstream output tags",
+			})
+		}
+	}
+	return diags
+}
+
+func flowTotals(comps []ComponentMetrics) (in, out int64) {
+	for _, c := range comps {
+		switch c.Kind {
+		case KindInput:
+			in += c.Records()
+		case KindOutput:
+			out += c.Records()
+		}
+	}
+	return
+}

+ 28 - 2
internal/tableview/view.go

@@ -22,6 +22,13 @@ type Params struct {
 	Humanize  bool
 }
 
+var (
+	healthOKStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
+	healthWarnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214"))
+	healthErrStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
+	healthIdleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
+)
+
 func Render(p Params) string {
 	prevMap := make(map[string]shared.ComponentMetrics, len(p.PrevComps))
 	for _, c := range p.PrevComps {
@@ -29,7 +36,7 @@ func Render(p Params) string {
 	}
 
 	var b strings.Builder
-	b.WriteString(headerStyle.Render(fmt.Sprintf(" %-4s %-24s %-7s %10s %10s %s", "#", "Component", "Kind", "Rec/s", "Bytes/s", "Extra")))
+	b.WriteString(headerStyle.Render(fmt.Sprintf(" %-4s %-1s %-24s %-7s %10s %10s %s", "#", "", "Component", "Kind", "Rec/s", "Bytes/s", "Extra")))
 	b.WriteString("\n")
 	b.WriteString(strings.Repeat("─", p.Width))
 	b.WriteString("\n")
@@ -73,7 +80,11 @@ func Render(p Params) string {
 			}
 		}
 
-		row := fmt.Sprintf(" %-4d %-24s %-7s %10s %10s %s", i+1, c.ID, c.Kind, recRate, byteRate, extra)
+		// Health badge
+		health := shared.ComponentHealth(c, prevMap[c.ID])
+		badge := healthBadge(health)
+
+		row := fmt.Sprintf(" %-4d %-1s %-24s %-7s %10s %10s %s", i+1, badge, c.ID, c.Kind, recRate, byteRate, extra)
 		if i == p.Cursor {
 			row = selectedStyle.Render(row)
 		}
@@ -96,3 +107,18 @@ func sectionName(k shared.Kind) string {
 		return "ALL"
 	}
 }
+
+func healthBadge(h shared.Health) string {
+	switch h {
+	case shared.HealthOK:
+		return healthOKStyle.Render("●")
+	case shared.HealthWarning:
+		return healthWarnStyle.Render("▲")
+	case shared.HealthError:
+		return healthErrStyle.Render("✖")
+	case shared.HealthIdle:
+		return healthIdleStyle.Render("○")
+	default:
+		return " "
+	}
+}