Compare commits

...

3 Commits

Author SHA1 Message Date
lerko a3711c652c fix(tui): move all store writes out of Update into tea.Cmds
CI / test (pull_request) Successful in 2m35s
CI / lint (pull_request) Successful in 56s
CI / vulncheck (pull_request) Successful in 51s
Deletes, pause toggles, maintenance end, theme/collapse prefs, and all
four form submits wrote to the store synchronously on the UI goroutine;
with busy_timeout=5000 a contended DB froze input for up to 5s.

Writes now run through a writeCmd helper returning writeDoneMsg. The
in-memory engine/model mutations stay in Update so rows react
instantly; the reply logs failures and reloads tab data, so the UI
converges on what was actually written. Closures capture snapshotted
values only — never the model.
2026-06-11 11:39:15 -04:00
lerko 634c3ee03c fix(tui): finish moving keypress DB reads into tea.Cmds
The #101 refactor stopped at the tick path; 'h' history and the SLA
view still queried state changes synchronously in Update, freezing the
UI for up to busy_timeout on a contended DB. Both now load through
Cmds with loading placeholders.

Also closes the remaining staleness holes in the async data flow:
- tabDataMsg carries a sequence number; out-of-order replies from
  slower earlier loads are dropped instead of overwriting newer data
- history/SLA replies are dropped when the user has navigated to a
  different site or period
- the open detail panel refreshes on the tab-data cadence instead of
  loading once on entry and going stale
