3d7ab5a49e
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
200 lines
5.0 KiB
Go
200 lines
5.0 KiB
Go
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
|
|
}
|