feat(tui): classify error reasons on DOWN monitors
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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user