From 274f0081e2928c192e12a6dc6c1bd12426de2c73 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 11 Jun 2026 11:23:16 -0400 Subject: [PATCH] fix(tui): move theme styles onto the Model to end cross-session races applyTheme mutated ~18 package-global lipgloss styles while every SSH session's tea.Program read them concurrently from its own goroutine. Pressing T or opening a new connection raced other sessions' View and bled themes across users. Styles now live in an immutable per-Model struct built by newStyles; free formatter helpers that consumed the globals became Model methods. --- internal/tui/format.go | 62 +++++++++++------------ internal/tui/format_test.go | 11 ++-- internal/tui/sparkline.go | 40 +++++++-------- internal/tui/sparkline_test.go | 44 ++++++++-------- internal/tui/tab_alerts.go | 54 ++++++++++---------- internal/tui/tab_logs.go | 24 ++++----- internal/tui/tab_maint.go | 29 +++++------ internal/tui/tab_nodes.go | 22 ++++---- internal/tui/tab_sites.go | 30 ++++++----- internal/tui/tab_users.go | 6 +-- internal/tui/table_helpers.go | 22 +++----- internal/tui/tui.go | 59 +++++++++++++--------- internal/tui/update.go | 2 +- internal/tui/update_test.go | 2 + internal/tui/view_dashboard.go | 32 ++++++------ internal/tui/view_detail.go | 84 +++++++++++++++---------------- internal/tui/view_history.go | 42 ++++++++-------- internal/tui/view_history_test.go | 8 +-- internal/tui/view_sla.go | 50 +++++++++--------- 19 files changed, 311 insertions(+), 312 deletions(-) diff --git a/internal/tui/format.go b/internal/tui/format.go index b6c304f..660ba10 100644 --- a/internal/tui/format.go +++ b/internal/tui/format.go @@ -18,13 +18,13 @@ func (m Model) dividerWidth() int { } func (m Model) divider() string { - return " " + subtleStyle.Render(strings.Repeat("─", m.dividerWidth())) + return " " + m.st.subtleStyle.Render(strings.Repeat("─", m.dividerWidth())) } func (m Model) emptyState(message, hint string) string { content := message if hint != "" { - content += "\n\n" + subtleStyle.Render(hint) + content += "\n\n" + m.st.subtleStyle.Render(hint) } return "\n" + lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). @@ -81,10 +81,10 @@ func typeIcon(siteType string, collapsed bool) string { } } -func fmtLatency(d time.Duration) string { +func (m Model) fmtLatency(d time.Duration) string { ms := d.Milliseconds() if ms == 0 { - return subtleStyle.Render("—") + return m.st.subtleStyle.Render("—") } var s string if ms < 1000 { @@ -93,17 +93,17 @@ func fmtLatency(d time.Duration) string { s = fmt.Sprintf("%.1fs", float64(ms)/1000) } if ms < 200 { - return specialStyle.Render(s) + return m.st.specialStyle.Render(s) } if ms < 500 { - return warnStyle.Render(s) + return m.st.warnStyle.Render(s) } - return dangerStyle.Render(s) + return m.st.dangerStyle.Render(s) } -func fmtUptime(statuses []bool) string { +func (m Model) fmtUptime(statuses []bool) string { if len(statuses) == 0 { - return subtleStyle.Render("—") + return m.st.subtleStyle.Render("—") } up := 0 for _, s := range statuses { @@ -114,70 +114,70 @@ func fmtUptime(statuses []bool) string { pct := float64(up) / float64(len(statuses)) * 100 s := fmt.Sprintf("%.1f%%", pct) if pct >= 99 { - return specialStyle.Render(s) + return m.st.specialStyle.Render(s) } if pct >= 95 { - return warnStyle.Render(s) + return m.st.warnStyle.Render(s) } - return dangerStyle.Render(s) + return m.st.dangerStyle.Render(s) } -func fmtSSL(site models.Site) string { +func (m Model) fmtSSL(site models.Site) string { if site.Type != "http" || !site.CheckSSL || !site.HasSSL { - return subtleStyle.Render("-") + return m.st.subtleStyle.Render("-") } days := int(time.Until(site.CertExpiry).Hours() / 24) s := fmt.Sprintf("%dd", days) if days <= 0 { - return dangerStyle.Render("EXPIRED") + return m.st.dangerStyle.Render("EXPIRED") } if days <= site.ExpiryThreshold { - return warnStyle.Render(s) + return m.st.warnStyle.Render(s) } - return specialStyle.Render(s) + return m.st.specialStyle.Render(s) } -func fmtRetries(site models.Site) string { +func (m Model) fmtRetries(site models.Site) string { dispCount := site.FailureCount if dispCount > site.MaxRetries { dispCount = site.MaxRetries } s := fmt.Sprintf("%d/%d", dispCount, site.MaxRetries) if site.Status == "DOWN" { - return dangerStyle.Render(s) + return m.st.dangerStyle.Render(s) } if site.Status == "UP" && site.FailureCount > 0 { - return warnStyle.Render(s) + return m.st.warnStyle.Render(s) } return s } -func fmtStatus(status string, paused bool, inMaint bool) string { +func (m Model) fmtStatus(status string, paused bool, inMaint bool) string { if paused { - return warnStyle.Render("◇ PAUSED") + return m.st.warnStyle.Render("◇ PAUSED") } if inMaint { - return maintStyle.Render("◼ MAINT") + return m.st.maintStyle.Render("◼ MAINT") } switch status { case "DOWN": - return dangerStyle.Render("▼ DOWN") + return m.st.dangerStyle.Render("▼ DOWN") case "SSL EXP": - return dangerStyle.Render("▼ SSL EXP") + return m.st.dangerStyle.Render("▼ SSL EXP") case "LATE": - return warnStyle.Render("◆ LATE") + return m.st.warnStyle.Render("◆ LATE") case "STALE": - return staleStyle.Render("◆ STALE") + return m.st.staleStyle.Render("◆ STALE") case "PENDING": - return subtleStyle.Render("○ PENDING") + return m.st.subtleStyle.Render("○ PENDING") default: - return specialStyle.Render("▲ " + status) + return m.st.specialStyle.Render("▲ " + status) } } -func fmtTimeAgo(t time.Time) string { +func (m Model) fmtTimeAgo(t time.Time) string { if t.IsZero() { - return subtleStyle.Render("never") + return m.st.subtleStyle.Render("never") } d := time.Since(t) if d < time.Minute { diff --git a/internal/tui/format_test.go b/internal/tui/format_test.go index 74fed89..9ee8d36 100644 --- a/internal/tui/format_test.go +++ b/internal/tui/format_test.go @@ -7,9 +7,8 @@ import ( "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" ) -func init() { - applyTheme(themeFlexokiDark) -} +// styledModel carries a default-theme styles instance for render-helper tests. +var styledModel = Model{st: newStyles(themeFlexokiDark)} func TestLimitStr(t *testing.T) { tests := []struct { @@ -72,7 +71,7 @@ func TestFmtStatus(t *testing.T) { {"DOWN", false, true, "◼ MAINT"}, } for _, tt := range tests { - got := fmtStatus(tt.status, tt.paused, tt.inMaint) + got := styledModel.fmtStatus(tt.status, tt.paused, tt.inMaint) if !containsPlain(got, tt.wantSub) { t.Errorf("fmtStatus(%q, paused=%v, maint=%v): %q missing %q", tt.status, tt.paused, tt.inMaint, got, tt.wantSub) @@ -136,7 +135,7 @@ func TestFmtUptime(t *testing.T) { {"all down", []bool{false, false}, "0.0%"}, } for _, tt := range tests { - got := fmtUptime(tt.statuses) + got := styledModel.fmtUptime(tt.statuses) if !containsPlain(got, tt.wantSub) { t.Errorf("fmtUptime(%s): %q missing %q", tt.name, got, tt.wantSub) } @@ -154,7 +153,7 @@ func TestFmtLatency(t *testing.T) { {1500 * time.Millisecond, "1.5s"}, } for _, tt := range tests { - got := fmtLatency(tt.d) + got := styledModel.fmtLatency(tt.d) if !containsPlain(got, tt.wantSub) { t.Errorf("fmtLatency(%v): %q missing %q", tt.d, got, tt.wantSub) } diff --git a/internal/tui/sparkline.go b/internal/tui/sparkline.go index 6c74512..79b1fe6 100644 --- a/internal/tui/sparkline.go +++ b/internal/tui/sparkline.go @@ -34,18 +34,18 @@ func withBg(s lipgloss.Style, bg lipgloss.Color) lipgloss.Style { return s } -func latencyStyle(ms int64, bg lipgloss.Color) lipgloss.Style { +func (m Model) latencyStyle(ms int64, bg lipgloss.Color) lipgloss.Style { var hex string var t float64 switch { case ms < 200: - hex = sparkSuccess + hex = m.st.sparkSuccess t = float64(ms) / 200 case ms < 500: - hex = sparkWarning + hex = m.st.sparkWarning t = float64(ms-200) / 300 default: - hex = sparkDanger + hex = m.st.sparkDanger t = float64(ms-500) / 1500 if t > 1 { t = 1 @@ -55,9 +55,9 @@ func latencyStyle(ms int64, bg lipgloss.Color) lipgloss.Style { return withBg(s, bg) } -func latencySparkline(latencies []time.Duration, statuses []bool, width int, bg lipgloss.Color) string { +func (m Model) latencySparkline(latencies []time.Duration, statuses []bool, width int, bg lipgloss.Color) string { if len(latencies) == 0 { - return withBg(subtleStyle, bg).Render(strings.Repeat("·", width)) + return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width)) } samples := latencies @@ -82,7 +82,7 @@ func latencySparkline(latencies []time.Duration, statuses []bool, width int, bg var sb strings.Builder if remaining := width - len(samples); remaining > 0 { - sb.WriteString(withBg(subtleStyle, bg).Render(strings.Repeat("·", remaining))) + sb.WriteString(withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", remaining))) } for i, l := range samples { idx := 0 @@ -95,17 +95,17 @@ func latencySparkline(latencies []time.Duration, statuses []bool, width int, bg ch := string(sparkChars[idx]) isDown := i < len(sampledStatuses) && !sampledStatuses[i] if isDown { - sb.WriteString(withBg(dangerStyle, bg).Render(ch)) + sb.WriteString(withBg(m.st.dangerStyle, bg).Render(ch)) } else { - sb.WriteString(latencyStyle(l.Milliseconds(), bg).Render(ch)) + sb.WriteString(m.latencyStyle(l.Milliseconds(), bg).Render(ch)) } } return sb.String() } -func heartbeatSparkline(statuses []bool, width int, bg lipgloss.Color) string { +func (m Model) heartbeatSparkline(statuses []bool, width int, bg lipgloss.Color) string { if len(statuses) == 0 { - return withBg(subtleStyle, bg).Render(strings.Repeat("·", width)) + return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width)) } samples := statuses @@ -115,13 +115,13 @@ func heartbeatSparkline(statuses []bool, width int, bg lipgloss.Color) string { var sb strings.Builder if remaining := width - len(samples); remaining > 0 { - sb.WriteString(withBg(subtleStyle, bg).Render(strings.Repeat("·", remaining))) + sb.WriteString(withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", remaining))) } for _, up := range samples { if up { - sb.WriteString(withBg(specialStyle, bg).Render("▁")) + sb.WriteString(withBg(m.st.specialStyle, bg).Render("▁")) } else { - sb.WriteString(withBg(dangerStyle, bg).Render("█")) + sb.WriteString(withBg(m.st.dangerStyle, bg).Render("█")) } } return sb.String() @@ -156,7 +156,7 @@ func (m Model) groupSparkline(groupID int, width int, bg lipgloss.Color) string } if len(childStatuses) == 0 { - return withBg(subtleStyle, bg).Render(strings.Repeat("·", width)) + return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width)) } maxLen := 0 @@ -184,13 +184,13 @@ func (m Model) groupSparkline(groupID int, width int, bg lipgloss.Color) string var sb strings.Builder if remaining := width - len(aggregated); remaining > 0 { - sb.WriteString(withBg(subtleStyle, bg).Render(strings.Repeat("·", remaining))) + sb.WriteString(withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", remaining))) } for _, up := range aggregated { if up { - sb.WriteString(withBg(subtleStyle, bg).Render("·")) + sb.WriteString(withBg(m.st.subtleStyle, bg).Render("·")) } else { - sb.WriteString(withBg(dangerStyle, bg).Render("•")) + sb.WriteString(withBg(m.st.dangerStyle, bg).Render("•")) } } return sb.String() @@ -208,7 +208,7 @@ func (m Model) groupUptime(groupID int) string { } } if len(allStatuses) == 0 { - return subtleStyle.Render("—") + return m.st.subtleStyle.Render("—") } total, up := 0, 0 for _, statuses := range allStatuses { @@ -219,7 +219,7 @@ func (m Model) groupUptime(groupID int) string { } } } - return fmtUptime(func() []bool { + return m.fmtUptime(func() []bool { out := make([]bool, total) idx := 0 for _, statuses := range allStatuses { diff --git a/internal/tui/sparkline_test.go b/internal/tui/sparkline_test.go index ef80a66..40ffe45 100644 --- a/internal/tui/sparkline_test.go +++ b/internal/tui/sparkline_test.go @@ -8,7 +8,7 @@ import ( ) func TestLatencySparkline_Empty(t *testing.T) { - got := latencySparkline(nil, nil, 10, "") + got := styledModel.latencySparkline(nil, nil, 10, "") if !strings.Contains(got, "··········") { t.Errorf("empty sparkline should be dots, got %q", got) } @@ -17,7 +17,7 @@ func TestLatencySparkline_Empty(t *testing.T) { func TestLatencySparkline_SingleValue(t *testing.T) { latencies := []time.Duration{100 * time.Millisecond} statuses := []bool{true} - got := latencySparkline(latencies, statuses, 5, "") + got := styledModel.latencySparkline(latencies, statuses, 5, "") if len(got) == 0 { t.Error("sparkline should not be empty") } @@ -33,7 +33,7 @@ func TestLatencySparkline_WidthTruncation(t *testing.T) { latencies[i] = time.Duration(i*50) * time.Millisecond statuses[i] = true } - got := latencySparkline(latencies, statuses, 5, "") + got := styledModel.latencySparkline(latencies, statuses, 5, "") if len(got) == 0 { t.Error("sparkline should not be empty") } @@ -45,7 +45,7 @@ func TestLatencySparkline_WidthTruncation(t *testing.T) { func TestLatencySparkline_RelativeHeight(t *testing.T) { latencies := []time.Duration{10 * time.Millisecond, 50 * time.Millisecond, 10 * time.Millisecond} statuses := []bool{true, true, true} - out := stripANSI(latencySparkline(latencies, statuses, 3, "")) + out := stripANSI(styledModel.latencySparkline(latencies, statuses, 3, "")) runes := []rune(out) if len(runes) < 3 { t.Fatalf("expected 3 runes, got %d", len(runes)) @@ -56,18 +56,15 @@ func TestLatencySparkline_RelativeHeight(t *testing.T) { } func TestLatencyStyle_BandsProduceDifferentColors(t *testing.T) { - sparkSuccess = "#00ff00" - sparkWarning = "#ffff00" - sparkDanger = "#ff0000" - defer func() { - sparkSuccess = "" - sparkWarning = "" - sparkDanger = "" - }() + st := newStyles(themeFlexokiDark) + st.sparkSuccess = "#00ff00" + st.sparkWarning = "#ffff00" + st.sparkDanger = "#ff0000" + m := Model{st: st} - green := latencyStyle(50, "") - yellow := latencyStyle(300, "") - red := latencyStyle(800, "") + green := m.latencyStyle(50, "") + yellow := m.latencyStyle(300, "") + red := m.latencyStyle(800, "") gfg := green.GetForeground() yfg := yellow.GetForeground() @@ -79,11 +76,12 @@ func TestLatencyStyle_BandsProduceDifferentColors(t *testing.T) { } func TestLatencyStyle_BrightnessVariesWithinBand(t *testing.T) { - sparkSuccess = "#00ff00" - defer func() { sparkSuccess = "" }() + st := newStyles(themeFlexokiDark) + st.sparkSuccess = "#00ff00" + m := Model{st: st} - dim := latencyStyle(10, "") - bright := latencyStyle(190, "") + dim := m.latencyStyle(10, "") + bright := m.latencyStyle(190, "") if dim.GetForeground() == bright.GetForeground() { t.Error("10ms and 190ms should have different brightness within green band") @@ -93,7 +91,7 @@ func TestLatencyStyle_BrightnessVariesWithinBand(t *testing.T) { func TestLatencySparkline_OutputWidth(t *testing.T) { latencies := []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 300 * time.Millisecond} statuses := []bool{true, true, true} - got := latencySparkline(latencies, statuses, 5, "") + got := styledModel.latencySparkline(latencies, statuses, 5, "") count := utf8.RuneCountInString(stripANSI(got)) if count != 5 { t.Errorf("expected 5 rune-width output, got %d from %q", count, got) @@ -118,7 +116,7 @@ func stripANSI(s string) string { } func TestHeartbeatSparkline_Empty(t *testing.T) { - got := heartbeatSparkline(nil, 10, "") + got := styledModel.heartbeatSparkline(nil, 10, "") if !strings.Contains(got, "··········") { t.Errorf("empty heartbeat should be dots, got %q", got) } @@ -126,7 +124,7 @@ func TestHeartbeatSparkline_Empty(t *testing.T) { func TestHeartbeatSparkline_Mixed(t *testing.T) { statuses := []bool{true, false, true, true, false} - got := heartbeatSparkline(statuses, 5, "") + got := styledModel.heartbeatSparkline(statuses, 5, "") if len(got) == 0 { t.Error("heartbeat sparkline should not be empty") } @@ -134,7 +132,7 @@ func TestHeartbeatSparkline_Mixed(t *testing.T) { func TestHeartbeatSparkline_PaddedWidth(t *testing.T) { statuses := []bool{true, true} - got := heartbeatSparkline(statuses, 5, "") + got := styledModel.heartbeatSparkline(statuses, 5, "") if !strings.Contains(got, "···") { t.Errorf("should have dot padding for width > data, got %q", got) } diff --git a/internal/tui/tab_alerts.go b/internal/tui/tab_alerts.go index 1249fbb..7fd7ef8 100644 --- a/internal/tui/tab_alerts.go +++ b/internal/tui/tab_alerts.go @@ -71,7 +71,7 @@ func fmtAlertType(t string) string { } } -func fmtAlertConfig(alert struct { +func (m Model) fmtAlertConfig(alert struct { Type string Settings map[string]string }) string { @@ -85,34 +85,34 @@ func fmtAlertConfig(alert struct { if host != "" { return limitStr(host, 34) } - return subtleStyle.Render("—") + return m.st.subtleStyle.Render("—") case "ntfy": topic := alert.Settings["topic"] url := alert.Settings["url"] if url != "" && topic != "" { return limitStr(fmt.Sprintf("%s/%s", url, topic), 34) } - return subtleStyle.Render("—") + return m.st.subtleStyle.Render("—") case "telegram": if id := alert.Settings["chat_id"]; id != "" { return limitStr(fmt.Sprintf("chat:%s", id), 34) } - return subtleStyle.Render("—") + return m.st.subtleStyle.Render("—") case "pagerduty": if key := alert.Settings["routing_key"]; key != "" { return limitStr(key, 34) } - return subtleStyle.Render("—") + return m.st.subtleStyle.Render("—") case "pushover": if user := alert.Settings["user"]; user != "" { return limitStr(fmt.Sprintf("user:%s", user), 34) } - return subtleStyle.Render("—") + return m.st.subtleStyle.Render("—") case "gotify": if url := alert.Settings["url"]; url != "" { return limitStr(url, 34) } - return subtleStyle.Render("—") + return m.st.subtleStyle.Render("—") case "opsgenie": key := alert.Settings["api_key"] if key != "" { @@ -125,27 +125,27 @@ func fmtAlertConfig(alert struct { } return limitStr(masked, 34) } - return subtleStyle.Render("—") + return m.st.subtleStyle.Render("—") default: if val, ok := alert.Settings["url"]; ok { return limitStr(val, 34) } - return subtleStyle.Render("—") + return m.st.subtleStyle.Render("—") } } -func fmtAlertHealth(h monitor.AlertHealth) string { +func (m Model) fmtAlertHealth(h monitor.AlertHealth) string { if h.LastSendAt.IsZero() { - return subtleStyle.Render("●") + return m.st.subtleStyle.Render("●") } if h.LastSendOK { - return specialStyle.Render("●") + return m.st.specialStyle.Render("●") } - return dangerStyle.Render("●") + return m.st.dangerStyle.Render("●") } -func fmtAlertLastSent(h monitor.AlertHealth) string { - return fmtTimeAgo(h.LastSendAt) +func (m Model) fmtAlertLastSent(h monitor.AlertHealth) string { + return m.fmtTimeAgo(h.LastSendAt) } func (m Model) viewAlertsTab() string { @@ -175,14 +175,14 @@ func (m Model) viewAlertsTab() string { h := m.engine.GetAlertHealth(a.ID) rows = append(rows, []string{ fmt.Sprintf("%d", i+1), - fmtAlertHealth(h), + m.fmtAlertHealth(h), m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(a.Name, nameW-2)), fmtAlertType(a.Type), - limitStr(fmtAlertConfig(struct { + limitStr(m.fmtAlertConfig(struct { Type string Settings map[string]string }{a.Type, a.Settings}), cfgW-2), - fmtAlertLastSent(h), + m.fmtAlertLastSent(h), }) } return rows @@ -200,41 +200,41 @@ func (m Model) viewAlertDetailPanel() string { var b strings.Builder - b.WriteString(subtleStyle.Render(" Alerts > ") + titleStyle.Render(a.Name) + "\n") + b.WriteString(m.st.subtleStyle.Render(" Alerts > ") + m.st.titleStyle.Render(a.Name) + "\n") b.WriteString(m.divider() + "\n") row := func(label, value string) { - fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render(label), value) + fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render(label), value) } row("Type", fmtAlertType(a.Type)) if h.LastSendAt.IsZero() { - row("Health", subtleStyle.Render("never sent")) + row("Health", m.st.subtleStyle.Render("never sent")) } else if h.LastSendOK { - row("Health", specialStyle.Render("OK")) + row("Health", m.st.specialStyle.Render("OK")) } else { - row("Health", dangerStyle.Render("FAILED")) + row("Health", m.st.dangerStyle.Render("FAILED")) } if !h.LastSendAt.IsZero() { - row("Last Sent", h.LastSendAt.Format("2006-01-02 15:04:05")+" ("+fmtAlertLastSent(h)+")") + row("Last Sent", h.LastSendAt.Format("2006-01-02 15:04:05")+" ("+m.fmtAlertLastSent(h)+")") } if h.SendCount > 0 { row("Sends", fmt.Sprintf("%d sent, %d failed", h.SendCount, h.FailCount)) } if h.LastError != "" { - row("Last Error", dangerStyle.Render(limitStr(h.LastError, 60))) + row("Last Error", m.st.dangerStyle.Render(limitStr(h.LastError, 60))) } b.WriteString(m.divider() + "\n") - b.WriteString(subtleStyle.Render(" CONFIGURATION") + "\n") + b.WriteString(m.st.subtleStyle.Render(" CONFIGURATION") + "\n") for k, v := range a.Settings { row(k, v) } b.WriteString(m.divider() + "\n") - b.WriteString(subtleStyle.Render(" [i/Esc] Back [e] Edit [t] Test [q] Quit")) + b.WriteString(m.st.subtleStyle.Render(" [i/Esc] Back [e] Edit [t] Test [q] Quit")) return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) } diff --git a/internal/tui/tab_logs.go b/internal/tui/tab_logs.go index 6dd439b..480e8f3 100644 --- a/internal/tui/tab_logs.go +++ b/internal/tui/tab_logs.go @@ -48,30 +48,30 @@ func isImportantLog(sev logSeverity) bool { return sev == severityDown || sev == severityUp || sev == severitySystem } -func renderLogTag(sev logSeverity) string { +func (m Model) renderLogTag(sev logSeverity) string { switch sev { case severityDown: - return dangerStyle.Render(" DOWN ") + return m.st.dangerStyle.Render(" DOWN ") case severityUp: - return specialStyle.Render(" UP ") + return m.st.specialStyle.Render(" UP ") case severityWarn: - return warnStyle.Render(" WARN ") + return m.st.warnStyle.Render(" WARN ") case severitySystem: - return titleStyle.Render(" SYS ") + return m.st.titleStyle.Render(" SYS ") default: - return subtleStyle.Render(" info ") + return m.st.subtleStyle.Render(" info ") } } -func renderLogLine(line string) string { +func (m Model) renderLogLine(line string) string { sev := classifyLog(line) - tag := renderLogTag(sev) + tag := m.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]) + ts = m.st.subtleStyle.Render(line[1:idx]) msg = strings.TrimSpace(line[idx+1:]) } } @@ -103,7 +103,7 @@ func (m Model) viewLogsTab() string { continue } shown++ - rendered = append(rendered, renderLogLine(line)) + rendered = append(rendered, m.renderLogLine(line)) } filterLabel := "All" @@ -111,11 +111,11 @@ func (m Model) viewLogsTab() string { filterLabel = "Important" } - header := subtleStyle.Render(fmt.Sprintf( + header := m.st.subtleStyle.Render(fmt.Sprintf( " %d entries Filter: %s", shown, filterLabel)) if m.logFilterImportant && shown < total { - header += subtleStyle.Render(fmt.Sprintf(" (%d hidden)", total-shown)) + header += m.st.subtleStyle.Render(fmt.Sprintf(" (%d hidden)", total-shown)) } m.logViewport.SetContent(strings.Join(rendered, "\n")) diff --git a/internal/tui/tab_maint.go b/internal/tui/tab_maint.go index 1923f3a..4ba4747 100644 --- a/internal/tui/tab_maint.go +++ b/internal/tui/tab_maint.go @@ -9,11 +9,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" ) -var maintStyle lipgloss.Style - type maintFormData struct { Title string Description string @@ -23,22 +20,22 @@ type maintFormData struct { CustomHours string } -func fmtMaintStatus(mw models.MaintenanceWindow) string { +func (m Model) fmtMaintStatus(mw models.MaintenanceWindow) string { now := time.Now() if mw.StartTime.After(now) { - return warnStyle.Render("SCHEDULED") + return m.st.warnStyle.Render("SCHEDULED") } if !mw.EndTime.IsZero() && mw.EndTime.Before(now) { - return subtleStyle.Render("ENDED") + return m.st.subtleStyle.Render("ENDED") } - return specialStyle.Render("ACTIVE") + return m.st.specialStyle.Render("ACTIVE") } -func fmtMaintType(t string) string { +func (m Model) fmtMaintType(t string) string { if t == "incident" { - return dangerStyle.Render("incident") + return m.st.dangerStyle.Render("incident") } - return maintStyle.Render("maintenance") + return m.st.maintStyle.Render("maintenance") } func fmtMaintMonitorW(monitorID int, sites []models.Site, maxW int) string { @@ -53,9 +50,9 @@ func fmtMaintMonitorW(monitorID int, sites []models.Site, maxW int) string { return fmt.Sprintf("#%d", monitorID) } -func fmtMaintTime(t time.Time, colW int) string { +func (m Model) fmtMaintTime(t time.Time, colW int) string { if t.IsZero() { - return subtleStyle.Render("—") + return m.st.subtleStyle.Render("—") } now := time.Now() if t.Year() == now.Year() && t.YearDay() == now.YearDay() { @@ -120,11 +117,11 @@ func (m Model) viewMaintTab() string { rows = append(rows, []string{ strconv.Itoa(i + 1), m.zones.Mark(fmt.Sprintf("maint-%d", i), limitStr(mw.Title, titleW-2)), - fmtMaintType(mw.Type), + m.fmtMaintType(mw.Type), fmtMaintMonitorW(mw.MonitorID, allSites, monW-2), - fmtMaintStatus(mw), - fmtMaintTime(mw.StartTime, timeW), - fmtMaintTime(mw.EndTime, timeW), + m.fmtMaintStatus(mw), + m.fmtMaintTime(mw.StartTime, timeW), + m.fmtMaintTime(mw.EndTime, timeW), }) } return rows diff --git a/internal/tui/tab_nodes.go b/internal/tui/tab_nodes.go index ac53828..49c8493 100644 --- a/internal/tui/tab_nodes.go +++ b/internal/tui/tab_nodes.go @@ -33,14 +33,14 @@ func (m Model) viewNodesTab() string { } region := node.Region if region == "" { - region = subtleStyle.Render("—") + region = m.st.subtleStyle.Render("—") } - lastSeen := fmtNodeLastSeen(node.LastSeen) + lastSeen := m.fmtNodeLastSeen(node.LastSeen) version := node.Version if version == "" { - version = subtleStyle.Render("—") + version = m.st.subtleStyle.Render("—") } - status := fmtNodeStatus(node.LastSeen) + status := m.fmtNodeStatus(node.LastSeen) rows = append(rows, []string{name, region, lastSeen, version, status}) } return rows @@ -50,20 +50,20 @@ func (m Model) viewNodesTab() string { ) } -func fmtNodeStatus(lastSeen time.Time) string { +func (m Model) fmtNodeStatus(lastSeen time.Time) string { if lastSeen.IsZero() { - return subtleStyle.Render("UNKNOWN") + return m.st.subtleStyle.Render("UNKNOWN") } ago := time.Since(lastSeen) if ago < 60*time.Second { - return specialStyle.Render("ONLINE") + return m.st.specialStyle.Render("ONLINE") } if ago < 5*time.Minute { - return warnStyle.Render("STALE") + return m.st.warnStyle.Render("STALE") } - return dangerStyle.Render("OFFLINE") + return m.st.dangerStyle.Render("OFFLINE") } -func fmtNodeLastSeen(t time.Time) string { - return fmtTimeAgo(t) +func (m Model) fmtNodeLastSeen(t time.Time) string { + return m.fmtTimeAgo(t) } diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index a78e54f..af9838e 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -11,8 +11,6 @@ import ( "github.com/charmbracelet/lipgloss" ) -var siteGroupStyle lipgloss.Style - type siteFormData struct { Name string SiteType string @@ -182,7 +180,7 @@ func pickCols(active []colKey, allCells map[colKey]string) []string { func (m Model) viewSitesTab() string { if len(m.sites) == 0 { - return m.emptyState(titleStyle.Render("uptop")+"\n\nNo monitors configured yet.", "[n] Add your first monitor") + return m.emptyState(m.st.titleStyle.Render("uptop")+"\n\nNo monitors configured yet.", "[n] Add your first monitor") } layout := m.computeLayout() @@ -219,12 +217,12 @@ func (m Model) viewSitesTab() string { colNum: strconv.Itoa(i + 1), colName: m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-4)), colType: "group", - colStatus: fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)), - colLatency: subtleStyle.Render("—"), + colStatus: m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)), + colLatency: m.st.subtleStyle.Render("—"), colUptime: m.groupUptime(site.ID), colHistory: m.groupSparkline(site.ID, sparkWidth, rowBg), - colSSL: subtleStyle.Render("-"), - colRetries: subtleStyle.Render("—"), + colSSL: m.st.subtleStyle.Render("-"), + colRetries: m.st.subtleStyle.Render("—"), } rows = append(rows, pickCols(layout.active, cells)) continue @@ -251,28 +249,28 @@ func (m Model) viewSitesTab() string { if tag != "" { errText = tag + " " + errText } - name = name + " " + subtleStyle.Render(limitStr(errText, errSpace)) + name = name + " " + m.st.subtleStyle.Render(limitStr(errText, errSpace)) } } hist, _ := m.engine.GetHistory(site.ID) var spark string if site.Type == "push" { - spark = heartbeatSparkline(hist.Statuses, sparkWidth, rowBg) + spark = m.heartbeatSparkline(hist.Statuses, sparkWidth, rowBg) } else { - spark = latencySparkline(hist.Latencies, hist.Statuses, sparkWidth, rowBg) + spark = m.latencySparkline(hist.Latencies, hist.Statuses, sparkWidth, rowBg) } cells := map[colKey]string{ colNum: strconv.Itoa(i + 1), colName: m.zones.Mark(fmt.Sprintf("site-%d", i), name), colType: typeIcon(site.Type, false) + " " + site.Type, - colStatus: fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)), - colLatency: fmtLatency(site.Latency), - colUptime: fmtUptime(hist.Statuses), + colStatus: m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)), + colLatency: m.fmtLatency(site.Latency), + colUptime: m.fmtUptime(hist.Statuses), colHistory: spark, - colSSL: fmtSSL(site), - colRetries: fmtRetries(site), + colSSL: m.fmtSSL(site), + colRetries: m.fmtRetries(site), } rows = append(rows, pickCols(layout.active, cells)) } @@ -281,7 +279,7 @@ func (m Model) viewSitesTab() string { layout.colWidths, func(row, col int) *lipgloss.Style { if groupRows[row] { - s := siteGroupStyle + s := m.st.siteGroupStyle return &s } return nil diff --git a/internal/tui/tab_users.go b/internal/tui/tab_users.go index b6967d8..eb8307b 100644 --- a/internal/tui/tab_users.go +++ b/internal/tui/tab_users.go @@ -13,9 +13,9 @@ type userFormData struct { Role string } -func fmtRole(role string) string { +func (m Model) fmtRole(role string) string { if role == "admin" { - return specialStyle.Render(role) + return m.st.specialStyle.Render(role) } return role } @@ -53,7 +53,7 @@ func (m Model) viewUsersTab() string { rows = append(rows, []string{ fmt.Sprintf("%d", i+1), m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, userW-2)), - fmtRole(u.Role), + m.fmtRole(u.Role), fmtKey(u.PublicKey), }) } diff --git a/internal/tui/table_helpers.go b/internal/tui/table_helpers.go index 37a8161..2c32182 100644 --- a/internal/tui/table_helpers.go +++ b/internal/tui/table_helpers.go @@ -5,14 +5,6 @@ import ( "github.com/charmbracelet/lipgloss/table" ) -var ( - tableHeaderStyle lipgloss.Style - tableCellStyle lipgloss.Style - tableSelectedStyle lipgloss.Style - tableBorderStyle lipgloss.Style - tableZebraStyle lipgloss.Style -) - type StyleOverride func(row, col int) *lipgloss.Style const ( @@ -53,13 +45,13 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en t := table.New(). Border(lipgloss.RoundedBorder()). - BorderStyle(tableBorderStyle). + BorderStyle(m.st.tableBorderStyle). Width(tableWidth). Headers(headers...). Rows(rows...). StyleFunc(func(row, col int) lipgloss.Style { if row == table.HeaderRow { - h := tableHeaderStyle + h := m.st.tableHeaderStyle if col < len(colWidths) && colWidths[col] > 0 { h = h.Width(colWidths[col]).MaxWidth(colWidths[col]) } @@ -70,10 +62,10 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en if s := styleOverride(row, col); s != nil { style := *s if row%2 == 1 { - style = style.Background(tableZebraStyle.GetBackground()) + style = style.Background(m.st.tableZebraStyle.GetBackground()) } if isSelected { - style = tableSelectedStyle.Foreground(s.GetForeground()) + style = m.st.tableSelectedStyle.Foreground(s.GetForeground()) } if col < len(colWidths) && colWidths[col] > 0 { style = style.Width(colWidths[col]).MaxWidth(colWidths[col]) @@ -81,12 +73,12 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en return style } } - base := tableCellStyle + base := m.st.tableCellStyle if row%2 == 1 { - base = tableZebraStyle + base = m.st.tableZebraStyle } if isSelected { - base = tableSelectedStyle + base = m.st.tableSelectedStyle } if col < len(colWidths) && colWidths[col] > 0 { base = base.Width(colWidths[col]).MaxWidth(colWidths[col]) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index acd06dd..8b5d4f3 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -16,7 +16,10 @@ import ( zone "github.com/lrstanley/bubblezone" ) -var ( +// styles holds every theme-derived lipgloss style. Each Model owns its own +// instance (built by newStyles), so concurrent SSH sessions can run different +// themes without racing on shared package state. Never mutate after creation. +type styles struct { subtleStyle lipgloss.Style specialStyle lipgloss.Style warnStyle lipgloss.Style @@ -29,31 +32,41 @@ var ( sparkSuccess string sparkWarning string sparkDanger string -) -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) + tableHeaderStyle lipgloss.Style + tableCellStyle lipgloss.Style + tableSelectedStyle lipgloss.Style + tableBorderStyle lipgloss.Style + tableZebraStyle lipgloss.Style - sparkSuccess = string(t.Success) - sparkWarning = string(t.Warning) - sparkDanger = string(t.Danger) + siteGroupStyle lipgloss.Style + maintStyle lipgloss.Style +} - titleStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true) - activeTab = lipgloss.NewStyle().Background(t.Surface).Foreground(t.Accent).Bold(true).Padding(0, 1) - inactiveTab = lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted) +func newStyles(t Theme) *styles { + return &styles{ + 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().Background(t.Surface).Foreground(t.Accent).Bold(true).Padding(0, 1), + inactiveTab: lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted), - tableHeaderStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Padding(0, 1) - tableCellStyle = lipgloss.NewStyle().Padding(0, 1) - tableSelectedStyle = lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.SelectedFg).Background(t.SelectedBg) - tableBorderStyle = lipgloss.NewStyle().Foreground(t.Border) - tableZebraStyle = lipgloss.NewStyle().Padding(0, 1).Background(t.ZebraBg) + sparkSuccess: string(t.Success), + sparkWarning: string(t.Warning), + sparkDanger: string(t.Danger), - siteGroupStyle = lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.Accent) - maintStyle = lipgloss.NewStyle().Foreground(t.Purple) + tableHeaderStyle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Padding(0, 1), + tableCellStyle: lipgloss.NewStyle().Padding(0, 1), + tableSelectedStyle: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.SelectedFg).Background(t.SelectedBg), + tableBorderStyle: lipgloss.NewStyle().Foreground(t.Border), + tableZebraStyle: lipgloss.NewStyle().Padding(0, 1).Background(t.ZebraBg), + + siteGroupStyle: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.Accent), + maintStyle: lipgloss.NewStyle().Foreground(t.Purple), + } } var pulseFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} @@ -128,6 +141,7 @@ type Model struct { engine *monitor.Engine theme Theme themeIndex int + st *styles // harmonica animation state pulseSpring harmonica.Spring @@ -174,8 +188,6 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri } } - applyTheme(theme) - return Model{ state: stateDashboard, logViewport: vpLogs, @@ -188,6 +200,7 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri collapsed: collapsed, theme: theme, themeIndex: themeIdx, + st: newStyles(theme), demoMode: os.Getenv("UPTOP_DEMO") == "1", version: version, sparkTooltipIdx: -1, diff --git a/internal/tui/update.go b/internal/tui/update.go index 235c842..089971a 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -542,7 +542,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "T": m.themeIndex = (m.themeIndex + 1) % len(themes) m.theme = themes[m.themeIndex] - applyTheme(m.theme) + m.st = newStyles(m.theme) _ = m.store.SetPreference("theme", m.theme.Name) case "d", "backspace": return m.handleDeleteItem() diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 43a13ba..5ee60b6 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -99,6 +99,8 @@ func newTestModel(ms *tuiMockStore) Model { isAdmin: true, zones: zone.New(), detailChangesSiteID: -1, + theme: themeFlexokiDark, + st: newStyles(themeFlexokiDark), } } diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 8ab4276..18fd814 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -54,8 +54,8 @@ func (m Model) View() string { case 5: kind = "user" } - msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName)) - hint := subtleStyle.Render("[y] Confirm [n] Cancel") + msg := m.st.dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName)) + hint := m.st.subtleStyle.Render("[y] Confirm [n] Cancel") box := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(m.theme.Danger). @@ -89,8 +89,8 @@ func (m Model) View() string { formHeight = 5 } m.huhForm.WithHeight(formHeight) - header := titleStyle.Render(title) - footer := subtleStyle.Render("\n[Esc] Cancel") + header := m.st.titleStyle.Render(title) + footer := m.st.subtleStyle.Render("\n[Esc] Cancel") return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer) } return "" @@ -216,16 +216,16 @@ func (m Model) renderTabBar(stats dashboardStats) string { if t.count > 0 { badge := countStyle.Render(fmt.Sprintf(" %d", t.count)) if t.warn > 0 { - badge = dangerStyle.Render(fmt.Sprintf(" %d", t.warn)) + badge = m.st.dangerStyle.Render(fmt.Sprintf(" %d", t.warn)) } label += badge } var rendered string if i == m.currentTab { - rendered = activeTab.Render(label) + rendered = m.st.activeTab.Render(label) } else { - rendered = inactiveTab.Render(label) + rendered = m.st.inactiveTab.Render(label) } renderedTabs = append(renderedTabs, m.zones.Mark(fmt.Sprintf("tab-%d", i), rendered)) } @@ -235,21 +235,21 @@ func (m Model) renderTabBar(stats dashboardStats) string { func (m Model) renderFooter(stats dashboardStats) string { if m.filterMode { cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│") - return "\n" + titleStyle.Render("/") + " " + m.filterText + cursor + " " + subtleStyle.Render("[Enter]Apply [Esc]Clear") + return "\n" + m.st.titleStyle.Render("/") + " " + m.filterText + cursor + " " + m.st.subtleStyle.Render("[Enter]Apply [Esc]Clear") } upCount := stats.totalMonitors - stats.downCount - stats.lateCount var upStr string if stats.downCount > 0 { - upStr = dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors)) + upStr = m.st.dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors)) } else if stats.lateCount > 0 { - upStr = warnStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors)) + upStr = m.st.warnStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors)) } else { - upStr = specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors)) + upStr = m.st.specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors)) } statusParts := []string{upStr} if stats.lateCount > 0 { - statusParts = append(statusParts, warnStyle.Render(fmt.Sprintf("%d LATE", stats.lateCount))) + statusParts = append(statusParts, m.st.warnStyle.Render(fmt.Sprintf("%d LATE", stats.lateCount))) } if len(m.nodes) > 0 { online := 0 @@ -264,7 +264,7 @@ func (m Model) renderFooter(stats dashboardStats) string { } statusParts = append(statusParts, fmt.Sprintf("%d %s", online, probeLabel)) } - statusLine := strings.Join(statusParts, subtleStyle.Render(" · ")) + statusLine := strings.Join(statusParts, m.st.subtleStyle.Render(" · ")) var keys string switch m.currentTab { @@ -282,10 +282,10 @@ func (m Model) renderFooter(stats dashboardStats) string { keys = "[T]Theme [Tab]Switch [q]Quit" } - ver := subtleStyle.Render("v" + m.version) - footer := statusLine + " " + subtleStyle.Render(keys) + " " + ver + ver := m.st.subtleStyle.Render("v" + m.version) + footer := statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver if m.filterText != "" && m.currentTab == 0 { - footer = subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + subtleStyle.Render(keys) + " " + ver + footer = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver } return footer } diff --git a/internal/tui/view_detail.go b/internal/tui/view_detail.go index 7a02d03..44c9abf 100644 --- a/internal/tui/view_detail.go +++ b/internal/tui/view_detail.go @@ -24,26 +24,26 @@ func (m Model) viewDetailPanel() string { if site.ParentID > 0 { for _, s := range m.sites { if s.ID == site.ParentID { - breadcrumb = subtleStyle.Render(" Sites > "+s.Name+" > ") + titleStyle.Render(site.Name) + breadcrumb = m.st.subtleStyle.Render(" Sites > "+s.Name+" > ") + m.st.titleStyle.Render(site.Name) break } } } if breadcrumb == "" { - breadcrumb = subtleStyle.Render(" Sites > ") + titleStyle.Render(site.Name) + breadcrumb = m.st.subtleStyle.Render(" Sites > ") + m.st.titleStyle.Render(site.Name) } b.WriteString(breadcrumb + "\n") b.WriteString(m.divider() + "\n") row := func(label, value string) { - fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render(label), value) + fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render(label), value) } section := func(label string) { - b.WriteString("\n" + subtleStyle.Render(" "+label) + "\n") + b.WriteString("\n" + m.st.subtleStyle.Render(" "+label) + "\n") } - row("Status", fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))) + row("Status", m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))) if (site.Status == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE" || site.Status == "STALE") && site.LastError != "" { errWidth := m.termWidth - chromePadH - 19 @@ -51,7 +51,7 @@ func (m Model) viewDetailPanel() string { errWidth = 30 } wrapped := lipgloss.NewStyle().Width(errWidth).Render(site.LastError) - row("Error", dangerStyle.Render(wrapped)) + row("Error", m.st.dangerStyle.Render(wrapped)) } if site.Type == "http" && site.StatusCode > 0 { @@ -66,19 +66,19 @@ func (m Model) viewDetailPanel() string { var icon string switch step.Status { case stepPassed: - icon = specialStyle.Render("✓") + icon = m.st.specialStyle.Render("✓") case stepFailed: - icon = dangerStyle.Render("✗") + icon = m.st.dangerStyle.Render("✗") case stepSkipped: - icon = subtleStyle.Render("·") + icon = m.st.subtleStyle.Render("·") } line := fmt.Sprintf(" %s %-16s", icon, step.Name) if step.Detail != "" { switch step.Status { case stepFailed: - line += " " + dangerStyle.Render(step.Detail) + line += " " + m.st.dangerStyle.Render(step.Detail) case stepSkipped: - line += " " + subtleStyle.Render(step.Detail) + line += " " + m.st.subtleStyle.Render(step.Detail) } } b.WriteString(line + "\n") @@ -99,7 +99,7 @@ func (m Model) viewDetailPanel() string { if m.isMonitorInMaintenance(site.ID) { for _, mw := range m.maintenanceWindows { if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) { - row("Maintenance", maintStyle.Render(mw.Title)) + row("Maintenance", m.st.maintStyle.Render(mw.Title)) break } } @@ -126,10 +126,10 @@ func (m Model) viewDetailPanel() string { if site.Timeout > 0 { row("Timeout", fmt.Sprintf("%ds", site.Timeout)) } - row("Latency", fmtLatency(site.Latency)) - row("Uptime", fmtUptime(hist.Statuses)) + row("Latency", m.fmtLatency(site.Latency)) + row("Uptime", m.fmtUptime(hist.Statuses)) if !site.LastCheck.IsZero() { - row("Last Check", fmtTimeAgo(site.LastCheck)) + row("Last Check", m.fmtTimeAgo(site.LastCheck)) } if site.Type == "http" { @@ -142,16 +142,16 @@ func (m Model) viewDetailPanel() string { codes = "200-299" } row("Codes", codes) - row("SSL", fmtSSL(site)) + row("SSL", m.fmtSSL(site)) if site.IgnoreTLS { - row("TLS Verify", dangerStyle.Render("disabled")) + row("TLS Verify", m.st.dangerStyle.Render("disabled")) } } if site.MaxRetries > 0 || site.Regions != "" || site.Description != "" { section("CONFIG") if site.MaxRetries > 0 { - row("Retries", fmtRetries(site)) + row("Retries", m.fmtRetries(site)) } if site.Regions != "" { row("Regions", site.Regions) @@ -163,17 +163,17 @@ func (m Model) viewDetailPanel() string { probeResults := m.engine.GetProbeResults(site.ID) if len(probeResults) > 0 { - b.WriteString("\n" + subtleStyle.Render(" PROBE RESULTS") + "\n") + b.WriteString("\n" + m.st.subtleStyle.Render(" PROBE RESULTS") + "\n") for nodeID, result := range probeResults { - status := specialStyle.Render("UP") + status := m.st.specialStyle.Render("UP") if !result.IsUp { - status = dangerStyle.Render("DN") + status = m.st.dangerStyle.Render("DN") } latency := time.Duration(result.LatencyNs).Milliseconds() ago := time.Since(result.CheckedAt).Truncate(time.Second) line := fmt.Sprintf(" %-14s %s %dms %s ago", nodeID, status, latency, ago) if !result.IsUp && result.ErrorReason != "" { - line += " " + dangerStyle.Render(result.ErrorReason) + line += " " + m.st.dangerStyle.Render(result.ErrorReason) } b.WriteString(line + "\n") } @@ -185,31 +185,31 @@ func (m Model) viewDetailPanel() string { stateChanges = m.detailChanges } if len(stateChanges) > 0 { - b.WriteString("\n" + subtleStyle.Render(" STATE CHANGES") + "\n") + b.WriteString("\n" + m.st.subtleStyle.Render(" STATE CHANGES") + "\n") for i, sc := range stateChanges { ago := fmtDuration(time.Since(sc.ChangedAt)) - arrow := subtleStyle.Render(sc.FromStatus) + " → " + arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → " if sc.ToStatus == "UP" { - arrow += specialStyle.Render(sc.ToStatus) + arrow += m.st.specialStyle.Render(sc.ToStatus) } else { - arrow += dangerStyle.Render(sc.ToStatus) + arrow += m.st.dangerStyle.Render(sc.ToStatus) } - line := fmt.Sprintf(" %s %s", arrow, subtleStyle.Render(ago+" ago")) + line := fmt.Sprintf(" %s %s", arrow, m.st.subtleStyle.Render(ago+" ago")) if dur := computeOutageDuration(stateChanges, i); dur > 0 { - line += " " + warnStyle.Render("outage "+fmtDuration(dur)) + line += " " + m.st.warnStyle.Render("outage "+fmtDuration(dur)) } if sc.ErrorReason != "" && sc.ToStatus != "UP" { - line += " " + dangerStyle.Render(sc.ErrorReason) + line += " " + m.st.dangerStyle.Render(sc.ErrorReason) } b.WriteString(line + "\n") } - b.WriteString(" " + subtleStyle.Render("[h] History") + "\n") + b.WriteString(" " + m.st.subtleStyle.Render("[h] History") + "\n") } b.WriteString(m.divider() + "\n") const sparkWidth = 40 if site.Type == "push" { - b.WriteString(" " + m.zones.Mark("spark-heartbeat", heartbeatSparkline(hist.Statuses, sparkWidth, ""))) + b.WriteString(" " + m.zones.Mark("spark-heartbeat", m.heartbeatSparkline(hist.Statuses, sparkWidth, ""))) if len(hist.Statuses) > 0 { up := 0 for _, s := range hist.Statuses { @@ -218,11 +218,11 @@ func (m Model) viewDetailPanel() string { } } fmt.Fprintf(&b, "\n %s %d/%d checks up", - subtleStyle.Render("Heartbeats"), + m.st.subtleStyle.Render("Heartbeats"), up, len(hist.Statuses)) } } else { - b.WriteString(" " + m.zones.Mark("spark-latency", latencySparkline(hist.Latencies, hist.Statuses, sparkWidth, ""))) + b.WriteString(" " + m.zones.Mark("spark-latency", m.latencySparkline(hist.Latencies, hist.Statuses, sparkWidth, ""))) var minL, maxL, total time.Duration count := 0 for i, l := range hist.Latencies { @@ -242,9 +242,9 @@ func (m Model) viewDetailPanel() string { if count > 0 { avg := total / time.Duration(count) fmt.Fprintf(&b, "\n %s %dms %s %dms %s %dms", - subtleStyle.Render("Min"), minL.Milliseconds(), - subtleStyle.Render("Avg"), avg.Milliseconds(), - subtleStyle.Render("Max"), maxL.Milliseconds()) + m.st.subtleStyle.Render("Min"), minL.Milliseconds(), + m.st.subtleStyle.Render("Avg"), avg.Milliseconds(), + m.st.subtleStyle.Render("Max"), maxL.Milliseconds()) } } @@ -254,7 +254,7 @@ func (m Model) viewDetailPanel() string { b.WriteString("\n") b.WriteString(m.divider() + "\n") - b.WriteString(subtleStyle.Render(" [i/Esc] Back [e] Edit [h] History [s] SLA [click] Inspect [q] Quit")) + b.WriteString(m.st.subtleStyle.Render(" [i/Esc] Back [e] Edit [h] History [s] SLA [click] Inspect [q] Quit")) return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) } @@ -283,18 +283,18 @@ func (m Model) renderSparkTooltip(site models.Site, hist monitor.SiteHistory, sp } if site.Type != "push" && idx < len(hist.Latencies) { - parts = append(parts, fmtLatency(hist.Latencies[idx])) + parts = append(parts, m.fmtLatency(hist.Latencies[idx])) } if idx < len(hist.Statuses) { if hist.Statuses[idx] { - parts = append(parts, specialStyle.Render("UP")) + parts = append(parts, m.st.specialStyle.Render("UP")) } else { - parts = append(parts, dangerStyle.Render("DOWN")) + parts = append(parts, m.st.dangerStyle.Render("DOWN")) } } - sep := subtleStyle.Render(" | ") - pos := subtleStyle.Render(fmt.Sprintf("[%d/%d]", idx+1, dataLen)) + sep := m.st.subtleStyle.Render(" | ") + pos := m.st.subtleStyle.Render(fmt.Sprintf("[%d/%d]", idx+1, dataLen)) return " " + strings.Join(parts, sep) + " " + pos } diff --git a/internal/tui/view_history.go b/internal/tui/view_history.go index ee18a9a..f61d7d6 100644 --- a/internal/tui/view_history.go +++ b/internal/tui/view_history.go @@ -49,7 +49,7 @@ func computeHistoryStats(changes []models.StateChange) historyStats { var stateChangeChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} -func stateChangeSparkline(changes []models.StateChange, width int) string { +func (m Model) stateChangeSparkline(changes []models.StateChange, width int) string { if len(changes) < 2 || width < 4 { return "" } @@ -96,11 +96,11 @@ func stateChangeSparkline(changes []models.StateChange, width int) string { ch := string(stateChangeChars[idx]) switch { case v >= 3: - sb.WriteString(dangerStyle.Render(ch)) + sb.WriteString(m.st.dangerStyle.Render(ch)) case v >= 2: - sb.WriteString(warnStyle.Render(ch)) + sb.WriteString(m.st.warnStyle.Render(ch)) default: - sb.WriteString(subtleStyle.Render(ch)) + sb.WriteString(m.st.subtleStyle.Render(ch)) } } return sb.String() @@ -120,26 +120,26 @@ func (m Model) buildHistoryContent() string { for i, sc := range m.historyChanges { ts := sc.ChangedAt.Format("2006-01-02 15:04") - arrow := subtleStyle.Render(sc.FromStatus) + " → " + arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → " switch sc.ToStatus { case "UP": - arrow += specialStyle.Render(sc.ToStatus) + arrow += m.st.specialStyle.Render(sc.ToStatus) case "LATE": - arrow += warnStyle.Render(sc.ToStatus) + arrow += m.st.warnStyle.Render(sc.ToStatus) case "STALE": - arrow += staleStyle.Render(sc.ToStatus) + arrow += m.st.staleStyle.Render(sc.ToStatus) default: - arrow += dangerStyle.Render(sc.ToStatus) + arrow += m.st.dangerStyle.Render(sc.ToStatus) } durStr := "" if dur := computeOutageDuration(m.historyChanges, i); dur > 0 { - durStr = warnStyle.Render("outage " + fmtDuration(dur)) + durStr = m.st.warnStyle.Render("outage " + fmtDuration(dur)) } reason := "" if sc.ErrorReason != "" && sc.ToStatus != "UP" { - reason = dangerStyle.Render(limitStr(sc.ErrorReason, reasonWidth)) + reason = m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonWidth)) } fmt.Fprintf(&b, " %-18s %s %-12s %s\n", ts, arrow, durStr, reason) @@ -151,27 +151,27 @@ func (m Model) buildHistoryContent() string { func (m Model) viewHistoryPanel() string { var b strings.Builder - header := " " + titleStyle.Render("STATE HISTORY: "+m.historySiteName) - header += " " + subtleStyle.Render("[q] Back") + header := " " + m.st.titleStyle.Render("STATE HISTORY: "+m.historySiteName) + header += " " + m.st.subtleStyle.Render("[q] Back") b.WriteString(header + "\n") divWidth := m.dividerWidth() b.WriteString(m.divider() + "\n") - sparkline := stateChangeSparkline(m.historyChanges, divWidth) + sparkline := m.stateChangeSparkline(m.historyChanges, divWidth) if sparkline != "" { b.WriteString(" " + sparkline + "\n") b.WriteString(m.divider() + "\n") } fmt.Fprintf(&b, " %-18s %-17s %-12s %s\n", - subtleStyle.Render("TIME"), - subtleStyle.Render("TRANSITION"), - subtleStyle.Render("DURATION"), - subtleStyle.Render("REASON")) + m.st.subtleStyle.Render("TIME"), + m.st.subtleStyle.Render("TRANSITION"), + m.st.subtleStyle.Render("DURATION"), + m.st.subtleStyle.Render("REASON")) if len(m.historyChanges) == 0 { - b.WriteString("\n " + subtleStyle.Render("No state changes recorded") + "\n") + b.WriteString("\n " + m.st.subtleStyle.Render("No state changes recorded") + "\n") } else { b.WriteString(m.historyViewport.View()) } @@ -185,8 +185,8 @@ func (m Model) viewHistoryPanel() string { avg := stats.totalDowntime / time.Duration(stats.outageCount) parts = append(parts, "avg outage "+fmtDuration(avg)) } - b.WriteString(" " + subtleStyle.Render(strings.Join(parts, " │ ")) + "\n") - b.WriteString(" " + subtleStyle.Render("[j/k/↑/↓] Scroll [q/Esc] Back")) + b.WriteString(" " + m.st.subtleStyle.Render(strings.Join(parts, " │ ")) + "\n") + b.WriteString(" " + m.st.subtleStyle.Render("[j/k/↑/↓] Scroll [q/Esc] Back")) return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) } diff --git a/internal/tui/view_history_test.go b/internal/tui/view_history_test.go index e3871bc..b90c205 100644 --- a/internal/tui/view_history_test.go +++ b/internal/tui/view_history_test.go @@ -134,14 +134,14 @@ func TestComputeHistoryStats_Empty(t *testing.T) { func TestStateChangeSparkline(t *testing.T) { t.Run("empty", func(t *testing.T) { - if got := stateChangeSparkline(nil, 20); got != "" { + if got := styledModel.stateChangeSparkline(nil, 20); got != "" { t.Errorf("expected empty for nil, got %q", got) } }) t.Run("single event", func(t *testing.T) { changes := []models.StateChange{{ChangedAt: time.Now()}} - if got := stateChangeSparkline(changes, 20); got != "" { + if got := styledModel.stateChangeSparkline(changes, 20); got != "" { t.Errorf("expected empty for single event, got %q", got) } }) @@ -152,7 +152,7 @@ func TestStateChangeSparkline(t *testing.T) { {ChangedAt: now}, {ChangedAt: now.Add(-1 * time.Hour)}, } - got := stateChangeSparkline(changes, 20) + got := styledModel.stateChangeSparkline(changes, 20) if got == "" { t.Error("expected non-empty sparkline for two events") } @@ -164,7 +164,7 @@ func TestStateChangeSparkline(t *testing.T) { {ChangedAt: now}, {ChangedAt: now.Add(-1 * time.Hour)}, } - if got := stateChangeSparkline(changes, 3); got != "" { + if got := styledModel.stateChangeSparkline(changes, 3); got != "" { t.Errorf("expected empty for width 3, got %q", got) } }) diff --git a/internal/tui/view_sla.go b/internal/tui/view_sla.go index 980881e..eff6583 100644 --- a/internal/tui/view_sla.go +++ b/internal/tui/view_sla.go @@ -24,13 +24,13 @@ var slaPeriods = []struct { func (m Model) viewSLAPanel() string { var b strings.Builder - header := " " + titleStyle.Render("SLA REPORT: "+m.slaSiteName) - header += " " + subtleStyle.Render("[q] Back") + header := " " + m.st.titleStyle.Render("SLA REPORT: "+m.slaSiteName) + header += " " + m.st.subtleStyle.Render("[q] Back") b.WriteString(header + "\n") b.WriteString(m.divider() + "\n") period := slaPeriods[m.slaPeriodIdx] - b.WriteString(" " + subtleStyle.Render("Period: Last "+period.label) + "\n\n") + b.WriteString(" " + m.st.subtleStyle.Render("Period: Last "+period.label) + "\n\n") r := m.slaReport @@ -38,22 +38,22 @@ func (m Model) viewSLAPanel() string { if barWidth < 10 { barWidth = 10 } - bar := uptimeBar(r.UptimePct, barWidth) - uptimeColor := specialStyle + bar := m.uptimeBar(r.UptimePct, barWidth) + uptimeColor := m.st.specialStyle if r.UptimePct < 99.9 { - uptimeColor = warnStyle + uptimeColor = m.st.warnStyle } if r.UptimePct < 99.0 { - uptimeColor = dangerStyle + uptimeColor = m.st.dangerStyle } - fmt.Fprintf(&b, " %-16s %s %s\n", subtleStyle.Render("Uptime"), uptimeColor.Render(fmt.Sprintf("%s%%", fmtPct(r.UptimePct))), bar) - fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render("Downtime"), fmtDuration(r.Downtime)) - fmt.Fprintf(&b, " %-16s %d\n", subtleStyle.Render("Outages"), r.OutageCount) + fmt.Fprintf(&b, " %-16s %s %s\n", m.st.subtleStyle.Render("Uptime"), uptimeColor.Render(fmt.Sprintf("%s%%", fmtPct(r.UptimePct))), bar) + fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Downtime"), fmtDuration(r.Downtime)) + fmt.Fprintf(&b, " %-16s %d\n", m.st.subtleStyle.Render("Outages"), r.OutageCount) if r.OutageCount > 0 { - fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render("Longest"), fmtDuration(r.LongestOut)) - fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render("MTTR"), fmtDuration(r.MTTR)) - fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render("MTBF"), fmtDuration(r.MTBF)) + fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Longest"), fmtDuration(r.LongestOut)) + fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("MTTR"), fmtDuration(r.MTTR)) + fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("MTBF"), fmtDuration(r.MTBF)) } b.WriteString("\n" + m.divider() + "\n") @@ -68,13 +68,13 @@ func (m Model) viewSLAPanel() string { for i, p := range slaPeriods { label := fmt.Sprintf("[%s] %s", p.key, p.label) if i == m.slaPeriodIdx { - keys = append(keys, titleStyle.Render(label)) + keys = append(keys, m.st.titleStyle.Render(label)) } else { - keys = append(keys, subtleStyle.Render(label)) + keys = append(keys, m.st.subtleStyle.Render(label)) } } b.WriteString(" " + strings.Join(keys, " ")) - b.WriteString(" " + subtleStyle.Render("[j/k/↑/↓] Scroll [q/Esc] Back")) + b.WriteString(" " + m.st.subtleStyle.Render("[j/k/↑/↓] Scroll [q/Esc] Back")) return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) } @@ -87,27 +87,27 @@ func (m Model) buildSLADailyContent() string { barWidth = 10 } - b.WriteString(" " + subtleStyle.Render("DAILY BREAKDOWN") + "\n") + b.WriteString(" " + m.st.subtleStyle.Render("DAILY BREAKDOWN") + "\n") for _, day := range m.slaDailyBreakdown { dateStr := day.Date.Format("Jan 02") - bar := uptimeBar(day.UptimePct, barWidth) + bar := m.uptimeBar(day.UptimePct, barWidth) pctStr := fmtPct(day.UptimePct) + "%" - color := specialStyle + color := m.st.specialStyle if day.UptimePct < 99.9 { - color = warnStyle + color = m.st.warnStyle } if day.UptimePct < 99.0 { - color = dangerStyle + color = m.st.dangerStyle } - fmt.Fprintf(&b, " %-8s %s %s\n", subtleStyle.Render(dateStr), bar, color.Render(pctStr)) + fmt.Fprintf(&b, " %-8s %s %s\n", m.st.subtleStyle.Render(dateStr), bar, color.Render(pctStr)) } return b.String() } -func uptimeBar(pct float64, width int) string { +func (m Model) uptimeBar(pct float64, width int) string { filled := int(math.Round(pct / 100 * float64(width))) if filled > width { filled = width @@ -117,9 +117,9 @@ func uptimeBar(pct float64, width int) string { } empty := width - filled - bar := specialStyle.Render(strings.Repeat("█", filled)) + bar := m.st.specialStyle.Render(strings.Repeat("█", filled)) if empty > 0 { - bar += subtleStyle.Render(strings.Repeat("░", empty)) + bar += m.st.subtleStyle.Render(strings.Repeat("░", empty)) } return bar }