- initSiteHuhForm reads the m.alerts cache instead of hitting the store
2026-06-11 11:35:03 -04:00
lerko 274f0081e2 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.
2026-06-11 11:23:16 -04:00
21 changed files with 724 additions and 421 deletions
+49 -10
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"sort" "sort"
"strings" "strings"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store" "gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
@@ -26,7 +27,9 @@ func loadCollapsed(s store.Store) map[int]bool {
return m return m
} }
func saveCollapsed(s store.Store, collapsed map[int]bool) { // collapsedJSON snapshots the collapsed-group set for persistence. Marshaling
// happens on the UI goroutine so the write Cmd never reads the live map.
func collapsedJSON(collapsed map[int]bool) string {
var ids []int var ids []int
for id, v := range collapsed { for id, v := range collapsed {
if v { if v {
@@ -34,7 +37,15 @@ func saveCollapsed(s store.Store, collapsed map[int]bool) {
} }
} }
data, _ := json.Marshal(ids) data, _ := json.Marshal(ids)
_ = s.SetPreference("collapsed_groups", string(data)) return string(data)
}
// writeCmd runs a store mutation off the UI goroutine. The closure must only
// capture values snapshotted in Update — never the model itself.
func writeCmd(op string, fn func() error) tea.Cmd {
return func() tea.Msg {
return writeDoneMsg{op: op, err: fn()}
}
} }
func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool) []models.Site { func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool) []models.Site {
@@ -108,32 +119,36 @@ func (m *Model) clampCursor() {
} }
// loadTabDataCmd returns a tea.Cmd that loads the DB-backed tab tables off the // loadTabDataCmd returns a tea.Cmd that loads the DB-backed tab tables off the
// UI goroutine. The closure reads only stable fields (store, isAdmin) and never // UI goroutine. Each call bumps tabSeq and stamps the reply with it, so
// mutates the model; results come back as a tabDataMsg. On the first store // handleTabData can drop out-of-order results from slower earlier loads. The
// error it returns an error-only msg so the model keeps its previous data. // closure reads only stable fields (store, isAdmin) and never mutates the
// model; results come back as a tabDataMsg. On the first store error it
// returns an error-only msg so the model keeps its previous data.
func (m *Model) loadTabDataCmd() tea.Cmd { func (m *Model) loadTabDataCmd() tea.Cmd {
m.tabSeq++
seq := m.tabSeq
st := m.store st := m.store
isAdmin := m.isAdmin isAdmin := m.isAdmin
return func() tea.Msg { return func() tea.Msg {
alerts, err := st.GetAllAlerts() alerts, err := st.GetAllAlerts()
if err != nil { if err != nil {
return tabDataMsg{err: err} return tabDataMsg{seq: seq, err: err}
} }
var users []models.User var users []models.User
if isAdmin { if isAdmin {
if users, err = st.GetAllUsers(); err != nil { if users, err = st.GetAllUsers(); err != nil {
return tabDataMsg{err: err} return tabDataMsg{seq: seq, err: err}
} }
} }
nodes, err := st.GetAllNodes() nodes, err := st.GetAllNodes()
if err != nil { if err != nil {
return tabDataMsg{err: err} return tabDataMsg{seq: seq, err: err}
} }
maint, err := st.GetAllMaintenanceWindows(100) maint, err := st.GetAllMaintenanceWindows(100)
if err != nil { if err != nil {
return tabDataMsg{err: err} return tabDataMsg{seq: seq, err: err}
} }
return tabDataMsg{alerts: alerts, users: users, nodes: nodes, maint: maint} return tabDataMsg{seq: seq, alerts: alerts, users: users, nodes: nodes, maint: maint}
} }
} }
@@ -145,3 +160,27 @@ func (m *Model) loadDetailCmd(siteID int) tea.Cmd {
return detailDataMsg{siteID: siteID, changes: eng.GetStateChanges(siteID, 5)} return detailDataMsg{siteID: siteID, changes: eng.GetStateChanges(siteID, 5)}
} }
} }
// loadHistoryCmd loads the full state-change history for the history view off
// the UI goroutine.
func (m *Model) loadHistoryCmd(siteID int) tea.Cmd {
eng := m.engine
return func() tea.Msg {
return historyDataMsg{siteID: siteID, changes: eng.GetStateChanges(siteID, 100)}
}
}
// loadSLACmd loads the state changes backing the SLA view off the UI
// goroutine. The reply carries the request's site and period so a stale reply
// can be recognized and dropped.
func (m *Model) loadSLACmd(siteID, periodIdx int) tea.Cmd {
eng := m.engine
since := time.Now().Add(-slaPeriods[periodIdx].duration)
return func() tea.Msg {
return slaDataMsg{
siteID: siteID,
periodIdx: periodIdx,
changes: eng.GetStateChangesSince(siteID, since),
}
}
}
+31 -31
View File
@@ -18,13 +18,13 @@ func (m Model) dividerWidth() int {
} }
func (m Model) divider() string { 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 { func (m Model) emptyState(message, hint string) string {
content := message content := message
if hint != "" { if hint != "" {
content += "\n\n" + subtleStyle.Render(hint) content += "\n\n" + m.st.subtleStyle.Render(hint)
} }
return "\n" + lipgloss.NewStyle(). return "\n" + lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()). 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() ms := d.Milliseconds()
if ms == 0 { if ms == 0 {
return subtleStyle.Render("—") return m.st.subtleStyle.Render("—")
} }
var s string var s string
if ms < 1000 { if ms < 1000 {
@@ -93,17 +93,17 @@ func fmtLatency(d time.Duration) string {
s = fmt.Sprintf("%.1fs", float64(ms)/1000) s = fmt.Sprintf("%.1fs", float64(ms)/1000)
} }
if ms < 200 { if ms < 200 {
return specialStyle.Render(s) return m.st.specialStyle.Render(s)
} }
if ms < 500 { 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 { if len(statuses) == 0 {
return subtleStyle.Render("—") return m.st.subtleStyle.Render("—")
} }
up := 0 up := 0
for _, s := range statuses { for _, s := range statuses {
@@ -114,70 +114,70 @@ func fmtUptime(statuses []bool) string {
pct := float64(up) / float64(len(statuses)) * 100 pct := float64(up) / float64(len(statuses)) * 100
s := fmt.Sprintf("%.1f%%", pct) s := fmt.Sprintf("%.1f%%", pct)
if pct >= 99 { if pct >= 99 {
return specialStyle.Render(s) return m.st.specialStyle.Render(s)
} }
if pct >= 95 { 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 { if site.Type != "http" || !site.CheckSSL || !site.HasSSL {
return subtleStyle.Render("-") return m.st.subtleStyle.Render("-")
} }
days := int(time.Until(site.CertExpiry).Hours() / 24) days := int(time.Until(site.CertExpiry).Hours() / 24)
s := fmt.Sprintf("%dd", days) s := fmt.Sprintf("%dd", days)
if days <= 0 { if days <= 0 {
return dangerStyle.Render("EXPIRED") return m.st.dangerStyle.Render("EXPIRED")
} }
if days <= site.ExpiryThreshold { 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 dispCount := site.FailureCount
if dispCount > site.MaxRetries { if dispCount > site.MaxRetries {
dispCount = site.MaxRetries dispCount = site.MaxRetries
} }
s := fmt.Sprintf("%d/%d", dispCount, site.MaxRetries) s := fmt.Sprintf("%d/%d", dispCount, site.MaxRetries)
if site.Status == "DOWN" { if site.Status == "DOWN" {
return dangerStyle.Render(s) return m.st.dangerStyle.Render(s)
} }
if site.Status == "UP" && site.FailureCount > 0 { if site.Status == "UP" && site.FailureCount > 0 {
return warnStyle.Render(s) return m.st.warnStyle.Render(s)
} }
return 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 { if paused {
return warnStyle.Render("◇ PAUSED") return m.st.warnStyle.Render("◇ PAUSED")
} }
if inMaint { if inMaint {
return maintStyle.Render("◼ MAINT") return m.st.maintStyle.Render("◼ MAINT")
} }
switch status { switch status {
case "DOWN": case "DOWN":
return dangerStyle.Render("▼ DOWN") return m.st.dangerStyle.Render("▼ DOWN")
case "SSL EXP": case "SSL EXP":
return dangerStyle.Render("▼ SSL EXP") return m.st.dangerStyle.Render("▼ SSL EXP")
case "LATE": case "LATE":
return warnStyle.Render("◆ LATE") return m.st.warnStyle.Render("◆ LATE")
case "STALE": case "STALE":
return staleStyle.Render("◆ STALE") return m.st.staleStyle.Render("◆ STALE")
case "PENDING": case "PENDING":
return subtleStyle.Render("○ PENDING") return m.st.subtleStyle.Render("○ PENDING")
default: 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() { if t.IsZero() {
return subtleStyle.Render("never") return m.st.subtleStyle.Render("never")
} }
d := time.Since(t) d := time.Since(t)
if d < time.Minute { if d < time.Minute {
+5 -6
View File
@@ -7,9 +7,8 @@ import (
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
) )
func init() { // styledModel carries a default-theme styles instance for render-helper tests.
applyTheme(themeFlexokiDark) var styledModel = Model{st: newStyles(themeFlexokiDark)}
}
func TestLimitStr(t *testing.T) { func TestLimitStr(t *testing.T) {
tests := []struct { tests := []struct {
@@ -72,7 +71,7 @@ func TestFmtStatus(t *testing.T) {
{"DOWN", false, true, "◼ MAINT"}, {"DOWN", false, true, "◼ MAINT"},
} }
for _, tt := range tests { 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) { if !containsPlain(got, tt.wantSub) {
t.Errorf("fmtStatus(%q, paused=%v, maint=%v): %q missing %q", t.Errorf("fmtStatus(%q, paused=%v, maint=%v): %q missing %q",
tt.status, tt.paused, tt.inMaint, got, tt.wantSub) 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%"}, {"all down", []bool{false, false}, "0.0%"},
} }
for _, tt := range tests { for _, tt := range tests {
got := fmtUptime(tt.statuses) got := styledModel.fmtUptime(tt.statuses)
if !containsPlain(got, tt.wantSub) { if !containsPlain(got, tt.wantSub) {
t.Errorf("fmtUptime(%s): %q missing %q", tt.name, 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"}, {1500 * time.Millisecond, "1.5s"},
} }
for _, tt := range tests { for _, tt := range tests {
got := fmtLatency(tt.d) got := styledModel.fmtLatency(tt.d)
if !containsPlain(got, tt.wantSub) { if !containsPlain(got, tt.wantSub) {
t.Errorf("fmtLatency(%v): %q missing %q", tt.d, got, tt.wantSub) t.Errorf("fmtLatency(%v): %q missing %q", tt.d, got, tt.wantSub)
} }
+31 -2
View File
@@ -18,8 +18,11 @@ type tickMsg time.Time
// tabDataMsg carries the result of an async load of the DB-backed tab tables. // tabDataMsg carries the result of an async load of the DB-backed tab tables.
// On err, the model keeps its previous data and logs — never wiping the view on // On err, the model keeps its previous data and logs — never wiping the view on
// a transient store error. // a transient store error. seq orders in-flight loads: replies whose seq is
// older than the model's current tabSeq are dropped, so a slow load can never
// overwrite the result of a newer one.
type tabDataMsg struct { type tabDataMsg struct {
seq int
alerts []models.AlertConfig alerts []models.AlertConfig
users []models.User users []models.User
nodes []models.ProbeNode nodes []models.ProbeNode
@@ -28,8 +31,34 @@ type tabDataMsg struct {
} }
// detailDataMsg carries the state-change history for the detail panel, loaded // detailDataMsg carries the state-change history for the detail panel, loaded
// when the panel is opened so View never touches the database. // on entry and refreshed on the tab-data cadence so View never touches the
// database.
type detailDataMsg struct { type detailDataMsg struct {
siteID int siteID int
changes []models.StateChange changes []models.StateChange
} }
// historyDataMsg carries the full state-change history for the history view.
// siteID guards against a slow reply landing after the user opened a
// different site's history.
type historyDataMsg struct {
siteID int
changes []models.StateChange
}
// slaDataMsg carries the state changes backing the SLA view for one
// site+period request. siteID and periodIdx guard stale replies the same way
// historyDataMsg does.
type slaDataMsg struct {
siteID int
periodIdx int
changes []models.StateChange
}
// writeDoneMsg reports a store mutation that ran off the UI goroutine. op
// names the action for the error log; the handler reloads tab data so the UI
// converges on what was actually written.
type writeDoneMsg struct {
op string
err error
}
+20 -20
View File
@@ -34,18 +34,18 @@ func withBg(s lipgloss.Style, bg lipgloss.Color) lipgloss.Style {
return s 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 hex string
var t float64 var t float64
switch { switch {
case ms < 200: case ms < 200:
hex = sparkSuccess hex = m.st.sparkSuccess
t = float64(ms) / 200 t = float64(ms) / 200
case ms < 500: case ms < 500:
hex = sparkWarning hex = m.st.sparkWarning
t = float64(ms-200) / 300 t = float64(ms-200) / 300
default: default:
hex = sparkDanger hex = m.st.sparkDanger
t = float64(ms-500) / 1500 t = float64(ms-500) / 1500
if t > 1 { if t > 1 {
t = 1 t = 1
@@ -55,9 +55,9 @@ func latencyStyle(ms int64, bg lipgloss.Color) lipgloss.Style {
return withBg(s, bg) 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 { if len(latencies) == 0 {
return withBg(subtleStyle, bg).Render(strings.Repeat("·", width)) return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width))
} }
samples := latencies samples := latencies
@@ -82,7 +82,7 @@ func latencySparkline(latencies []time.Duration, statuses []bool, width int, bg
var sb strings.Builder var sb strings.Builder
if remaining := width - len(samples); remaining > 0 { 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 { for i, l := range samples {
idx := 0 idx := 0
@@ -95,17 +95,17 @@ func latencySparkline(latencies []time.Duration, statuses []bool, width int, bg
ch := string(sparkChars[idx]) ch := string(sparkChars[idx])
isDown := i < len(sampledStatuses) && !sampledStatuses[i] isDown := i < len(sampledStatuses) && !sampledStatuses[i]
if isDown { if isDown {
sb.WriteString(withBg(dangerStyle, bg).Render(ch)) sb.WriteString(withBg(m.st.dangerStyle, bg).Render(ch))
} else { } else {
sb.WriteString(latencyStyle(l.Milliseconds(), bg).Render(ch)) sb.WriteString(m.latencyStyle(l.Milliseconds(), bg).Render(ch))
} }
} }
return sb.String() 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 { if len(statuses) == 0 {
return withBg(subtleStyle, bg).Render(strings.Repeat("·", width)) return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width))
} }
samples := statuses samples := statuses
@@ -115,13 +115,13 @@ func heartbeatSparkline(statuses []bool, width int, bg lipgloss.Color) string {
var sb strings.Builder var sb strings.Builder
if remaining := width - len(samples); remaining > 0 { 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 { for _, up := range samples {
if up { if up {
sb.WriteString(withBg(specialStyle, bg).Render("▁")) sb.WriteString(withBg(m.st.specialStyle, bg).Render("▁"))
} else { } else {
sb.WriteString(withBg(dangerStyle, bg).Render("█")) sb.WriteString(withBg(m.st.dangerStyle, bg).Render("█"))
} }
} }
return sb.String() return sb.String()
@@ -156,7 +156,7 @@ func (m Model) groupSparkline(groupID int, width int, bg lipgloss.Color) string
} }
if len(childStatuses) == 0 { if len(childStatuses) == 0 {
return withBg(subtleStyle, bg).Render(strings.Repeat("·", width)) return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width))
} }
maxLen := 0 maxLen := 0
@@ -184,13 +184,13 @@ func (m Model) groupSparkline(groupID int, width int, bg lipgloss.Color) string
var sb strings.Builder var sb strings.Builder
if remaining := width - len(aggregated); remaining > 0 { 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 { for _, up := range aggregated {
if up { if up {
sb.WriteString(withBg(subtleStyle, bg).Render("·")) sb.WriteString(withBg(m.st.subtleStyle, bg).Render("·"))
} else { } else {
sb.WriteString(withBg(dangerStyle, bg).Render("•")) sb.WriteString(withBg(m.st.dangerStyle, bg).Render("•"))
} }
} }
return sb.String() return sb.String()
@@ -208,7 +208,7 @@ func (m Model) groupUptime(groupID int) string {
} }
} }
if len(allStatuses) == 0 { if len(allStatuses) == 0 {
return subtleStyle.Render("—") return m.st.subtleStyle.Render("—")
} }
total, up := 0, 0 total, up := 0, 0
for _, statuses := range allStatuses { 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) out := make([]bool, total)
idx := 0 idx := 0
for _, statuses := range allStatuses { for _, statuses := range allStatuses {
+21 -23
View File
@@ -8,7 +8,7 @@ import (
) )
func TestLatencySparkline_Empty(t *testing.T) { func TestLatencySparkline_Empty(t *testing.T) {
got := latencySparkline(nil, nil, 10, "") got := styledModel.latencySparkline(nil, nil, 10, "")
if !strings.Contains(got, "··········") { if !strings.Contains(got, "··········") {
t.Errorf("empty sparkline should be dots, got %q", 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) { func TestLatencySparkline_SingleValue(t *testing.T) {
latencies := []time.Duration{100 * time.Millisecond} latencies := []time.Duration{100 * time.Millisecond}
statuses := []bool{true} statuses := []bool{true}
got := latencySparkline(latencies, statuses, 5, "") got := styledModel.latencySparkline(latencies, statuses, 5, "")
if len(got) == 0 { if len(got) == 0 {
t.Error("sparkline should not be empty") 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 latencies[i] = time.Duration(i*50) * time.Millisecond
statuses[i] = true statuses[i] = true
} }
got := latencySparkline(latencies, statuses, 5, "") got := styledModel.latencySparkline(latencies, statuses, 5, "")
if len(got) == 0 { if len(got) == 0 {
t.Error("sparkline should not be empty") t.Error("sparkline should not be empty")
} }
@@ -45,7 +45,7 @@ func TestLatencySparkline_WidthTruncation(t *testing.T) {
func TestLatencySparkline_RelativeHeight(t *testing.T) { func TestLatencySparkline_RelativeHeight(t *testing.T) {
latencies := []time.Duration{10 * time.Millisecond, 50 * time.Millisecond, 10 * time.Millisecond} latencies := []time.Duration{10 * time.Millisecond, 50 * time.Millisecond, 10 * time.Millisecond}
statuses := []bool{true, true, true} statuses := []bool{true, true, true}
out := stripANSI(latencySparkline(latencies, statuses, 3, "")) out := stripANSI(styledModel.latencySparkline(latencies, statuses, 3, ""))
runes := []rune(out) runes := []rune(out)
if len(runes) < 3 { if len(runes) < 3 {
t.Fatalf("expected 3 runes, got %d", len(runes)) 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) { func TestLatencyStyle_BandsProduceDifferentColors(t *testing.T) {
sparkSuccess = "#00ff00" st := newStyles(themeFlexokiDark)
sparkWarning = "#ffff00" st.sparkSuccess = "#00ff00"
sparkDanger = "#ff0000" st.sparkWarning = "#ffff00"
defer func() { st.sparkDanger = "#ff0000"
sparkSuccess = "" m := Model{st: st}
sparkWarning = ""
sparkDanger = ""
}()
green := latencyStyle(50, "") green := m.latencyStyle(50, "")
yellow := latencyStyle(300, "") yellow := m.latencyStyle(300, "")
red := latencyStyle(800, "") red := m.latencyStyle(800, "")
gfg := green.GetForeground() gfg := green.GetForeground()
yfg := yellow.GetForeground() yfg := yellow.GetForeground()
@@ -79,11 +76,12 @@ func TestLatencyStyle_BandsProduceDifferentColors(t *testing.T) {
} }
func TestLatencyStyle_BrightnessVariesWithinBand(t *testing.T) { func TestLatencyStyle_BrightnessVariesWithinBand(t *testing.T) {
sparkSuccess = "#00ff00" st := newStyles(themeFlexokiDark)
defer func() { sparkSuccess = "" }() st.sparkSuccess = "#00ff00"
m := Model{st: st}
dim := latencyStyle(10, "") dim := m.latencyStyle(10, "")
bright := latencyStyle(190, "") bright := m.latencyStyle(190, "")
if dim.GetForeground() == bright.GetForeground() { if dim.GetForeground() == bright.GetForeground() {
t.Error("10ms and 190ms should have different brightness within green band") 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) { func TestLatencySparkline_OutputWidth(t *testing.T) {
latencies := []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 300 * time.Millisecond} latencies := []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 300 * time.Millisecond}
statuses := []bool{true, true, true} statuses := []bool{true, true, true}
got := latencySparkline(latencies, statuses, 5, "") got := styledModel.latencySparkline(latencies, statuses, 5, "")
count := utf8.RuneCountInString(stripANSI(got)) count := utf8.RuneCountInString(stripANSI(got))
if count != 5 { if count != 5 {
t.Errorf("expected 5 rune-width output, got %d from %q", count, got) 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) { func TestHeartbeatSparkline_Empty(t *testing.T) {
got := heartbeatSparkline(nil, 10, "") got := styledModel.heartbeatSparkline(nil, 10, "")
if !strings.Contains(got, "··········") { if !strings.Contains(got, "··········") {
t.Errorf("empty heartbeat should be dots, got %q", 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) { func TestHeartbeatSparkline_Mixed(t *testing.T) {
statuses := []bool{true, false, true, true, false} statuses := []bool{true, false, true, true, false}
got := heartbeatSparkline(statuses, 5, "") got := styledModel.heartbeatSparkline(statuses, 5, "")
if len(got) == 0 { if len(got) == 0 {
t.Error("heartbeat sparkline should not be empty") t.Error("heartbeat sparkline should not be empty")
} }
@@ -134,7 +132,7 @@ func TestHeartbeatSparkline_Mixed(t *testing.T) {
func TestHeartbeatSparkline_PaddedWidth(t *testing.T) { func TestHeartbeatSparkline_PaddedWidth(t *testing.T) {
statuses := []bool{true, true} statuses := []bool{true, true}
got := heartbeatSparkline(statuses, 5, "") got := styledModel.heartbeatSparkline(statuses, 5, "")
if !strings.Contains(got, "···") { if !strings.Contains(got, "···") {
t.Errorf("should have dot padding for width > data, got %q", got) t.Errorf("should have dot padding for width > data, got %q", got)
} }
+39 -37
View File
@@ -71,7 +71,7 @@ func fmtAlertType(t string) string {
} }
} }
func fmtAlertConfig(alert struct { func (m Model) fmtAlertConfig(alert struct {
Type string Type string
Settings map[string]string Settings map[string]string
}) string { }) string {
@@ -85,34 +85,34 @@ func fmtAlertConfig(alert struct {
if host != "" { if host != "" {
return limitStr(host, 34) return limitStr(host, 34)
} }
return subtleStyle.Render("—") return m.st.subtleStyle.Render("—")
case "ntfy": case "ntfy":
topic := alert.Settings["topic"] topic := alert.Settings["topic"]
url := alert.Settings["url"] url := alert.Settings["url"]
if url != "" && topic != "" { if url != "" && topic != "" {
return limitStr(fmt.Sprintf("%s/%s", url, topic), 34) return limitStr(fmt.Sprintf("%s/%s", url, topic), 34)
} }
return subtleStyle.Render("—") return m.st.subtleStyle.Render("—")
case "telegram": case "telegram":
if id := alert.Settings["chat_id"]; id != "" { if id := alert.Settings["chat_id"]; id != "" {
return limitStr(fmt.Sprintf("chat:%s", id), 34) return limitStr(fmt.Sprintf("chat:%s", id), 34)
} }
return subtleStyle.Render("—") return m.st.subtleStyle.Render("—")
case "pagerduty": case "pagerduty":
if key := alert.Settings["routing_key"]; key != "" { if key := alert.Settings["routing_key"]; key != "" {
return limitStr(key, 34) return limitStr(key, 34)
} }
return subtleStyle.Render("—") return m.st.subtleStyle.Render("—")
case "pushover": case "pushover":
if user := alert.Settings["user"]; user != "" { if user := alert.Settings["user"]; user != "" {
return limitStr(fmt.Sprintf("user:%s", user), 34) return limitStr(fmt.Sprintf("user:%s", user), 34)
} }
return subtleStyle.Render("—") return m.st.subtleStyle.Render("—")
case "gotify": case "gotify":
if url := alert.Settings["url"]; url != "" { if url := alert.Settings["url"]; url != "" {
return limitStr(url, 34) return limitStr(url, 34)
} }
return subtleStyle.Render("—") return m.st.subtleStyle.Render("—")
case "opsgenie": case "opsgenie":
key := alert.Settings["api_key"] key := alert.Settings["api_key"]
if key != "" { if key != "" {
@@ -125,27 +125,27 @@ func fmtAlertConfig(alert struct {
} }
return limitStr(masked, 34) return limitStr(masked, 34)
} }
return subtleStyle.Render("—") return m.st.subtleStyle.Render("—")
default: default:
if val, ok := alert.Settings["url"]; ok { if val, ok := alert.Settings["url"]; ok {
return limitStr(val, 34) 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() { if h.LastSendAt.IsZero() {
return subtleStyle.Render("●") return m.st.subtleStyle.Render("●")
} }
if h.LastSendOK { 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 { func (m Model) fmtAlertLastSent(h monitor.AlertHealth) string {
return fmtTimeAgo(h.LastSendAt) return m.fmtTimeAgo(h.LastSendAt)
} }
func (m Model) viewAlertsTab() string { func (m Model) viewAlertsTab() string {
@@ -175,14 +175,14 @@ func (m Model) viewAlertsTab() string {
h := m.engine.GetAlertHealth(a.ID) h := m.engine.GetAlertHealth(a.ID)
rows = append(rows, []string{ rows = append(rows, []string{
fmt.Sprintf("%d", i+1), fmt.Sprintf("%d", i+1),
fmtAlertHealth(h), m.fmtAlertHealth(h),
m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(a.Name, nameW-2)), m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(a.Name, nameW-2)),
fmtAlertType(a.Type), fmtAlertType(a.Type),
limitStr(fmtAlertConfig(struct { limitStr(m.fmtAlertConfig(struct {
Type string Type string
Settings map[string]string Settings map[string]string
}{a.Type, a.Settings}), cfgW-2), }{a.Type, a.Settings}), cfgW-2),
fmtAlertLastSent(h), m.fmtAlertLastSent(h),
}) })
} }
return rows return rows
@@ -200,41 +200,41 @@ func (m Model) viewAlertDetailPanel() string {
var b strings.Builder 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") b.WriteString(m.divider() + "\n")
row := func(label, value string) { 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)) row("Type", fmtAlertType(a.Type))
if h.LastSendAt.IsZero() { if h.LastSendAt.IsZero() {
row("Health", subtleStyle.Render("never sent")) row("Health", m.st.subtleStyle.Render("never sent"))
} else if h.LastSendOK { } else if h.LastSendOK {
row("Health", specialStyle.Render("OK")) row("Health", m.st.specialStyle.Render("OK"))
} else { } else {
row("Health", dangerStyle.Render("FAILED")) row("Health", m.st.dangerStyle.Render("FAILED"))
} }
if !h.LastSendAt.IsZero() { 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 { if h.SendCount > 0 {
row("Sends", fmt.Sprintf("%d sent, %d failed", h.SendCount, h.FailCount)) row("Sends", fmt.Sprintf("%d sent, %d failed", h.SendCount, h.FailCount))
} }
if h.LastError != "" { 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(m.divider() + "\n")
b.WriteString(subtleStyle.Render(" CONFIGURATION") + "\n") b.WriteString(m.st.subtleStyle.Render(" CONFIGURATION") + "\n")
for k, v := range a.Settings { for k, v := range a.Settings {
row(k, v) row(k, v)
} }
b.WriteString(m.divider() + "\n") 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()) return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
} }
@@ -445,7 +445,7 @@ func (m *Model) initAlertHuhForm() tea.Cmd {
return m.huhForm.Init() return m.huhForm.Init()
} }
func (m *Model) submitAlertForm() { func (m *Model) submitAlertForm() tea.Cmd {
d := m.alertFormData d := m.alertFormData
settings := make(map[string]string) settings := make(map[string]string)
@@ -486,14 +486,16 @@ func (m *Model) submitAlertForm() {
settings["url"] = d.WebhookURL settings["url"] = d.WebhookURL
} }
if m.editID > 0 { st := m.store
if err := m.store.UpdateAlert(m.editID, d.Name, d.AlertType, settings); err != nil { id := m.editID
m.engine.AddLog("Update alert failed: " + err.Error()) name, aType := d.Name, d.AlertType
}
} else {
if err := m.store.AddAlert(d.Name, d.AlertType, settings); err != nil {
m.engine.AddLog("Add alert failed: " + err.Error())
}
}
m.state = stateDashboard m.state = stateDashboard
if id > 0 {
return writeCmd("Update alert", func() error {
return st.UpdateAlert(id, name, aType, settings)
})
}
return writeCmd("Add alert", func() error {
return st.AddAlert(name, aType, settings)
})
} }
+12 -12
View File
@@ -48,30 +48,30 @@ func isImportantLog(sev logSeverity) bool {
return sev == severityDown || sev == severityUp || sev == severitySystem return sev == severityDown || sev == severityUp || sev == severitySystem
} }
func renderLogTag(sev logSeverity) string { func (m Model) renderLogTag(sev logSeverity) string {
switch sev { switch sev {
case severityDown: case severityDown:
return dangerStyle.Render(" DOWN ") return m.st.dangerStyle.Render(" DOWN ")
case severityUp: case severityUp:
return specialStyle.Render(" UP ") return m.st.specialStyle.Render(" UP ")
case severityWarn: case severityWarn:
return warnStyle.Render(" WARN ") return m.st.warnStyle.Render(" WARN ")
case severitySystem: case severitySystem:
return titleStyle.Render(" SYS ") return m.st.titleStyle.Render(" SYS ")
default: 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) sev := classifyLog(line)
tag := renderLogTag(sev) tag := m.renderLogTag(sev)
ts := "" ts := ""
msg := line msg := line
if len(line) > 10 && line[0] == '[' { if len(line) > 10 && line[0] == '[' {
if idx := strings.Index(line, "]"); idx > 0 && idx < 12 { 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:]) msg = strings.TrimSpace(line[idx+1:])
} }
} }
@@ -103,7 +103,7 @@ func (m Model) viewLogsTab() string {
continue continue
} }
shown++ shown++
rendered = append(rendered, renderLogLine(line)) rendered = append(rendered, m.renderLogLine(line))
} }
filterLabel := "All" filterLabel := "All"
@@ -111,11 +111,11 @@ func (m Model) viewLogsTab() string {
filterLabel = "Important" filterLabel = "Important"
} }
header := subtleStyle.Render(fmt.Sprintf( header := m.st.subtleStyle.Render(fmt.Sprintf(
" %d entries Filter: %s", shown, filterLabel)) " %d entries Filter: %s", shown, filterLabel))
if m.logFilterImportant && shown < total { 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")) m.logViewport.SetContent(strings.Join(rendered, "\n"))
+18 -20
View File
@@ -9,11 +9,8 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
) )
var maintStyle lipgloss.Style
type maintFormData struct { type maintFormData struct {
Title string Title string
Description string Description string
@@ -23,22 +20,22 @@ type maintFormData struct {
CustomHours string CustomHours string
} }
func fmtMaintStatus(mw models.MaintenanceWindow) string { func (m Model) fmtMaintStatus(mw models.MaintenanceWindow) string {
now := time.Now() now := time.Now()
if mw.StartTime.After(now) { if mw.StartTime.After(now) {
return warnStyle.Render("SCHEDULED") return m.st.warnStyle.Render("SCHEDULED")
} }
if !mw.EndTime.IsZero() && mw.EndTime.Before(now) { 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" { 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 { 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) 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() { if t.IsZero() {
return subtleStyle.Render("—") return m.st.subtleStyle.Render("—")
} }
now := time.Now() now := time.Now()
if t.Year() == now.Year() && t.YearDay() == now.YearDay() { if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
@@ -120,11 +117,11 @@ func (m Model) viewMaintTab() string {
rows = append(rows, []string{ rows = append(rows, []string{
strconv.Itoa(i + 1), strconv.Itoa(i + 1),
m.zones.Mark(fmt.Sprintf("maint-%d", i), limitStr(mw.Title, titleW-2)), 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), fmtMaintMonitorW(mw.MonitorID, allSites, monW-2),
fmtMaintStatus(mw), m.fmtMaintStatus(mw),
fmtMaintTime(mw.StartTime, timeW), m.fmtMaintTime(mw.StartTime, timeW),
fmtMaintTime(mw.EndTime, timeW), m.fmtMaintTime(mw.EndTime, timeW),
}) })
} }
return rows return rows
@@ -209,7 +206,7 @@ func (m *Model) initMaintHuhForm() tea.Cmd {
return m.huhForm.Init() return m.huhForm.Init()
} }
func (m *Model) submitMaintForm() { func (m *Model) submitMaintForm() tea.Cmd {
d := m.maintFormData d := m.maintFormData
monitorID, _ := strconv.Atoi(d.MonitorID) monitorID, _ := strconv.Atoi(d.MonitorID)
@@ -240,8 +237,9 @@ func (m *Model) submitMaintForm() {
} }
} }
if err := m.store.AddMaintenanceWindow(mw); err != nil { st := m.store
m.engine.AddLog("Add maintenance window failed: " + err.Error())
}
m.state = stateDashboard m.state = stateDashboard
return writeCmd("Add maintenance window", func() error {
return st.AddMaintenanceWindow(mw)
})
} }
+11 -11
View File
@@ -33,14 +33,14 @@ func (m Model) viewNodesTab() string {
} }
region := node.Region region := node.Region
if region == "" { if region == "" {
region = subtleStyle.Render("—") region = m.st.subtleStyle.Render("—")
} }
lastSeen := fmtNodeLastSeen(node.LastSeen) lastSeen := m.fmtNodeLastSeen(node.LastSeen)
version := node.Version version := node.Version
if 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}) rows = append(rows, []string{name, region, lastSeen, version, status})
} }
return rows 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() { if lastSeen.IsZero() {
return subtleStyle.Render("UNKNOWN") return m.st.subtleStyle.Render("UNKNOWN")
} }
ago := time.Since(lastSeen) ago := time.Since(lastSeen)
if ago < 60*time.Second { if ago < 60*time.Second {
return specialStyle.Render("ONLINE") return m.st.specialStyle.Render("ONLINE")
} }
if ago < 5*time.Minute { 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 { func (m Model) fmtNodeLastSeen(t time.Time) string {
return fmtTimeAgo(t) return m.fmtTimeAgo(t)
} }
+26 -30
View File
@@ -11,8 +11,6 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
var siteGroupStyle lipgloss.Style
type siteFormData struct { type siteFormData struct {
Name string Name string
SiteType string SiteType string
@@ -182,7 +180,7 @@ func pickCols(active []colKey, allCells map[colKey]string) []string {
func (m Model) viewSitesTab() string { func (m Model) viewSitesTab() string {
if len(m.sites) == 0 { 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() layout := m.computeLayout()
@@ -219,12 +217,12 @@ func (m Model) viewSitesTab() string {
colNum: strconv.Itoa(i + 1), colNum: strconv.Itoa(i + 1),
colName: m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-4)), colName: m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-4)),
colType: "group", colType: "group",
colStatus: fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)), colStatus: m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
colLatency: subtleStyle.Render("—"), colLatency: m.st.subtleStyle.Render("—"),
colUptime: m.groupUptime(site.ID), colUptime: m.groupUptime(site.ID),
colHistory: m.groupSparkline(site.ID, sparkWidth, rowBg), colHistory: m.groupSparkline(site.ID, sparkWidth, rowBg),
colSSL: subtleStyle.Render("-"), colSSL: m.st.subtleStyle.Render("-"),
colRetries: subtleStyle.Render("—"), colRetries: m.st.subtleStyle.Render("—"),
} }
rows = append(rows, pickCols(layout.active, cells)) rows = append(rows, pickCols(layout.active, cells))
continue continue
@@ -251,28 +249,28 @@ func (m Model) viewSitesTab() string {
if tag != "" { if tag != "" {
errText = tag + " " + errText 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) hist, _ := m.engine.GetHistory(site.ID)
var spark string var spark string
if site.Type == "push" { if site.Type == "push" {
spark = heartbeatSparkline(hist.Statuses, sparkWidth, rowBg) spark = m.heartbeatSparkline(hist.Statuses, sparkWidth, rowBg)
} else { } else {
spark = latencySparkline(hist.Latencies, hist.Statuses, sparkWidth, rowBg) spark = m.latencySparkline(hist.Latencies, hist.Statuses, sparkWidth, rowBg)
} }
cells := map[colKey]string{ cells := map[colKey]string{
colNum: strconv.Itoa(i + 1), colNum: strconv.Itoa(i + 1),
colName: m.zones.Mark(fmt.Sprintf("site-%d", i), name), colName: m.zones.Mark(fmt.Sprintf("site-%d", i), name),
colType: typeIcon(site.Type, false) + " " + site.Type, colType: typeIcon(site.Type, false) + " " + site.Type,
colStatus: fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)), colStatus: m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
colLatency: fmtLatency(site.Latency), colLatency: m.fmtLatency(site.Latency),
colUptime: fmtUptime(hist.Statuses), colUptime: m.fmtUptime(hist.Statuses),
colHistory: spark, colHistory: spark,
colSSL: fmtSSL(site), colSSL: m.fmtSSL(site),
colRetries: fmtRetries(site), colRetries: m.fmtRetries(site),
} }
rows = append(rows, pickCols(layout.active, cells)) rows = append(rows, pickCols(layout.active, cells))
} }
@@ -281,7 +279,7 @@ func (m Model) viewSitesTab() string {
layout.colWidths, layout.colWidths,
func(row, col int) *lipgloss.Style { func(row, col int) *lipgloss.Style {
if groupRows[row] { if groupRows[row] {
s := siteGroupStyle s := m.st.siteGroupStyle
return &s return &s
} }
return nil return nil
@@ -327,15 +325,14 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
} }
} }
// m.alerts is the tab-data cache (≤5s stale) — no store IO in Update.
alertOpts := []huh.Option[string]{huh.NewOption("None", "0")} alertOpts := []huh.Option[string]{huh.NewOption("None", "0")}
if alerts, err := m.store.GetAllAlerts(); err == nil { for _, a := range m.alerts {
for _, a := range alerts {
alertOpts = append(alertOpts, huh.NewOption( alertOpts = append(alertOpts, huh.NewOption(
fmt.Sprintf("%s (%s)", a.Name, a.Type), fmt.Sprintf("%s (%s)", a.Name, a.Type),
strconv.Itoa(a.ID), strconv.Itoa(a.ID),
)) ))
} }
}
groupOpts := []huh.Option[string]{huh.NewOption("None", "0")} groupOpts := []huh.Option[string]{huh.NewOption("None", "0")}
for _, s := range m.sites { for _, s := range m.sites {
@@ -521,7 +518,7 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
return m.huhForm.Init() return m.huhForm.Init()
} }
func (m *Model) submitSiteForm() { func (m *Model) submitSiteForm() tea.Cmd {
d := m.siteFormData d := m.siteFormData
interval, _ := strconv.Atoi(d.Interval) interval, _ := strconv.Atoi(d.Interval)
alertID, _ := strconv.Atoi(d.AlertID) alertID, _ := strconv.Atoi(d.AlertID)
@@ -558,15 +555,14 @@ func (m *Model) submitSiteForm() {
Regions: d.Regions, Regions: d.Regions,
} }
if m.editID > 0 { st := m.store
if err := m.store.UpdateSite(site); err != nil {
m.engine.AddLog("Update site failed: " + err.Error())
}
m.engine.UpdateSiteConfig(site)
} else {
if err := m.store.AddSite(site); err != nil {
m.engine.AddLog("Add site failed: " + err.Error())
}
}
m.state = stateDashboard m.state = stateDashboard
if m.editID > 0 {
// The engine's in-memory config updates immediately; the DB write
// follows in the Cmd. New sites enter the engine via its poll loop
// once the insert lands.
m.engine.UpdateSiteConfig(site)
return writeCmd("Update site", func() error { return st.UpdateSite(site) })
}
return writeCmd("Add site", func() error { return st.AddSite(site) })
} }
+15 -13
View File
@@ -13,9 +13,9 @@ type userFormData struct {
Role string Role string
} }
func fmtRole(role string) string { func (m Model) fmtRole(role string) string {
if role == "admin" { if role == "admin" {
return specialStyle.Render(role) return m.st.specialStyle.Render(role)
} }
return role return role
} }
@@ -53,7 +53,7 @@ func (m Model) viewUsersTab() string {
rows = append(rows, []string{ rows = append(rows, []string{
fmt.Sprintf("%d", i+1), fmt.Sprintf("%d", i+1),
m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, userW-2)), m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, userW-2)),
fmtRole(u.Role), m.fmtRole(u.Role),
fmtKey(u.PublicKey), fmtKey(u.PublicKey),
}) })
} }
@@ -110,16 +110,18 @@ func (m *Model) initUserHuhForm() tea.Cmd {
return m.huhForm.Init() return m.huhForm.Init()
} }
func (m *Model) submitUserForm() { func (m *Model) submitUserForm() tea.Cmd {
d := m.userFormData d := m.userFormData
if m.editID > 0 { st := m.store
if err := m.store.UpdateUser(m.editID, d.Username, d.PublicKey, d.Role); err != nil { id := m.editID
m.engine.AddLog("Update user failed: " + err.Error()) username, key, role := d.Username, d.PublicKey, d.Role
}
} else {
if err := m.store.AddUser(d.Username, d.PublicKey, d.Role); err != nil {
m.engine.AddLog("Add user failed: " + err.Error())
}
}
m.state = stateUsers m.state = stateUsers
if id > 0 {
return writeCmd("Update user", func() error {
return st.UpdateUser(id, username, key, role)
})
}
return writeCmd("Add user", func() error {
return st.AddUser(username, key, role)
})
} }
+7 -15
View File
@@ -5,14 +5,6 @@ import (
"github.com/charmbracelet/lipgloss/table" "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 type StyleOverride func(row, col int) *lipgloss.Style
const ( const (
@@ -53,13 +45,13 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
t := table.New(). t := table.New().
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderStyle(tableBorderStyle). BorderStyle(m.st.tableBorderStyle).
Width(tableWidth). Width(tableWidth).
Headers(headers...). Headers(headers...).
Rows(rows...). Rows(rows...).
StyleFunc(func(row, col int) lipgloss.Style { StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow { if row == table.HeaderRow {
h := tableHeaderStyle h := m.st.tableHeaderStyle
if col < len(colWidths) && colWidths[col] > 0 { if col < len(colWidths) && colWidths[col] > 0 {
h = h.Width(colWidths[col]).MaxWidth(colWidths[col]) 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 { if s := styleOverride(row, col); s != nil {
style := *s style := *s
if row%2 == 1 { if row%2 == 1 {
style = style.Background(tableZebraStyle.GetBackground()) style = style.Background(m.st.tableZebraStyle.GetBackground())
} }
if isSelected { if isSelected {
style = tableSelectedStyle.Foreground(s.GetForeground()) style = m.st.tableSelectedStyle.Foreground(s.GetForeground())
} }
if col < len(colWidths) && colWidths[col] > 0 { if col < len(colWidths) && colWidths[col] > 0 {
style = style.Width(colWidths[col]).MaxWidth(colWidths[col]) 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 return style
} }
} }
base := tableCellStyle base := m.st.tableCellStyle
if row%2 == 1 { if row%2 == 1 {
base = tableZebraStyle base = m.st.tableZebraStyle
} }
if isSelected { if isSelected {
base = tableSelectedStyle base = m.st.tableSelectedStyle
} }
if col < len(colWidths) && colWidths[col] > 0 { if col < len(colWidths) && colWidths[col] > 0 {
base = base.Width(colWidths[col]).MaxWidth(colWidths[col]) base = base.Width(colWidths[col]).MaxWidth(colWidths[col])
+38 -23
View File
@@ -16,7 +16,10 @@ import (
zone "github.com/lrstanley/bubblezone" 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 subtleStyle lipgloss.Style
specialStyle lipgloss.Style specialStyle lipgloss.Style
warnStyle lipgloss.Style warnStyle lipgloss.Style
@@ -29,31 +32,41 @@ var (
sparkSuccess string sparkSuccess string
sparkWarning string sparkWarning string
sparkDanger string sparkDanger string
)
func applyTheme(t Theme) { tableHeaderStyle lipgloss.Style
subtleStyle = lipgloss.NewStyle().Foreground(t.Subtle) tableCellStyle lipgloss.Style
specialStyle = lipgloss.NewStyle().Foreground(t.Success) tableSelectedStyle lipgloss.Style
warnStyle = lipgloss.NewStyle().Foreground(t.Warning) tableBorderStyle lipgloss.Style
staleStyle = lipgloss.NewStyle().Foreground(t.Stale) tableZebraStyle lipgloss.Style
dangerStyle = lipgloss.NewStyle().Foreground(t.Danger)
sparkSuccess = string(t.Success) siteGroupStyle lipgloss.Style
sparkWarning = string(t.Warning) maintStyle lipgloss.Style
sparkDanger = string(t.Danger) }
titleStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true) func newStyles(t Theme) *styles {
activeTab = lipgloss.NewStyle().Background(t.Surface).Foreground(t.Accent).Bold(true).Padding(0, 1) return &styles{
inactiveTab = lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted) 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) sparkSuccess: string(t.Success),
tableCellStyle = lipgloss.NewStyle().Padding(0, 1) sparkWarning: string(t.Warning),
tableSelectedStyle = lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.SelectedFg).Background(t.SelectedBg) sparkDanger: string(t.Danger),
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) tableHeaderStyle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Padding(0, 1),
maintStyle = lipgloss.NewStyle().Foreground(t.Purple) 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{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} var pulseFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
@@ -108,6 +121,7 @@ type Model struct {
historyViewport viewport.Model historyViewport viewport.Model
historyChanges []models.StateChange historyChanges []models.StateChange
historySiteName string historySiteName string
historySiteID int
slaViewport viewport.Model slaViewport viewport.Model
slaReport monitor.SLAReport slaReport monitor.SLAReport
@@ -128,6 +142,7 @@ type Model struct {
engine *monitor.Engine engine *monitor.Engine
theme Theme theme Theme
themeIndex int themeIndex int
st *styles
// harmonica animation state // harmonica animation state
pulseSpring harmonica.Spring pulseSpring harmonica.Spring
@@ -141,6 +156,7 @@ type Model struct {
nodes []models.ProbeNode nodes []models.ProbeNode
maintenanceWindows []models.MaintenanceWindow maintenanceWindows []models.MaintenanceWindow
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle) lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
tabSeq int // seq of the newest issued tab-data load
// detail-panel state-change history, loaded on enter so View does no DB IO // detail-panel state-change history, loaded on enter so View does no DB IO
detailChanges []models.StateChange detailChanges []models.StateChange
@@ -174,8 +190,6 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri
} }
} }
applyTheme(theme)
return Model{ return Model{
state: stateDashboard, state: stateDashboard,
logViewport: vpLogs, logViewport: vpLogs,
@@ -188,6 +202,7 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri
collapsed: collapsed, collapsed: collapsed,
theme: theme, theme: theme,
themeIndex: themeIdx, themeIndex: themeIdx,
st: newStyles(theme),
demoMode: os.Getenv("UPTOP_DEMO") == "1", demoMode: os.Getenv("UPTOP_DEMO") == "1",
version: version, version: version,
sparkTooltipIdx: -1, sparkTooltipIdx: -1,
+116 -50
View File
@@ -20,9 +20,30 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tabDataMsg: case tabDataMsg:
return m.handleTabData(msg) return m.handleTabData(msg)
case detailDataMsg: case detailDataMsg:
// Drop replies for a site the user has already navigated away from,
// so a slow load can't clobber the panel currently on screen.
if m.state == stateDetail && m.cursor < len(m.sites) && m.sites[m.cursor].ID != msg.siteID {
return m, nil
}
m.detailChanges = msg.changes m.detailChanges = msg.changes
m.detailChangesSiteID = msg.siteID m.detailChangesSiteID = msg.siteID
return m, nil return m, nil
case historyDataMsg:
if msg.siteID != m.historySiteID {
return m, nil // stale reply for a previously opened history
}
m.historyChanges = msg.changes
m.historyViewport.SetContent(m.buildHistoryContent())
m.historyViewport.GotoTop()
return m, nil
case slaDataMsg:
return m.handleSLAData(msg)
case writeDoneMsg:
if msg.err != nil {
m.engine.AddLog(msg.op + " failed: " + msg.err.Error())
}
m.refreshLive()
return m, m.loadTabDataCmd()
} }
if m.state == stateConfirmDelete { if m.state == stateConfirmDelete {
@@ -48,27 +69,26 @@ func (m *Model) handleConfirmDelete(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
switch keyMsg.String() { switch keyMsg.String() {
case "y", "Y": case "y", "Y":
// The store delete runs in a Cmd; the in-memory engine/model updates
// stay here so the row vanishes immediately. If the delete fails, the
// writeDoneMsg reload converges the UI back to the DB state (and the
// engine poll loop re-adds a site that is still in the DB).
st := m.store
id := m.deleteID
var cmd tea.Cmd
switch m.deleteTab { switch m.deleteTab {
case 0: case 0:
if err := m.store.DeleteSite(m.deleteID); err != nil { cmd = writeCmd("Delete site", func() error { return st.DeleteSite(id) })
m.engine.AddLog("Delete site failed: " + err.Error()) m.engine.RemoveSite(id)
}
m.engine.RemoveSite(m.deleteID)
m.adjustCursor(len(m.sites) - 1) m.adjustCursor(len(m.sites) - 1)
case 1: case 1:
if err := m.store.DeleteAlert(m.deleteID); err != nil { cmd = writeCmd("Delete alert", func() error { return st.DeleteAlert(id) })
m.engine.AddLog("Delete alert failed: " + err.Error())
}
m.adjustCursor(len(m.alerts) - 1) m.adjustCursor(len(m.alerts) - 1)
case 4: case 4:
if err := m.store.DeleteMaintenanceWindow(m.deleteID); err != nil { cmd = writeCmd("Delete maintenance window", func() error { return st.DeleteMaintenanceWindow(id) })
m.engine.AddLog("Delete maintenance window failed: " + err.Error())
}
m.adjustCursor(len(m.maintenanceWindows) - 1) m.adjustCursor(len(m.maintenanceWindows) - 1)
case 5: case 5:
if err := m.store.DeleteUser(m.deleteID); err != nil { cmd = writeCmd("Delete user", func() error { return st.DeleteUser(id) })
m.engine.AddLog("Delete user failed: " + err.Error())
}
m.adjustCursor(len(m.users) - 1) m.adjustCursor(len(m.users) - 1)
} }
m.refreshLive() m.refreshLive()
@@ -76,7 +96,7 @@ func (m *Model) handleConfirmDelete(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.deleteTab == 5 { if m.deleteTab == 5 {
m.state = stateUsers m.state = stateUsers
} }
return m, m.loadTabDataCmd() return m, cmd
case "n", "N", "esc": case "n", "N", "esc":
m.state = stateDashboard m.state = stateDashboard
if m.deleteTab == 5 { if m.deleteTab == 5 {
@@ -112,10 +132,12 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
m.huhForm = f m.huhForm = f
} }
if m.huhForm.State == huh.StateCompleted { if m.huhForm.State == huh.StateCompleted {
m.submitForm() // The store write runs in the returned Cmd; its writeDoneMsg
// triggers the tab-data reload once the row actually exists.
cmd := m.submitForm()
m.refreshLive() m.refreshLive()
m.huhForm = nil m.huhForm = nil
return m, m.loadTabDataCmd() return m, cmd
} }
return m, formCmd return m, formCmd
} }
@@ -152,14 +174,31 @@ func (m *Model) handleTick(t time.Time) (tea.Model, tea.Cmd) {
if t.Sub(m.lastTabLoad) > tabRefreshTTL { if t.Sub(m.lastTabLoad) > tabRefreshTTL {
m.lastTabLoad = t m.lastTabLoad = t
cmds = append(cmds, m.loadTabDataCmd()) cmds = append(cmds, m.loadTabDataCmd())
if dc := m.detailRefreshCmd(); dc != nil {
cmds = append(cmds, dc)
}
} }
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} }
// handleTabData folds an async tab-data load into the model. On error the // detailRefreshCmd reloads the open detail panel's state-change list on the
// previous data is kept and the failure logged, so a transient store error // tab-data cadence, so a flap that happens while the panel is on screen shows
// never blanks the view. // up without leaving and re-entering. Nil when no detail panel is open.
func (m *Model) detailRefreshCmd() tea.Cmd {
if m.state != stateDetail || m.cursor >= len(m.sites) {
return nil
}
return m.loadDetailCmd(m.sites[m.cursor].ID)
}
// handleTabData folds an async tab-data load into the model. Replies older
// than the newest issued load are dropped so out-of-order completions can't
// overwrite fresher data. On error the previous data is kept and the failure
// logged, so a transient store error never blanks the view.
func (m *Model) handleTabData(msg tabDataMsg) (tea.Model, tea.Cmd) { func (m *Model) handleTabData(msg tabDataMsg) (tea.Model, tea.Cmd) {
if msg.seq != m.tabSeq {
return m, nil
}
if msg.err != nil { if msg.err != nil {
m.engine.AddLog("Tab data refresh failed: " + msg.err.Error()) m.engine.AddLog("Tab data refresh failed: " + msg.err.Error())
return m, nil return m, nil
@@ -324,18 +363,19 @@ func (m *Model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.cursor < len(m.sites) { if m.cursor < len(m.sites) {
site := m.sites[m.cursor] site := m.sites[m.cursor]
m.historySiteName = site.Name m.historySiteName = site.Name
m.historyChanges = m.engine.GetStateChanges(site.ID, 100) m.historySiteID = site.ID
m.historyChanges = nil
m.historyViewport = viewport.New( m.historyViewport = viewport.New(
m.termWidth-chromePadH, m.termWidth-chromePadH,
m.termHeight-10, m.termHeight-10,
) )
m.historyViewport.SetContent(m.buildHistoryContent()) m.historyViewport.SetContent("\n Loading state history...")
m.historyViewport.GotoTop()
m.state = stateHistory m.state = stateHistory
return m, m.loadHistoryCmd(site.ID)
} }
case "s": case "s":
if m.cursor < len(m.sites) { if m.cursor < len(m.sites) {
m.openSLAView(m.sites[m.cursor]) return m, m.openSLAView(m.sites[m.cursor])
} }
case "q": case "q":
return m, tea.Quit return m, tea.Quit
@@ -375,7 +415,7 @@ func (m *Model) handleSLAKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
idx := int(msg.String()[0]-'0') - 1 idx := int(msg.String()[0]-'0') - 1
if idx >= 0 && idx < len(slaPeriods) { if idx >= 0 && idx < len(slaPeriods) {
m.slaPeriodIdx = idx m.slaPeriodIdx = idx
m.recomputeSLA() return m, m.loadSLACmd(m.slaSiteID, idx)
} }
case "up", "k": case "up", "k":
m.slaViewport.ScrollUp(1) m.slaViewport.ScrollUp(1)
@@ -391,26 +431,39 @@ func (m *Model) handleSLAKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func (m *Model) openSLAView(site models.Site) { func (m *Model) openSLAView(site models.Site) tea.Cmd {
m.slaSiteName = site.Name m.slaSiteName = site.Name
m.slaSiteID = site.ID m.slaSiteID = site.ID
m.slaPeriodIdx = 2 // default 30d m.slaPeriodIdx = 2 // default 30d
m.recomputeSLA() m.slaViewport = viewport.New(
m.termWidth-chromePadH,
m.termHeight-16,
)
m.slaViewport.SetContent("\n Loading SLA report...")
m.state = stateSLA m.state = stateSLA
return m.loadSLACmd(site.ID, m.slaPeriodIdx)
} }
func (m *Model) recomputeSLA() { // handleSLAData folds an async SLA load into the model. The SLA math itself is
period := slaPeriods[m.slaPeriodIdx] // pure CPU and cheap, so it runs here; only the state-change read happens in
since := time.Now().Add(-period.duration) // the Cmd. Replies for a different site or period than currently selected are
changes := m.engine.GetStateChangesSince(m.slaSiteID, since) // stale and dropped.
func (m *Model) handleSLAData(msg slaDataMsg) (tea.Model, tea.Cmd) {
if msg.siteID != m.slaSiteID || msg.periodIdx != m.slaPeriodIdx {
return m, nil
}
period := slaPeriods[msg.periodIdx]
var currentStatus string var currentStatus string
if m.cursor < len(m.sites) { for _, s := range m.sites {
currentStatus = m.sites[m.cursor].Status if s.ID == msg.siteID {
currentStatus = s.Status
break
}
} }
m.slaReport = monitor.ComputeSLA(changes, currentStatus, period.duration) m.slaReport = monitor.ComputeSLA(msg.changes, currentStatus, period.duration)
m.slaDailyBreakdown = monitor.ComputeDailyBreakdown(changes, currentStatus, period.days, time.Now()) m.slaDailyBreakdown = monitor.ComputeDailyBreakdown(msg.changes, currentStatus, period.days, time.Now())
m.slaViewport = viewport.New( m.slaViewport = viewport.New(
m.termWidth-chromePadH, m.termWidth-chromePadH,
@@ -418,6 +471,7 @@ func (m *Model) recomputeSLA() {
) )
m.slaViewport.SetContent(m.buildSLADailyContent()) m.slaViewport.SetContent(m.buildSLADailyContent())
m.slaViewport.GotoTop() m.slaViewport.GotoTop()
return m, nil
} }
func (m *Model) handleHistoryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *Model) handleHistoryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
@@ -508,16 +562,22 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" { if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" {
gid := m.sites[m.cursor].ID gid := m.sites[m.cursor].ID
m.collapsed[gid] = !m.collapsed[gid] m.collapsed[gid] = !m.collapsed[gid]
saveCollapsed(m.store, m.collapsed) payload := collapsedJSON(m.collapsed)
st := m.store
m.refreshLive() m.refreshLive()
return m, writeCmd("Save collapsed groups", func() error {
return st.SetPreference("collapsed_groups", payload)
})
} }
case "p": case "p":
if m.currentTab == 0 && len(m.sites) > 0 { if m.currentTab == 0 && len(m.sites) > 0 {
site := m.sites[m.cursor] id := m.sites[m.cursor].ID
m.engine.ToggleSitePause(site.ID) paused := m.engine.ToggleSitePause(id)
site.Paused = !site.Paused st := m.store
_ = m.store.UpdateSitePaused(site.ID, site.Paused)
m.refreshLive() m.refreshLive()
return m, writeCmd("Update pause state", func() error {
return st.UpdateSitePaused(id, paused)
})
} }
case "i": case "i":
if m.currentTab == 0 && len(m.sites) > 0 { if m.currentTab == 0 && len(m.sites) > 0 {
@@ -532,18 +592,23 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
now := time.Now() now := time.Now()
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
if isActive { if isActive {
if err := m.store.EndMaintenanceWindow(mw.ID); err != nil { st := m.store
m.engine.AddLog("End maintenance failed: " + err.Error()) id := mw.ID
}
m.refreshLive() m.refreshLive()
return m, m.loadTabDataCmd() return m, writeCmd("End maintenance", func() error {
return st.EndMaintenanceWindow(id)
})
} }
} }
case "T": case "T":
m.themeIndex = (m.themeIndex + 1) % len(themes) m.themeIndex = (m.themeIndex + 1) % len(themes)
m.theme = themes[m.themeIndex] m.theme = themes[m.themeIndex]
applyTheme(m.theme) m.st = newStyles(m.theme)
_ = m.store.SetPreference("theme", m.theme.Name) st := m.store
name := m.theme.Name
return m, writeCmd("Save theme", func() error {
return st.SetPreference("theme", name)
})
case "d", "backspace": case "d", "backspace":
return m.handleDeleteItem() return m.handleDeleteItem()
} }
@@ -691,25 +756,26 @@ func (m *Model) adjustCursor(newLen int) {
} }
} }
func (m *Model) submitForm() { func (m *Model) submitForm() tea.Cmd {
switch m.state { switch m.state {
case stateFormSite: case stateFormSite:
if m.siteFormData != nil { if m.siteFormData != nil {
m.submitSiteForm() return m.submitSiteForm()
} }
case stateFormAlert: case stateFormAlert:
if m.alertFormData != nil { if m.alertFormData != nil {
m.submitAlertForm() return m.submitAlertForm()
} }
case stateFormUser: case stateFormUser:
if m.userFormData != nil { if m.userFormData != nil {
m.submitUserForm() return m.submitUserForm()
} }
case stateFormMaint: case stateFormMaint:
if m.maintFormData != nil { if m.maintFormData != nil {
m.submitMaintForm() return m.submitMaintForm()
} }
} }
return nil
} }
func (m Model) currentListLen() int { func (m Model) currentListLen() int {
+168 -1
View File
@@ -1,11 +1,13 @@
package tui package tui
import ( import (
"strings"
"testing" "testing"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
tea "github.com/charmbracelet/bubbletea"
zone "github.com/lrstanley/bubblezone" zone "github.com/lrstanley/bubblezone"
) )
@@ -18,6 +20,7 @@ type tuiMockStore struct {
maint []models.MaintenanceWindow maint []models.MaintenanceWindow
stateChanges []models.StateChange stateChanges []models.StateChange
stateChangeCalls int // counts GetStateChanges hits (to prove View does no IO) stateChangeCalls int // counts GetStateChanges hits (to prove View does no IO)
deleteSiteCalls int // counts DeleteSite hits (to prove writes run in Cmds)
} }
func (m *tuiMockStore) GetAllAlerts() ([]models.AlertConfig, error) { return m.alerts, nil } func (m *tuiMockStore) GetAllAlerts() ([]models.AlertConfig, error) { return m.alerts, nil }
@@ -36,7 +39,10 @@ func (m *tuiMockStore) GetSites() ([]models.Site, error)
func (m *tuiMockStore) AddSite(models.Site) error { return nil } func (m *tuiMockStore) AddSite(models.Site) error { return nil }
func (m *tuiMockStore) UpdateSite(models.Site) error { return nil } func (m *tuiMockStore) UpdateSite(models.Site) error { return nil }
func (m *tuiMockStore) UpdateSitePaused(int, bool) error { return nil } func (m *tuiMockStore) UpdateSitePaused(int, bool) error { return nil }
func (m *tuiMockStore) DeleteSite(int) error { return nil } func (m *tuiMockStore) DeleteSite(int) error {
m.deleteSiteCalls++
return nil
}
func (m *tuiMockStore) GetAlert(int) (models.AlertConfig, error) { return models.AlertConfig{}, nil } func (m *tuiMockStore) GetAlert(int) (models.AlertConfig, error) { return models.AlertConfig{}, nil }
func (m *tuiMockStore) AddAlert(string, string, map[string]string) error { return nil } func (m *tuiMockStore) AddAlert(string, string, map[string]string) error { return nil }
func (m *tuiMockStore) UpdateAlert(int, string, string, map[string]string) error { return nil } func (m *tuiMockStore) UpdateAlert(int, string, string, map[string]string) error { return nil }
@@ -99,6 +105,8 @@ func newTestModel(ms *tuiMockStore) Model {
isAdmin: true, isAdmin: true,
zones: zone.New(), zones: zone.New(),
detailChangesSiteID: -1, detailChangesSiteID: -1,
theme: themeFlexokiDark,
st: newStyles(themeFlexokiDark),
} }
} }
@@ -220,3 +228,162 @@ func TestHandleTick_ThrottlesTabLoad(t *testing.T) {
t.Errorf("tick past TTL should re-dispatch; lastTabLoad=%v want %v", mp.lastTabLoad, t2) t.Errorf("tick past TTL should re-dispatch; lastTabLoad=%v want %v", mp.lastTabLoad, t2)
} }
} }
// keyMsg builds a plain-rune key message ("h", "s", ...).
func keyMsg(s string) tea.KeyMsg {
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
}
func TestHandleTabData_DropsStaleSeq(t *testing.T) {
m := newTestModel(&tuiMockStore{})
mp := &m
_ = mp.loadTabDataCmd() // seq 1 (superseded)
_ = mp.loadTabDataCmd() // seq 2 (newest)
updated, _ := mp.handleTabData(tabDataMsg{seq: 1, alerts: []models.AlertConfig{{ID: 1}}})
if got := updated.(*Model); len(got.alerts) != 0 {
t.Error("stale tab-data reply was applied over a newer in-flight load")
}
updated, _ = mp.handleTabData(tabDataMsg{seq: 2, alerts: []models.AlertConfig{{ID: 2}}})
if got := updated.(*Model); len(got.alerts) != 1 || got.alerts[0].ID != 2 {
t.Error("fresh tab-data reply was not applied")
}
}
func TestHistoryKey_LoadsOffUIGoroutine(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{ID: 7, Name: "site"}}
m.state = stateDetail
m.termWidth, m.termHeight = 120, 40
updated, cmd := (&m).handleDetailKey(keyMsg("h"))
if ms.stateChangeCalls != 0 {
t.Fatal("history keypress hit the store synchronously in Update")
}
got := updated.(*Model)
if got.state != stateHistory || got.historySiteID != 7 {
t.Fatalf("history view not opened: state=%v siteID=%d", got.state, got.historySiteID)
}
if cmd == nil {
t.Fatal("expected a history load Cmd")
}
msg := cmd()
hd, ok := msg.(historyDataMsg)
if !ok || hd.siteID != 7 || len(hd.changes) != 1 {
t.Fatalf("unexpected historyDataMsg: %+v", msg)
}
folded, _ := got.Update(hd)
m2 := folded.(Model)
if len(m2.historyChanges) != 1 {
t.Fatal("history reply not folded into the model")
}
// A reply for a previously opened site must not clobber the current one.
m2.historySiteID = 9
stale, _ := m2.Update(historyDataMsg{siteID: 7, changes: nil})
if m3 := stale.(Model); len(m3.historyChanges) != 1 {
t.Error("stale history reply overwrote the current view")
}
}
func TestSLAData_DropsStaleReply(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.termWidth, m.termHeight = 120, 40
m.sites = []models.Site{{ID: 3, Status: "UP"}}
if cmd := (&m).openSLAView(m.sites[0]); cmd == nil {
t.Fatal("openSLAView should return a load Cmd")
}
// Reply for a different period than currently selected → dropped.
// (slaDataMsg routes through a pointer-receiver handler, so Update
// returns *Model on this path.)
updated, _ := m.Update(slaDataMsg{siteID: 3, periodIdx: 0})
if mm := updated.(*Model); mm.slaDailyBreakdown != nil {
t.Error("stale SLA reply (old period) was applied")
}
// Matching reply → report computed.
updated, _ = updated.(*Model).Update(slaDataMsg{siteID: 3, periodIdx: m.slaPeriodIdx})
if mm := updated.(*Model); mm.slaDailyBreakdown == nil {
t.Error("matching SLA reply was not applied")
}
}
func TestConfirmDelete_WritesOffUIGoroutine(t *testing.T) {
ms := &tuiMockStore{}
m := newTestModel(ms)
m.sites = []models.Site{{ID: 4, Name: "s"}}
m.state = stateConfirmDelete
m.deleteTab = 0
m.deleteID = 4
updated, cmd := (&m).handleConfirmDelete(keyMsg("y"))
if ms.deleteSiteCalls != 0 {
t.Fatal("delete hit the store synchronously in Update")
}
if cmd == nil {
t.Fatal("expected a write Cmd")
}
if got := updated.(*Model); got.state != stateDashboard {
t.Fatalf("expected return to dashboard, got state %v", got.state)
}
wd, ok := cmd().(writeDoneMsg)
if !ok || wd.err != nil {
t.Fatalf("unexpected write result: %+v", wd)
}
if ms.deleteSiteCalls != 1 {
t.Fatalf("expected exactly 1 store delete from the Cmd, got %d", ms.deleteSiteCalls)
}
}
func TestWriteDoneMsg_LogsErrorAndReloads(t *testing.T) {
m := newTestModel(&tuiMockStore{})
updated, cmd := m.Update(writeDoneMsg{op: "Delete site", err: errSentinel})
if cmd == nil {
t.Error("writeDoneMsg did not trigger a tab-data reload")
}
mm := updated.(Model)
found := false
for _, line := range mm.engine.GetLogs() {
if strings.Contains(line, "Delete site failed: boom") {
found = true
}
}
if !found {
t.Error("write error was not logged")
}
}
func TestDetailRefreshCmd_OnlyWhileDetailOpen(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{ID: 5, Name: "site"}}
m.state = stateDashboard
if (&m).detailRefreshCmd() != nil {
t.Error("refresh Cmd issued outside the detail view")
}
m.state = stateDetail
cmd := (&m).detailRefreshCmd()
if cmd == nil {
t.Fatal("open detail panel should refresh on the tab-data cadence")
}
dd, ok := cmd().(detailDataMsg)
if !ok || dd.siteID != 5 || len(dd.changes) != 1 {
t.Fatalf("unexpected detail refresh reply: %+v", dd)
}
m.cursor = 7 // cursor out of range → no refresh, no panic
if (&m).detailRefreshCmd() != nil {
t.Error("refresh Cmd issued for an out-of-range cursor")
}
}
+16 -16
View File
@@ -54,8 +54,8 @@ func (m Model) View() string {
case 5: case 5:
kind = "user" kind = "user"
} }
msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName)) msg := m.st.dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName))
hint := subtleStyle.Render("[y] Confirm [n] Cancel") hint := m.st.subtleStyle.Render("[y] Confirm [n] Cancel")
box := lipgloss.NewStyle(). box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(m.theme.Danger). BorderForeground(m.theme.Danger).
@@ -89,8 +89,8 @@ func (m Model) View() string {
formHeight = 5 formHeight = 5
} }
m.huhForm.WithHeight(formHeight) m.huhForm.WithHeight(formHeight)
header := titleStyle.Render(title) header := m.st.titleStyle.Render(title)
footer := subtleStyle.Render("\n[Esc] Cancel") footer := m.st.subtleStyle.Render("\n[Esc] Cancel")
return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer) return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer)
} }
return "" return ""
@@ -216,16 +216,16 @@ func (m Model) renderTabBar(stats dashboardStats) string {
if t.count > 0 { if t.count > 0 {
badge := countStyle.Render(fmt.Sprintf(" %d", t.count)) badge := countStyle.Render(fmt.Sprintf(" %d", t.count))
if t.warn > 0 { if t.warn > 0 {
badge = dangerStyle.Render(fmt.Sprintf(" %d", t.warn)) badge = m.st.dangerStyle.Render(fmt.Sprintf(" %d", t.warn))
} }
label += badge label += badge
} }
var rendered string var rendered string
if i == m.currentTab { if i == m.currentTab {
rendered = activeTab.Render(label) rendered = m.st.activeTab.Render(label)
} else { } else {
rendered = inactiveTab.Render(label) rendered = m.st.inactiveTab.Render(label)
} }
renderedTabs = append(renderedTabs, m.zones.Mark(fmt.Sprintf("tab-%d", i), rendered)) 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 { func (m Model) renderFooter(stats dashboardStats) string {
if m.filterMode { if m.filterMode {
cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│") 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 upCount := stats.totalMonitors - stats.downCount - stats.lateCount
var upStr string var upStr string
if stats.downCount > 0 { 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 { } 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 { } 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} statusParts := []string{upStr}
if stats.lateCount > 0 { 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 { if len(m.nodes) > 0 {
online := 0 online := 0
@@ -264,7 +264,7 @@ func (m Model) renderFooter(stats dashboardStats) string {
} }
statusParts = append(statusParts, fmt.Sprintf("%d %s", online, probeLabel)) 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 var keys string
switch m.currentTab { switch m.currentTab {
@@ -282,10 +282,10 @@ func (m Model) renderFooter(stats dashboardStats) string {
keys = "[T]Theme [Tab]Switch [q]Quit" keys = "[T]Theme [Tab]Switch [q]Quit"
} }
ver := subtleStyle.Render("v" + m.version) ver := m.st.subtleStyle.Render("v" + m.version)
footer := statusLine + " " + subtleStyle.Render(keys) + " " + ver footer := statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
if m.filterText != "" && m.currentTab == 0 { 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 return footer
} }
+42 -42
View File
@@ -24,26 +24,26 @@ func (m Model) viewDetailPanel() string {
if site.ParentID > 0 { if site.ParentID > 0 {
for _, s := range m.sites { for _, s := range m.sites {
if s.ID == site.ParentID { 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 break
} }
} }
} }
if breadcrumb == "" { 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(breadcrumb + "\n")
b.WriteString(m.divider() + "\n") b.WriteString(m.divider() + "\n")
row := func(label, value string) { 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) { 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 != "" { if (site.Status == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE" || site.Status == "STALE") && site.LastError != "" {
errWidth := m.termWidth - chromePadH - 19 errWidth := m.termWidth - chromePadH - 19
@@ -51,7 +51,7 @@ func (m Model) viewDetailPanel() string {
errWidth = 30 errWidth = 30
} }
wrapped := lipgloss.NewStyle().Width(errWidth).Render(site.LastError) 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 { if site.Type == "http" && site.StatusCode > 0 {
@@ -66,19 +66,19 @@ func (m Model) viewDetailPanel() string {
var icon string var icon string
switch step.Status { switch step.Status {
case stepPassed: case stepPassed:
icon = specialStyle.Render("✓") icon = m.st.specialStyle.Render("✓")
case stepFailed: case stepFailed:
icon = dangerStyle.Render("✗") icon = m.st.dangerStyle.Render("✗")
case stepSkipped: case stepSkipped:
icon = subtleStyle.Render("·") icon = m.st.subtleStyle.Render("·")
} }
line := fmt.Sprintf(" %s %-16s", icon, step.Name) line := fmt.Sprintf(" %s %-16s", icon, step.Name)
if step.Detail != "" { if step.Detail != "" {
switch step.Status { switch step.Status {
case stepFailed: case stepFailed:
line += " " + dangerStyle.Render(step.Detail) line += " " + m.st.dangerStyle.Render(step.Detail)
case stepSkipped: case stepSkipped:
line += " " + subtleStyle.Render(step.Detail) line += " " + m.st.subtleStyle.Render(step.Detail)
} }
} }
b.WriteString(line + "\n") b.WriteString(line + "\n")
@@ -99,7 +99,7 @@ func (m Model) viewDetailPanel() string {
if m.isMonitorInMaintenance(site.ID) { if m.isMonitorInMaintenance(site.ID) {
for _, mw := range m.maintenanceWindows { for _, mw := range m.maintenanceWindows {
if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) { 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 break
} }
} }
@@ -126,10 +126,10 @@ func (m Model) viewDetailPanel() string {
if site.Timeout > 0 { if site.Timeout > 0 {
row("Timeout", fmt.Sprintf("%ds", site.Timeout)) row("Timeout", fmt.Sprintf("%ds", site.Timeout))
} }
row("Latency", fmtLatency(site.Latency)) row("Latency", m.fmtLatency(site.Latency))
row("Uptime", fmtUptime(hist.Statuses)) row("Uptime", m.fmtUptime(hist.Statuses))
if !site.LastCheck.IsZero() { if !site.LastCheck.IsZero() {
row("Last Check", fmtTimeAgo(site.LastCheck)) row("Last Check", m.fmtTimeAgo(site.LastCheck))
} }
if site.Type == "http" { if site.Type == "http" {
@@ -142,16 +142,16 @@ func (m Model) viewDetailPanel() string {
codes = "200-299" codes = "200-299"
} }
row("Codes", codes) row("Codes", codes)
row("SSL", fmtSSL(site)) row("SSL", m.fmtSSL(site))
if site.IgnoreTLS { 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 != "" { if site.MaxRetries > 0 || site.Regions != "" || site.Description != "" {
section("CONFIG") section("CONFIG")
if site.MaxRetries > 0 { if site.MaxRetries > 0 {
row("Retries", fmtRetries(site)) row("Retries", m.fmtRetries(site))
} }
if site.Regions != "" { if site.Regions != "" {
row("Regions", site.Regions) row("Regions", site.Regions)
@@ -163,17 +163,17 @@ func (m Model) viewDetailPanel() string {
probeResults := m.engine.GetProbeResults(site.ID) probeResults := m.engine.GetProbeResults(site.ID)
if len(probeResults) > 0 { 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 { for nodeID, result := range probeResults {
status := specialStyle.Render("UP") status := m.st.specialStyle.Render("UP")
if !result.IsUp { if !result.IsUp {
status = dangerStyle.Render("DN") status = m.st.dangerStyle.Render("DN")
} }
latency := time.Duration(result.LatencyNs).Milliseconds() latency := time.Duration(result.LatencyNs).Milliseconds()
ago := time.Since(result.CheckedAt).Truncate(time.Second) ago := time.Since(result.CheckedAt).Truncate(time.Second)
line := fmt.Sprintf(" %-14s %s %dms %s ago", nodeID, status, latency, ago) line := fmt.Sprintf(" %-14s %s %dms %s ago", nodeID, status, latency, ago)
if !result.IsUp && result.ErrorReason != "" { if !result.IsUp && result.ErrorReason != "" {
line += " " + dangerStyle.Render(result.ErrorReason) line += " " + m.st.dangerStyle.Render(result.ErrorReason)
} }
b.WriteString(line + "\n") b.WriteString(line + "\n")
} }
@@ -185,31 +185,31 @@ func (m Model) viewDetailPanel() string {
stateChanges = m.detailChanges stateChanges = m.detailChanges
} }
if len(stateChanges) > 0 { 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 { for i, sc := range stateChanges {
ago := fmtDuration(time.Since(sc.ChangedAt)) ago := fmtDuration(time.Since(sc.ChangedAt))
arrow := subtleStyle.Render(sc.FromStatus) + " → " arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → "
if sc.ToStatus == "UP" { if sc.ToStatus == "UP" {
arrow += specialStyle.Render(sc.ToStatus) arrow += m.st.specialStyle.Render(sc.ToStatus)
} else { } 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 { 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" { if sc.ErrorReason != "" && sc.ToStatus != "UP" {
line += " " + dangerStyle.Render(sc.ErrorReason) line += " " + m.st.dangerStyle.Render(sc.ErrorReason)
} }
b.WriteString(line + "\n") 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") b.WriteString(m.divider() + "\n")
const sparkWidth = 40 const sparkWidth = 40
if site.Type == "push" { 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 { if len(hist.Statuses) > 0 {
up := 0 up := 0
for _, s := range hist.Statuses { for _, s := range hist.Statuses {
@@ -218,11 +218,11 @@ func (m Model) viewDetailPanel() string {
} }
} }
fmt.Fprintf(&b, "\n %s %d/%d checks up", fmt.Fprintf(&b, "\n %s %d/%d checks up",
subtleStyle.Render("Heartbeats"), m.st.subtleStyle.Render("Heartbeats"),
up, len(hist.Statuses)) up, len(hist.Statuses))
} }
} else { } 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 var minL, maxL, total time.Duration
count := 0 count := 0
for i, l := range hist.Latencies { for i, l := range hist.Latencies {
@@ -242,9 +242,9 @@ func (m Model) viewDetailPanel() string {
if count > 0 { if count > 0 {
avg := total / time.Duration(count) avg := total / time.Duration(count)
fmt.Fprintf(&b, "\n %s %dms %s %dms %s %dms", fmt.Fprintf(&b, "\n %s %dms %s %dms %s %dms",
subtleStyle.Render("Min"), minL.Milliseconds(), m.st.subtleStyle.Render("Min"), minL.Milliseconds(),
subtleStyle.Render("Avg"), avg.Milliseconds(), m.st.subtleStyle.Render("Avg"), avg.Milliseconds(),
subtleStyle.Render("Max"), maxL.Milliseconds()) m.st.subtleStyle.Render("Max"), maxL.Milliseconds())
} }
} }
@@ -254,7 +254,7 @@ func (m Model) viewDetailPanel() string {
b.WriteString("\n") b.WriteString("\n")
b.WriteString(m.divider() + "\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()) 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) { 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 idx < len(hist.Statuses) {
if hist.Statuses[idx] { if hist.Statuses[idx] {
parts = append(parts, specialStyle.Render("UP")) parts = append(parts, m.st.specialStyle.Render("UP"))
} else { } else {
parts = append(parts, dangerStyle.Render("DOWN")) parts = append(parts, m.st.dangerStyle.Render("DOWN"))
} }
} }
sep := subtleStyle.Render(" | ") sep := m.st.subtleStyle.Render(" | ")
pos := subtleStyle.Render(fmt.Sprintf("[%d/%d]", idx+1, dataLen)) pos := m.st.subtleStyle.Render(fmt.Sprintf("[%d/%d]", idx+1, dataLen))
return " " + strings.Join(parts, sep) + " " + pos return " " + strings.Join(parts, sep) + " " + pos
} }
+21 -21
View File
@@ -49,7 +49,7 @@ func computeHistoryStats(changes []models.StateChange) historyStats {
var stateChangeChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} 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 { if len(changes) < 2 || width < 4 {
return "" return ""
} }
@@ -96,11 +96,11 @@ func stateChangeSparkline(changes []models.StateChange, width int) string {
ch := string(stateChangeChars[idx]) ch := string(stateChangeChars[idx])
switch { switch {
case v >= 3: case v >= 3:
sb.WriteString(dangerStyle.Render(ch)) sb.WriteString(m.st.dangerStyle.Render(ch))
case v >= 2: case v >= 2:
sb.WriteString(warnStyle.Render(ch)) sb.WriteString(m.st.warnStyle.Render(ch))
default: default:
sb.WriteString(subtleStyle.Render(ch)) sb.WriteString(m.st.subtleStyle.Render(ch))
} }
} }
return sb.String() return sb.String()
@@ -120,26 +120,26 @@ func (m Model) buildHistoryContent() string {
for i, sc := range m.historyChanges { for i, sc := range m.historyChanges {
ts := sc.ChangedAt.Format("2006-01-02 15:04") ts := sc.ChangedAt.Format("2006-01-02 15:04")
arrow := subtleStyle.Render(sc.FromStatus) + " → " arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → "
switch sc.ToStatus { switch sc.ToStatus {
case "UP": case "UP":
arrow += specialStyle.Render(sc.ToStatus) arrow += m.st.specialStyle.Render(sc.ToStatus)
case "LATE": case "LATE":
arrow += warnStyle.Render(sc.ToStatus) arrow += m.st.warnStyle.Render(sc.ToStatus)
case "STALE": case "STALE":
arrow += staleStyle.Render(sc.ToStatus) arrow += m.st.staleStyle.Render(sc.ToStatus)
default: default:
arrow += dangerStyle.Render(sc.ToStatus) arrow += m.st.dangerStyle.Render(sc.ToStatus)
} }
durStr := "" durStr := ""
if dur := computeOutageDuration(m.historyChanges, i); dur > 0 { if dur := computeOutageDuration(m.historyChanges, i); dur > 0 {
durStr = warnStyle.Render("outage " + fmtDuration(dur)) durStr = m.st.warnStyle.Render("outage " + fmtDuration(dur))
} }
reason := "" reason := ""
if sc.ErrorReason != "" && sc.ToStatus != "UP" { 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) 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 { func (m Model) viewHistoryPanel() string {
var b strings.Builder var b strings.Builder
header := " " + titleStyle.Render("STATE HISTORY: "+m.historySiteName) header := " " + m.st.titleStyle.Render("STATE HISTORY: "+m.historySiteName)
header += " " + subtleStyle.Render("[q] Back") header += " " + m.st.subtleStyle.Render("[q] Back")
b.WriteString(header + "\n") b.WriteString(header + "\n")
divWidth := m.dividerWidth() divWidth := m.dividerWidth()
b.WriteString(m.divider() + "\n") b.WriteString(m.divider() + "\n")
sparkline := stateChangeSparkline(m.historyChanges, divWidth) sparkline := m.stateChangeSparkline(m.historyChanges, divWidth)
if sparkline != "" { if sparkline != "" {
b.WriteString(" " + sparkline + "\n") b.WriteString(" " + sparkline + "\n")
b.WriteString(m.divider() + "\n") b.WriteString(m.divider() + "\n")
} }
fmt.Fprintf(&b, " %-18s %-17s %-12s %s\n", fmt.Fprintf(&b, " %-18s %-17s %-12s %s\n",
subtleStyle.Render("TIME"), m.st.subtleStyle.Render("TIME"),
subtleStyle.Render("TRANSITION"), m.st.subtleStyle.Render("TRANSITION"),
subtleStyle.Render("DURATION"), m.st.subtleStyle.Render("DURATION"),
subtleStyle.Render("REASON")) m.st.subtleStyle.Render("REASON"))
if len(m.historyChanges) == 0 { 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 { } else {
b.WriteString(m.historyViewport.View()) b.WriteString(m.historyViewport.View())
} }
@@ -185,8 +185,8 @@ func (m Model) viewHistoryPanel() string {
avg := stats.totalDowntime / time.Duration(stats.outageCount) avg := stats.totalDowntime / time.Duration(stats.outageCount)
parts = append(parts, "avg outage "+fmtDuration(avg)) parts = append(parts, "avg outage "+fmtDuration(avg))
} }
b.WriteString(" " + subtleStyle.Render(strings.Join(parts, " │ ")) + "\n") b.WriteString(" " + m.st.subtleStyle.Render(strings.Join(parts, " │ ")) + "\n")
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()) return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
} }
+4 -4
View File
@@ -134,14 +134,14 @@ func TestComputeHistoryStats_Empty(t *testing.T) {
func TestStateChangeSparkline(t *testing.T) { func TestStateChangeSparkline(t *testing.T) {
t.Run("empty", func(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.Errorf("expected empty for nil, got %q", got)
} }
}) })
t.Run("single event", func(t *testing.T) { t.Run("single event", func(t *testing.T) {
changes := []models.StateChange{{ChangedAt: time.Now()}} 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) t.Errorf("expected empty for single event, got %q", got)
} }
}) })
@@ -152,7 +152,7 @@ func TestStateChangeSparkline(t *testing.T) {
{ChangedAt: now}, {ChangedAt: now},
{ChangedAt: now.Add(-1 * time.Hour)}, {ChangedAt: now.Add(-1 * time.Hour)},
} }
got := stateChangeSparkline(changes, 20) got := styledModel.stateChangeSparkline(changes, 20)
if got == "" { if got == "" {
t.Error("expected non-empty sparkline for two events") t.Error("expected non-empty sparkline for two events")
} }
@@ -164,7 +164,7 @@ func TestStateChangeSparkline(t *testing.T) {
{ChangedAt: now}, {ChangedAt: now},
{ChangedAt: now.Add(-1 * time.Hour)}, {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) t.Errorf("expected empty for width 3, got %q", got)
} }
}) })
+25 -25
View File
@@ -24,13 +24,13 @@ var slaPeriods = []struct {
func (m Model) viewSLAPanel() string { func (m Model) viewSLAPanel() string {
var b strings.Builder var b strings.Builder
header := " " + titleStyle.Render("SLA REPORT: "+m.slaSiteName) header := " " + m.st.titleStyle.Render("SLA REPORT: "+m.slaSiteName)
header += " " + subtleStyle.Render("[q] Back") header += " " + m.st.subtleStyle.Render("[q] Back")
b.WriteString(header + "\n") b.WriteString(header + "\n")
b.WriteString(m.divider() + "\n") b.WriteString(m.divider() + "\n")
period := slaPeriods[m.slaPeriodIdx] 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 r := m.slaReport
@@ -38,22 +38,22 @@ func (m Model) viewSLAPanel() string {
if barWidth < 10 { if barWidth < 10 {
barWidth = 10 barWidth = 10
} }
bar := uptimeBar(r.UptimePct, barWidth) bar := m.uptimeBar(r.UptimePct, barWidth)
uptimeColor := specialStyle uptimeColor := m.st.specialStyle
if r.UptimePct < 99.9 { if r.UptimePct < 99.9 {
uptimeColor = warnStyle uptimeColor = m.st.warnStyle
} }
if r.UptimePct < 99.0 { 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 %s\n", m.st.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 %s\n", m.st.subtleStyle.Render("Downtime"), fmtDuration(r.Downtime))
fmt.Fprintf(&b, " %-16s %d\n", subtleStyle.Render("Outages"), r.OutageCount) fmt.Fprintf(&b, " %-16s %d\n", m.st.subtleStyle.Render("Outages"), r.OutageCount)
if r.OutageCount > 0 { if r.OutageCount > 0 {
fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render("Longest"), fmtDuration(r.LongestOut)) fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Longest"), fmtDuration(r.LongestOut))
fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render("MTTR"), fmtDuration(r.MTTR)) fmt.Fprintf(&b, " %-16s %s\n", m.st.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("MTBF"), fmtDuration(r.MTBF))
} }
b.WriteString("\n" + m.divider() + "\n") b.WriteString("\n" + m.divider() + "\n")
@@ -68,13 +68,13 @@ func (m Model) viewSLAPanel() string {
for i, p := range slaPeriods { for i, p := range slaPeriods {
label := fmt.Sprintf("[%s] %s", p.key, p.label) label := fmt.Sprintf("[%s] %s", p.key, p.label)
if i == m.slaPeriodIdx { if i == m.slaPeriodIdx {
keys = append(keys, titleStyle.Render(label)) keys = append(keys, m.st.titleStyle.Render(label))
} else { } else {
keys = append(keys, subtleStyle.Render(label)) keys = append(keys, m.st.subtleStyle.Render(label))
} }
} }
b.WriteString(" " + strings.Join(keys, " ")) 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()) return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
} }
@@ -87,27 +87,27 @@ func (m Model) buildSLADailyContent() string {
barWidth = 10 barWidth = 10
} }
b.WriteString(" " + subtleStyle.Render("DAILY BREAKDOWN") + "\n") b.WriteString(" " + m.st.subtleStyle.Render("DAILY BREAKDOWN") + "\n")
for _, day := range m.slaDailyBreakdown { for _, day := range m.slaDailyBreakdown {
dateStr := day.Date.Format("Jan 02") dateStr := day.Date.Format("Jan 02")
bar := uptimeBar(day.UptimePct, barWidth) bar := m.uptimeBar(day.UptimePct, barWidth)
pctStr := fmtPct(day.UptimePct) + "%" pctStr := fmtPct(day.UptimePct) + "%"
color := specialStyle color := m.st.specialStyle
if day.UptimePct < 99.9 { if day.UptimePct < 99.9 {
color = warnStyle color = m.st.warnStyle
} }
if day.UptimePct < 99.0 { 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() 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))) filled := int(math.Round(pct / 100 * float64(width)))
if filled > width { if filled > width {
filled = width filled = width
@@ -117,9 +117,9 @@ func uptimeBar(pct float64, width int) string {
} }
empty := width - filled empty := width - filled
bar := specialStyle.Render(strings.Repeat("█", filled)) bar := m.st.specialStyle.Render(strings.Repeat("█", filled))
if empty > 0 { if empty > 0 {
bar += subtleStyle.Render(strings.Repeat("░", empty)) bar += m.st.subtleStyle.Render(strings.Repeat("░", empty))
} }
return bar return bar
} }