feat: logs tab overhaul — severity tags, filtering, recovery durations
Logs tab visual overhaul:
- Severity-classified entries: DOWN (red), UP (green), WARN (amber),
SYS (cyan), info (gray) — rendered as inline tags, not whole-line color
- Column-aligned format: [timestamp] [severity tag] [message]
- Filter toggle (f key): All vs Important only (hides retry noise)
- Header shows entry count, filter state, hidden count
Engine log improvements:
- Recovery messages include downtime duration ("was down 14m")
- LATE transition logged ("heartbeat overdue")
- Push monitor recovery includes downtime duration
This commit is contained in:
@@ -96,6 +96,19 @@ func sanitizeLog(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
func fmtDurationShort(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
|
||||
}
|
||||
return fmt.Sprintf("%dd %dh", int(d.Hours())/24, int(d.Hours())%24)
|
||||
}
|
||||
|
||||
func (e *Engine) AddLog(msg string) {
|
||||
e.logMu.Lock()
|
||||
defer e.logMu.Unlock()
|
||||
@@ -206,8 +219,12 @@ func (e *Engine) RecordHeartbeat(token string) bool {
|
||||
case "LATE":
|
||||
e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was late)", site.Name))
|
||||
case "DOWN":
|
||||
e.AddLog(fmt.Sprintf("Push Monitor '%s' recovered", site.Name))
|
||||
go e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.", site.Name))
|
||||
downDur := ""
|
||||
if !site.StatusChangedAt.IsZero() {
|
||||
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(site.StatusChangedAt)))
|
||||
}
|
||||
e.AddLog(fmt.Sprintf("Push Monitor '%s' recovered%s", site.Name, downDur))
|
||||
go e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.%s", site.Name, downDur))
|
||||
}
|
||||
|
||||
if prevStatus != "UP" && prevStatus != "PENDING" {
|
||||
@@ -514,6 +531,11 @@ func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int
|
||||
}
|
||||
|
||||
isBroken := func(s string) bool { return s == "DOWN" || s == "SSL EXP" }
|
||||
|
||||
if site.Status == "UP" && newState.Status == "LATE" {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat overdue", site.Name))
|
||||
}
|
||||
|
||||
if !isBroken(site.Status) && isBroken(newState.Status) && newState.Status != "PENDING" {
|
||||
if inMaint {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed — maintenance)", site.Name))
|
||||
@@ -529,11 +551,17 @@ func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int
|
||||
}
|
||||
}
|
||||
if isBroken(site.Status) && newState.Status == "UP" {
|
||||
if !inMaint {
|
||||
e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP", site.Name))
|
||||
} else {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' recovered (maintenance active, alert suppressed)", site.Name))
|
||||
downDur := ""
|
||||
if !site.StatusChangedAt.IsZero() {
|
||||
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(site.StatusChangedAt)))
|
||||
}
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' recovered%s", site.Name, downDur))
|
||||
if !inMaint {
|
||||
e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP%s", site.Name, downDur))
|
||||
}
|
||||
}
|
||||
if site.Status == "LATE" && newState.Status == "UP" && !isBroken(site.Status) {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat arrived (was late)", site.Name))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+88
-20
@@ -5,27 +5,83 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func colorizeLog(line string) string {
|
||||
type logSeverity int
|
||||
|
||||
const (
|
||||
severityInfo logSeverity = iota
|
||||
severityWarn
|
||||
severityDown
|
||||
severityUp
|
||||
severitySystem
|
||||
)
|
||||
|
||||
func classifyLog(line string) logSeverity {
|
||||
lower := strings.ToLower(line)
|
||||
switch {
|
||||
case strings.Contains(lower, "confirmed down"),
|
||||
strings.Contains(lower, "is down"),
|
||||
strings.Contains(lower, "missed heartbeat"),
|
||||
strings.Contains(lower, "failed check"),
|
||||
strings.Contains(lower, "ssl warning"):
|
||||
return dangerStyle.Render(line)
|
||||
strings.Contains(lower, "alert send failed"):
|
||||
return severityDown
|
||||
case strings.Contains(lower, "recovered"),
|
||||
strings.Contains(lower, "is up"),
|
||||
strings.Contains(lower, "recovery"):
|
||||
return specialStyle.Render(line)
|
||||
strings.Contains(lower, "recovery"),
|
||||
strings.Contains(lower, "first heartbeat"):
|
||||
return severityUp
|
||||
case strings.Contains(lower, "failed check"),
|
||||
strings.Contains(lower, "ssl warning"),
|
||||
strings.Contains(lower, "overdue"),
|
||||
strings.Contains(lower, "was late"):
|
||||
return severityWarn
|
||||
case strings.Contains(lower, "engine"),
|
||||
strings.Contains(lower, "cluster"):
|
||||
return titleStyle.Render(line)
|
||||
strings.Contains(lower, "cluster"),
|
||||
strings.Contains(lower, "loaded"),
|
||||
strings.Contains(lower, "paused"),
|
||||
strings.Contains(lower, "resumed"):
|
||||
return severitySystem
|
||||
default:
|
||||
return line
|
||||
return severityInfo
|
||||
}
|
||||
}
|
||||
|
||||
func isImportantLog(sev logSeverity) bool {
|
||||
return sev == severityDown || sev == severityUp || sev == severitySystem
|
||||
}
|
||||
|
||||
func renderLogTag(sev logSeverity) string {
|
||||
switch sev {
|
||||
case severityDown:
|
||||
return dangerStyle.Render(" DOWN ")
|
||||
case severityUp:
|
||||
return specialStyle.Render(" UP ")
|
||||
case severityWarn:
|
||||
return warnStyle.Render(" WARN ")
|
||||
case severitySystem:
|
||||
return titleStyle.Render(" SYS ")
|
||||
default:
|
||||
return subtleStyle.Render(" info ")
|
||||
}
|
||||
}
|
||||
|
||||
func renderLogLine(line string) string {
|
||||
sev := classifyLog(line)
|
||||
tag := renderLogTag(sev)
|
||||
|
||||
ts := ""
|
||||
msg := line
|
||||
if len(line) > 10 && line[0] == '[' {
|
||||
if idx := strings.Index(line, "]"); idx > 0 && idx < 12 {
|
||||
ts = subtleStyle.Render(line[1:idx])
|
||||
msg = strings.TrimSpace(line[idx+1:])
|
||||
}
|
||||
}
|
||||
|
||||
if ts != "" {
|
||||
return fmt.Sprintf(" %s %s %s", ts, tag, msg)
|
||||
}
|
||||
return fmt.Sprintf(" %s %s", tag, msg)
|
||||
}
|
||||
|
||||
func (m Model) viewLogsTab() string {
|
||||
content := m.logViewport.View()
|
||||
if strings.TrimSpace(content) == "" || content == "Waiting for logs..." {
|
||||
@@ -33,22 +89,34 @@ func (m Model) viewLogsTab() string {
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
var colored []string
|
||||
var rendered []string
|
||||
total := 0
|
||||
shown := 0
|
||||
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
colored = append(colored, line)
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
colored = append(colored, colorizeLog(line))
|
||||
total++
|
||||
sev := classifyLog(line)
|
||||
if m.logFilterImportant && !isImportantLog(sev) {
|
||||
continue
|
||||
}
|
||||
shown++
|
||||
rendered = append(rendered, renderLogLine(line))
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, l := range lines {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
count++
|
||||
}
|
||||
filterLabel := "All"
|
||||
if m.logFilterImportant {
|
||||
filterLabel = "Important"
|
||||
}
|
||||
|
||||
header := subtleStyle.Render(fmt.Sprintf(" %d entries [↑/↓] Scroll [PgUp/PgDn] Page", count))
|
||||
return "\n" + header + "\n\n" + strings.Join(colored, "\n")
|
||||
header := subtleStyle.Render(fmt.Sprintf(
|
||||
" %d entries [↑/↓] Scroll [PgUp/PgDn] Page [f] Filter: %s", shown, filterLabel))
|
||||
|
||||
if m.logFilterImportant && shown < total {
|
||||
header += subtleStyle.Render(fmt.Sprintf(" (%d hidden)", total-shown))
|
||||
}
|
||||
|
||||
return "\n" + header + "\n\n" + strings.Join(rendered, "\n")
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ type Model struct {
|
||||
maintFormData *maintFormData
|
||||
|
||||
logViewport viewport.Model
|
||||
logFilterImportant bool
|
||||
isAdmin bool
|
||||
zones *zone.Manager
|
||||
|
||||
@@ -392,6 +393,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.filterMode = true
|
||||
return m, nil
|
||||
}
|
||||
case "f":
|
||||
if m.state == stateLogs {
|
||||
m.logFilterImportant = !m.logFilterImportant
|
||||
return m, nil
|
||||
}
|
||||
case "tab":
|
||||
m.switchTab(m.currentTab + 1)
|
||||
case "pgup", "pgdown":
|
||||
@@ -937,6 +943,8 @@ func (m Model) viewDashboard() string {
|
||||
switch m.currentTab {
|
||||
case 0:
|
||||
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [T]Theme [Tab]Switch [q]Quit"
|
||||
case 2:
|
||||
keys = "[f]Filter [T]Theme [Tab]Switch [q]Quit"
|
||||
case 4:
|
||||
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
|
||||
case 5:
|
||||
|
||||
Reference in New Issue
Block a user