From 3d7ab5a49e11ffef29e9fe39b219e2a03d590beb Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Wed, 3 Jun 2026 16:33:12 -0400 Subject: [PATCH] feat(tui): classify error reasons on DOWN monitors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Categorize raw error strings into DNS/TCP/TLS/HTTP/ICMP/TMO/PRIV so users get instant triage from the monitor list without opening the detail panel. - Status column shows DOWN:DNS, DOWN:TLS, DOWN:HTTP, etc. - Inline NAME column errors prefixed with category tag [DNS], [TLS] - Detail panel shows connection chain checklist for HTTP monitors (✓ DNS → ✓ TCP → ✗ TLS → · HTTP) pinpointing failure layer - All display-side only — no database or model changes --- internal/tui/errclass.go | 199 ++++++++++++++++++++++++++++++++++ internal/tui/errclass_test.go | 186 +++++++++++++++++++++++++++++++ internal/tui/format.go | 10 +- internal/tui/format_test.go | 30 +++++ internal/tui/tab_sites.go | 16 ++- internal/tui/view_detail.go | 31 +++++- 6 files changed, 464 insertions(+), 8 deletions(-) create mode 100644 internal/tui/errclass.go create mode 100644 internal/tui/errclass_test.go diff --git a/internal/tui/errclass.go b/internal/tui/errclass.go new file mode 100644 index 0000000..8196526 --- /dev/null +++ b/internal/tui/errclass.go @@ -0,0 +1,199 @@ +package tui + +import ( + "fmt" + "strings" +) + +type ErrorCategory string + +const ( + ErrCatDNS ErrorCategory = "DNS" + ErrCatTCP ErrorCategory = "TCP" + ErrCatTLS ErrorCategory = "TLS" + ErrCatHTTP ErrorCategory = "HTTP" + ErrCatICMP ErrorCategory = "ICMP" + ErrCatTimeout ErrorCategory = "TMO" + ErrCatPrivate ErrorCategory = "PRIV" + ErrCatUnknown ErrorCategory = "" +) + +func classifyError(errorReason string, siteType string, statusCode int) ErrorCategory { + if errorReason == "" { + return ErrCatUnknown + } + lower := strings.ToLower(errorReason) + + if strings.Contains(lower, "resolves to private") || strings.Contains(lower, "private address") || strings.Contains(lower, "private ip") { + return ErrCatPrivate + } + + if strings.Contains(lower, "tls handshake") || strings.Contains(lower, "x509") || + strings.Contains(lower, "certificate") || strings.Contains(lower, "ssl") { + return ErrCatTLS + } + + if strings.Contains(lower, "no such host") || strings.Contains(lower, "nxdomain") || + strings.Contains(lower, "dns query") || strings.Contains(lower, "dns rcode") || + strings.Contains(lower, "servfail") || strings.Contains(lower, "server misbehaving") { + return ErrCatDNS + } + + if strings.Contains(lower, "i/o timeout") || strings.Contains(lower, "deadline exceeded") || + strings.Contains(lower, "context canceled") { + return ErrCatTimeout + } + + if strings.Contains(lower, "no icmp response") || strings.Contains(lower, "ping setup") || + strings.Contains(lower, "ping failed") { + return ErrCatICMP + } + + if strings.Contains(lower, "connection refused") || strings.Contains(lower, "connection reset") || + strings.Contains(lower, "no route to host") || strings.Contains(lower, "network unreachable") || + strings.Contains(lower, "network is unreachable") { + return ErrCatTCP + } + + if strings.HasPrefix(lower, "http ") || strings.Contains(lower, "keyword not found") { + return ErrCatHTTP + } + if statusCode >= 400 { + return ErrCatHTTP + } + + return ErrCatUnknown +} + +func categoryTag(cat ErrorCategory) string { + if cat == ErrCatUnknown { + return "" + } + return "[" + string(cat) + "]" +} + +type stepStatus int + +const ( + stepPassed stepStatus = iota + stepFailed + stepSkipped +) + +type chainStep struct { + Name string + Status stepStatus + Detail string +} + +func connectionChain(errorReason string, siteType string, statusCode int, isHTTPS bool) []chainStep { + if siteType != "http" { + return nil + } + if errorReason == "" { + return nil + } + + cat := classifyError(errorReason, siteType, statusCode) + detail := extractDetail(errorReason, cat, statusCode) + + steps := []struct { + name string + cat ErrorCategory + }{ + {"DNS resolve", ErrCatDNS}, + {"TCP connect", ErrCatTCP}, + } + if isHTTPS { + steps = append(steps, struct { + name string + cat ErrorCategory + }{"TLS handshake", ErrCatTLS}) + } + steps = append(steps, struct { + name string + cat ErrorCategory + }{"HTTP response", ErrCatHTTP}) + + chain := make([]chainStep, 0, len(steps)) + failed := false + for _, s := range steps { + switch { + case failed: + chain = append(chain, chainStep{Name: s.name, Status: stepSkipped, Detail: "(skipped)"}) + case s.cat == cat || (cat == ErrCatTimeout && s.cat == ErrCatTCP) || (cat == ErrCatPrivate && s.cat == ErrCatDNS): + chain = append(chain, chainStep{Name: s.name, Status: stepFailed, Detail: detail}) + failed = true + default: + chain = append(chain, chainStep{Name: s.name, Status: stepPassed}) + } + } + + return chain +} + +func extractDetail(errorReason string, cat ErrorCategory, statusCode int) string { + lower := strings.ToLower(errorReason) + var detail string + + switch cat { + case ErrCatDNS: + for _, keyword := range []string{"NXDOMAIN", "SERVFAIL", "REFUSED", "no such host"} { + if strings.Contains(strings.ToUpper(errorReason), strings.ToUpper(keyword)) { + detail = keyword + break + } + } + if detail == "" { + detail = limitStr(errorReason, 30) + } + case ErrCatTCP: + for _, keyword := range []string{"connection refused", "connection reset", "no route to host", "network unreachable"} { + if strings.Contains(lower, keyword) { + detail = keyword + break + } + } + if detail == "" { + detail = limitStr(errorReason, 30) + } + case ErrCatTLS: + for _, keyword := range []string{"certificate expired", "certificate has expired", "handshake failure", "unknown authority"} { + if strings.Contains(lower, keyword) { + detail = keyword + break + } + } + if detail == "" && strings.Contains(lower, "x509:") { + idx := strings.Index(lower, "x509:") + detail = limitStr(errorReason[idx:], 30) + } + if detail == "" { + detail = limitStr(errorReason, 30) + } + case ErrCatHTTP: + if statusCode > 0 { + detail = fmt.Sprintf("HTTP %d", statusCode) + } else { + detail = limitStr(errorReason, 30) + } + case ErrCatTimeout: + if strings.Contains(lower, "i/o timeout") { + detail = "i/o timeout" + } else { + detail = "deadline exceeded" + } + case ErrCatICMP: + if strings.Contains(lower, "no icmp response") { + detail = "no response" + } else { + detail = limitStr(errorReason, 30) + } + case ErrCatPrivate: + detail = "private IP blocked" + default: + detail = limitStr(errorReason, 30) + } + + return detail +} diff --git a/internal/tui/errclass_test.go b/internal/tui/errclass_test.go new file mode 100644 index 0000000..53b7c52 --- /dev/null +++ b/internal/tui/errclass_test.go @@ -0,0 +1,186 @@ +package tui + +import "testing" + +func TestClassifyError(t *testing.T) { + tests := []struct { + reason string + siteType string + statusCode int + want ErrorCategory + }{ + {"", "http", 0, ErrCatUnknown}, + {"some unknown error", "http", 0, ErrCatUnknown}, + + // PRIV + {"target resolves to private IP", "http", 0, ErrCatPrivate}, + {"blocked: host resolves to private address 10.0.0.1", "port", 0, ErrCatPrivate}, + + // TLS + {"SSL certificate expired", "http", 0, ErrCatTLS}, + {"tls handshake failure", "http", 0, ErrCatTLS}, + {"x509: certificate has expired", "http", 0, ErrCatTLS}, + {"x509: certificate signed by unknown authority", "http", 0, ErrCatTLS}, + + // DNS + {"DNS query failed: NXDOMAIN", "http", 0, ErrCatDNS}, + {"DNS RCODE: SERVFAIL", "dns", 0, ErrCatDNS}, + {"dial tcp: lookup example.com: no such host", "http", 0, ErrCatDNS}, + {"DNS query failed: server misbehaving", "dns", 0, ErrCatDNS}, + + // TMO + {"dial tcp 1.2.3.4:443: i/o timeout", "http", 0, ErrCatTimeout}, + {"context deadline exceeded", "http", 0, ErrCatTimeout}, + {"Get https://example.com: context canceled", "http", 0, ErrCatTimeout}, + + // ICMP + {"no ICMP response", "ping", 0, ErrCatICMP}, + {"ping setup: permission denied", "ping", 0, ErrCatICMP}, + {"ping failed: packet loss", "ping", 0, ErrCatICMP}, + + // TCP + {"dial tcp 1.2.3.4:80: connect: connection refused", "port", 0, ErrCatTCP}, + {"connection reset by peer", "http", 0, ErrCatTCP}, + {"dial tcp: no route to host", "http", 0, ErrCatTCP}, + {"network unreachable", "http", 0, ErrCatTCP}, + + // HTTP + {"HTTP 500 (expected 200-299)", "http", 500, ErrCatHTTP}, + {"HTTP 403 (expected 200-299)", "http", 403, ErrCatHTTP}, + {"keyword not found", "http", 200, ErrCatHTTP}, + {"", "http", 502, ErrCatUnknown}, + {"unexpected status", "http", 404, ErrCatHTTP}, + } + for _, tt := range tests { + got := classifyError(tt.reason, tt.siteType, tt.statusCode) + if got != tt.want { + t.Errorf("classifyError(%q, %q, %d) = %q, want %q", + tt.reason, tt.siteType, tt.statusCode, got, tt.want) + } + } +} + +func TestCategoryTag(t *testing.T) { + tests := []struct { + cat ErrorCategory + want string + }{ + {ErrCatDNS, "[DNS]"}, + {ErrCatTCP, "[TCP]"}, + {ErrCatTLS, "[TLS]"}, + {ErrCatHTTP, "[HTTP]"}, + {ErrCatICMP, "[ICMP]"}, + {ErrCatTimeout, "[TMO]"}, + {ErrCatPrivate, "[PRIV]"}, + {ErrCatUnknown, ""}, + } + for _, tt := range tests { + got := categoryTag(tt.cat) + if got != tt.want { + t.Errorf("categoryTag(%q) = %q, want %q", tt.cat, got, tt.want) + } + } +} + +func TestConnectionChain(t *testing.T) { + t.Run("nil for non-http", func(t *testing.T) { + if chain := connectionChain("no ICMP response", "ping", 0, false); chain != nil { + t.Errorf("expected nil for ping, got %v", chain) + } + }) + + t.Run("nil for empty error", func(t *testing.T) { + if chain := connectionChain("", "http", 0, true); chain != nil { + t.Errorf("expected nil for empty error, got %v", chain) + } + }) + + t.Run("DNS failure HTTPS", func(t *testing.T) { + chain := connectionChain("no such host", "http", 0, true) + if len(chain) != 4 { + t.Fatalf("expected 4 steps, got %d", len(chain)) + } + if chain[0].Status != stepFailed { + t.Errorf("DNS step: want failed, got %d", chain[0].Status) + } + if chain[1].Status != stepSkipped { + t.Errorf("TCP step: want skipped, got %d", chain[1].Status) + } + if chain[2].Status != stepSkipped { + t.Errorf("TLS step: want skipped, got %d", chain[2].Status) + } + if chain[3].Status != stepSkipped { + t.Errorf("HTTP step: want skipped, got %d", chain[3].Status) + } + }) + + t.Run("TCP failure HTTP", func(t *testing.T) { + chain := connectionChain("connection refused", "http", 0, false) + if len(chain) != 3 { + t.Fatalf("expected 3 steps (no TLS), got %d", len(chain)) + } + if chain[0].Status != stepPassed { + t.Errorf("DNS step: want passed, got %d", chain[0].Status) + } + if chain[1].Status != stepFailed { + t.Errorf("TCP step: want failed, got %d", chain[1].Status) + } + if chain[2].Status != stepSkipped { + t.Errorf("HTTP step: want skipped, got %d", chain[2].Status) + } + }) + + t.Run("TLS failure HTTPS", func(t *testing.T) { + chain := connectionChain("x509: certificate has expired", "http", 0, true) + if len(chain) != 4 { + t.Fatalf("expected 4 steps, got %d", len(chain)) + } + if chain[0].Status != stepPassed { + t.Errorf("DNS: want passed, got %d", chain[0].Status) + } + if chain[1].Status != stepPassed { + t.Errorf("TCP: want passed, got %d", chain[1].Status) + } + if chain[2].Status != stepFailed { + t.Errorf("TLS: want failed, got %d", chain[2].Status) + } + if chain[3].Status != stepSkipped { + t.Errorf("HTTP: want skipped, got %d", chain[3].Status) + } + }) + + t.Run("HTTP failure HTTPS", func(t *testing.T) { + chain := connectionChain("HTTP 500 (expected 200-299)", "http", 500, true) + if len(chain) != 4 { + t.Fatalf("expected 4 steps, got %d", len(chain)) + } + for i := 0; i < 3; i++ { + if chain[i].Status != stepPassed { + t.Errorf("step %d: want passed, got %d", i, chain[i].Status) + } + } + if chain[3].Status != stepFailed { + t.Errorf("HTTP: want failed, got %d", chain[3].Status) + } + if chain[3].Detail != "HTTP 500" { + t.Errorf("HTTP detail: want %q, got %q", "HTTP 500", chain[3].Detail) + } + }) + + t.Run("timeout maps to TCP step", func(t *testing.T) { + chain := connectionChain("i/o timeout", "http", 0, true) + if chain[1].Status != stepFailed { + t.Errorf("TCP step: want failed for timeout, got %d", chain[1].Status) + } + if chain[1].Detail != "i/o timeout" { + t.Errorf("detail: want %q, got %q", "i/o timeout", chain[1].Detail) + } + }) + + t.Run("private IP maps to DNS step", func(t *testing.T) { + chain := connectionChain("target resolves to private IP", "http", 0, true) + if chain[0].Status != stepFailed { + t.Errorf("DNS step: want failed for private IP, got %d", chain[0].Status) + } + }) +} diff --git a/internal/tui/format.go b/internal/tui/format.go index 6757365..3c87f1d 100644 --- a/internal/tui/format.go +++ b/internal/tui/format.go @@ -128,7 +128,7 @@ func fmtRetries(site models.Site) string { return s } -func fmtStatus(status string, paused bool, inMaint bool) string { +func fmtStatus(status string, paused bool, inMaint bool, errCategory ErrorCategory) string { if paused { return warnStyle.Render("PAUSED") } @@ -136,7 +136,13 @@ func fmtStatus(status string, paused bool, inMaint bool) string { return maintStyle.Render("MAINT") } switch status { - case "DOWN", "SSL EXP": + case "DOWN": + label := "DOWN" + if errCategory != ErrCatUnknown { + label = "DOWN:" + string(errCategory) + } + return dangerStyle.Render(label) + case "SSL EXP": return dangerStyle.Render(status) case "LATE": return warnStyle.Render(status) diff --git a/internal/tui/format_test.go b/internal/tui/format_test.go index 721a59d..f27d668 100644 --- a/internal/tui/format_test.go +++ b/internal/tui/format_test.go @@ -55,6 +55,36 @@ func TestSiteOrder(t *testing.T) { } } +func TestFmtStatus_ErrorCategory(t *testing.T) { + tests := []struct { + status string + paused bool + inMaint bool + cat ErrorCategory + wantSub string + }{ + {"DOWN", false, false, ErrCatDNS, "DOWN:DNS"}, + {"DOWN", false, false, ErrCatTLS, "DOWN:TLS"}, + {"DOWN", false, false, ErrCatHTTP, "DOWN:HTTP"}, + {"DOWN", false, false, ErrCatTCP, "DOWN:TCP"}, + {"DOWN", false, false, ErrCatTimeout, "DOWN:TMO"}, + {"DOWN", false, false, ErrCatICMP, "DOWN:ICMP"}, + {"DOWN", false, false, ErrCatPrivate, "DOWN:PRIV"}, + {"DOWN", false, false, ErrCatUnknown, "DOWN"}, + {"UP", false, false, ErrCatUnknown, "UP"}, + {"SSL EXP", false, false, ErrCatUnknown, "SSL EXP"}, + {"DOWN", true, false, ErrCatDNS, "PAUSED"}, + {"DOWN", false, true, ErrCatDNS, "MAINT"}, + } + for _, tt := range tests { + got := fmtStatus(tt.status, tt.paused, tt.inMaint, tt.cat) + if !containsPlain(got, tt.wantSub) { + t.Errorf("fmtStatus(%q, paused=%v, maint=%v, %q): %q missing %q", + tt.status, tt.paused, tt.inMaint, tt.cat, got, tt.wantSub) + } + } +} + func TestFmtDuration(t *testing.T) { tests := []struct { d time.Duration diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 7f16b8e..32b6548 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -52,8 +52,8 @@ func (m Model) computeLayout() tableLayout { fixed = 4 + 10 + 10 + 10 + 8 + 7 + 9 } else { headers = []string{"#", "NAME", "TYPE", "STATUS", "LAT", "UP%", "HISTORY", "SSL", "RT"} - widths = []int{4, 0, 8, 8, 7, 8, 0, 5, 5} - fixed = 4 + 8 + 8 + 7 + 8 + 5 + 5 + widths = []int{4, 0, 8, 10, 7, 8, 0, 5, 5} + fixed = 4 + 8 + 10 + 7 + 8 + 5 + 5 } numCols := len(headers) @@ -137,7 +137,7 @@ func (m Model) viewSitesTab() string { strconv.Itoa(i + 1), m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-4)), "group", - fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)), + fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID), ErrCatUnknown), subtleStyle.Render("—"), m.groupUptime(site.ID), m.groupSparkline(site.ID, sparkWidth), @@ -162,7 +162,13 @@ func (m Model) viewSitesTab() string { nameLen := len([]rune(name)) errSpace := nameW - nameLen - 3 if errSpace > 10 { - name = name + " " + subtleStyle.Render(limitStr(site.LastError, errSpace)) + cat := classifyError(site.LastError, site.Type, site.StatusCode) + tag := categoryTag(cat) + errText := site.LastError + if tag != "" { + errText = tag + " " + errText + } + name = name + " " + subtleStyle.Render(limitStr(errText, errSpace)) } } @@ -178,7 +184,7 @@ func (m Model) viewSitesTab() string { strconv.Itoa(i + 1), m.zones.Mark(fmt.Sprintf("site-%d", i), name), typeIcon(site.Type, false) + " " + site.Type, - fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)), + fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID), classifyError(site.LastError, site.Type, site.StatusCode)), fmtLatency(site.Latency), fmtUptime(hist.Statuses), spark, diff --git a/internal/tui/view_detail.go b/internal/tui/view_detail.go index c7f39c1..0a3090b 100644 --- a/internal/tui/view_detail.go +++ b/internal/tui/view_detail.go @@ -40,7 +40,8 @@ func (m Model) viewDetailPanel() string { b.WriteString("\n" + subtleStyle.Render(" "+label) + "\n") } - row("Status", fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))) + errCat := classifyError(site.LastError, site.Type, site.StatusCode) + row("Status", fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID), errCat)) if (site.Status == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE") && site.LastError != "" { row("Error", dangerStyle.Render(limitStr(site.LastError, 60))) @@ -50,6 +51,34 @@ func (m Model) viewDetailPanel() string { row("HTTP Code", strconv.Itoa(site.StatusCode)) } + if (site.Status == "DOWN" || site.Status == "SSL EXP") && site.LastError != "" { + chain := connectionChain(site.LastError, site.Type, site.StatusCode, strings.HasPrefix(site.URL, "https")) + if len(chain) > 0 { + b.WriteString("\n") + for _, step := range chain { + var icon string + switch step.Status { + case stepPassed: + icon = specialStyle.Render("✓") + case stepFailed: + icon = dangerStyle.Render("✗") + case stepSkipped: + icon = subtleStyle.Render("·") + } + line := fmt.Sprintf(" %s %-16s", icon, step.Name) + if step.Detail != "" { + switch step.Status { + case stepFailed: + line += " " + dangerStyle.Render(step.Detail) + case stepSkipped: + line += " " + subtleStyle.Render(step.Detail) + } + } + b.WriteString(line + "\n") + } + } + } + if !site.StatusChangedAt.IsZero() { dur := time.Since(site.StatusChangedAt) row("State Since", site.StatusChangedAt.Format("2006-01-02 15:04:05")+" ("+fmtDuration(dur)+")")