feat(tui): classify error reasons on DOWN monitors
CI / test (pull_request) Successful in 2m30s
CI / lint (pull_request) Successful in 1m7s
CI / vulncheck (pull_request) Successful in 46s

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
This commit is contained in:
2026-06-03 16:33:12 -04:00
parent 5d362fdbe6
commit 3d7ab5a49e
6 changed files with 464 additions and 8 deletions
+199
View File
@@ -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
}
+186
View File
@@ -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)
}
})
}
+8 -2
View File
@@ -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)
+30
View File
@@ -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
+11 -5
View File
@@ -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,
+30 -1
View File
@@ -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)+")")