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,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)+")")
|
||||
|
||||
Reference in New Issue
Block a user