diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 2db743f..e03b3fd 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -509,6 +509,7 @@ func (e *Engine) checkPush(site models.Site) { } overdue := site.LastCheck.Add(interval) + staleMark := overdue.Add(grace / 2) graceEnd := overdue.Add(grace) now := time.Now() @@ -516,6 +517,10 @@ func (e *Engine) checkPush(site models.Site) { if site.Status != "DOWN" { e.handleStatusChange(site, "DOWN", 0, 0, "heartbeat missed") } + } else if now.After(staleMark) { + if site.Status != "STALE" { + e.handleStatusChange(site, "STALE", 0, 0, "heartbeat stale") + } } else if now.After(overdue) { if site.Status != "LATE" { e.handleStatusChange(site, "LATE", 0, 0, "heartbeat overdue") diff --git a/internal/monitor/monitor_test.go b/internal/monitor/monitor_test.go index 3a39a31..b62e433 100644 --- a/internal/monitor/monitor_test.go +++ b/internal/monitor/monitor_test.go @@ -571,6 +571,26 @@ func TestCheckPush_OverdueBecomesLate(t *testing.T) { } } +func TestCheckPush_OverdueBecomesStale(t *testing.T) { + ms := newMockStore() + e := newTestEngine(ms) + // interval=300, grace=150 (300/2), staleMark=overdue+75 + // at 380s: past staleMark(375) but before graceEnd(450) + site := models.Site{ + ID: 1, Name: "push", Type: "push", Status: "UP", + Interval: 300, + LastCheck: time.Now().Add(-380 * time.Second), + } + injectSite(e, site) + + e.checkPush(site) + + s, _ := getSite(e, 1) + if s.Status != "STALE" { + t.Errorf("expected STALE when past midpoint of grace, got %s", s.Status) + } +} + func TestCheckPush_WithinDeadline(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) diff --git a/internal/tui/format.go b/internal/tui/format.go index a2e553d..f72834a 100644 --- a/internal/tui/format.go +++ b/internal/tui/format.go @@ -22,6 +22,8 @@ func siteOrder(s models.Site) int { switch s.Status { case "DOWN", "SSL EXP": return 0 + case "STALE": + return 1 case "LATE": return 1 case "PENDING": @@ -142,6 +144,8 @@ func fmtStatus(status string, paused bool, inMaint bool, errCategory ErrorCatego return dangerStyle.Render(status) case "LATE": return warnStyle.Render(status) + case "STALE": + return staleStyle.Render(status) case "PENDING": return subtleStyle.Render(status) default: diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 32b6548..0644ee3 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -158,7 +158,7 @@ func (m Model) viewSitesTab() string { name = limitStr(name, nameW-2) } - if (site.Status == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE") && site.LastError != "" { + if (site.Status == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE" || site.Status == "STALE") && site.LastError != "" { nameLen := len([]rune(name)) errSpace := nameW - nameLen - 3 if errSpace > 10 { diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 4c0af6b..f81834f 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -22,6 +22,7 @@ type Theme struct { // Semantic Success lipgloss.Color Warning lipgloss.Color + Stale lipgloss.Color Danger lipgloss.Color Info lipgloss.Color Accent lipgloss.Color @@ -54,6 +55,7 @@ var themeFlexokiDark = Theme{ Subtle: "#6F6E69", Success: "#879A39", Warning: "#D0A215", + Stale: "#DA702C", Danger: "#D14D41", Info: "#4385BE", Accent: "#3AA99F", @@ -74,6 +76,7 @@ var themeTokyoNight = Theme{ Subtle: "#565f89", Success: "#9ece6a", Warning: "#e0af68", + Stale: "#ff9e64", Danger: "#f7768e", Info: "#7aa2f7", Accent: "#7dcfff", @@ -94,6 +97,7 @@ var themeGruvbox = Theme{ Subtle: "#7c6f64", Success: "#b8bb26", Warning: "#fabd2f", + Stale: "#fe8019", Danger: "#fb4934", Info: "#83a598", Accent: "#8ec07c", @@ -114,6 +118,7 @@ var themeCatppuccinMocha = Theme{ Subtle: "#6c7086", Success: "#a6e3a1", Warning: "#f9e2af", + Stale: "#fab387", Danger: "#f38ba8", Info: "#89b4fa", Accent: "#94e2d5", @@ -134,6 +139,7 @@ var themeNord = Theme{ Subtle: "#4c566a", Success: "#a3be8c", Warning: "#ebcb8b", + Stale: "#d08770", Danger: "#bf616a", Info: "#81a1c1", Accent: "#88c0d0", diff --git a/internal/tui/tui.go b/internal/tui/tui.go index ab76f65..e2c0bc1 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -20,6 +20,7 @@ var ( subtleStyle lipgloss.Style specialStyle lipgloss.Style warnStyle lipgloss.Style + staleStyle lipgloss.Style dangerStyle lipgloss.Style titleStyle lipgloss.Style activeTab lipgloss.Style @@ -30,6 +31,7 @@ func applyTheme(t Theme) { subtleStyle = lipgloss.NewStyle().Foreground(t.Subtle) specialStyle = lipgloss.NewStyle().Foreground(t.Success) warnStyle = lipgloss.NewStyle().Foreground(t.Warning) + staleStyle = lipgloss.NewStyle().Foreground(t.Stale) dangerStyle = lipgloss.NewStyle().Foreground(t.Danger) titleStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true) activeTab = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, true, false).BorderForeground(t.Accent).Foreground(t.Accent).Bold(true).Padding(0, 1) diff --git a/internal/tui/view_detail.go b/internal/tui/view_detail.go index 340326d..2c11f95 100644 --- a/internal/tui/view_detail.go +++ b/internal/tui/view_detail.go @@ -43,7 +43,7 @@ func (m Model) viewDetailPanel() string { 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 != "" { + if (site.Status == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE" || site.Status == "STALE") && site.LastError != "" { errWidth := m.termWidth - chromePadH - 19 if errWidth < 30 { errWidth = 30 diff --git a/internal/tui/view_history.go b/internal/tui/view_history.go index 7811dae..5dcbbd7 100644 --- a/internal/tui/view_history.go +++ b/internal/tui/view_history.go @@ -119,9 +119,14 @@ func (m Model) buildHistoryContent() string { ts := sc.ChangedAt.Format("2006-01-02 15:04") arrow := subtleStyle.Render(sc.FromStatus) + " → " - if sc.ToStatus == "UP" { + switch sc.ToStatus { + case "UP": arrow += specialStyle.Render(sc.ToStatus) - } else { + case "LATE": + arrow += warnStyle.Render(sc.ToStatus) + case "STALE": + arrow += staleStyle.Render(sc.ToStatus) + default: arrow += dangerStyle.Render(sc.ToStatus) }