chore(tui): polish demo + regenerate screenshots
CI / test (pull_request) Successful in 2m45s
CI / lint (pull_request) Successful in 1m4s
CI / vulncheck (pull_request) Successful in 56s

Rework the VHS demo so the README screenshots actually entice a download.

Demo data / tooling:
- seed.yaml: real, reachable service URLs (detail now shows nextcloud.com,
  not example.com); Auth Portal -> non-resolving home.arpa host so it reads
  as a believable, reliably-DOWN monitor
- backfill: transient outages for Nextcloud/Jellyfin/Immich aligned with their
  state changes (uptime % now matches); log timestamps derived from now so the
  Logs view reads chronologically; real SSL warning; three probe nodes across
  regions; seeded alert send health
- demo.tape: shorter warm-up, added Nodes + theme captures, ordered so every
  shot stays inside the 60s node-freshness window (consistent probe count)
- vhs/crop: new tool to trim the empty terminal border around each screenshot
- setup.sh: build backfill up front for deterministic timing; UPTOP_DEMO=1

Supporting code:
- persist alert send health (new alert_health table, load on startup,
  best-effort save on send) so health/last-sent survive restarts
- latency Min/Avg/Max ignore failed checks (no more "Min 0ms")
- correct "probe"/"probes" pluralization
- stable status dot instead of an animated spinner under UPTOP_DEMO
This commit is contained in:
2026-05-28 22:32:45 -04:00
parent 9c7ed284b3
commit 03cbe283df
25 changed files with 483 additions and 99 deletions
+19 -12
View File
@@ -959,19 +959,26 @@ func (m Model) viewDetailPanel() string {
}
} else {
b.WriteString(" " + latencySparkline(hist.Latencies, hist.Statuses, sparkWidth))
if len(hist.Latencies) > 0 {
minL, maxL := hist.Latencies[0], hist.Latencies[0]
var total time.Duration
for _, l := range hist.Latencies {
total += l
if l < minL {
minL = l
}
if l > maxL {
maxL = l
}
// Stats over successful checks only — a failed check is stored as 0ns latency
// and would otherwise drag Min to 0ms and skew the average.
var minL, maxL, total time.Duration
count := 0
for i, l := range hist.Latencies {
if i < len(hist.Statuses) && !hist.Statuses[i] {
continue
}
avg := total / time.Duration(len(hist.Latencies))
if count == 0 {
minL, maxL = l, l
} else if l < minL {
minL = l
} else if l > maxL {
maxL = l
}
total += l
count++
}
if count > 0 {
avg := total / time.Duration(count)
fmt.Fprintf(&b, "\n %s %dms %s %dms %s %dms",
subtleStyle.Render("Min"), minL.Milliseconds(),
subtleStyle.Render("Avg"), avg.Milliseconds(),
+24 -6
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"math"
"os"
"sort"
"strings"
"time"
@@ -122,6 +123,10 @@ type Model struct {
filterMode bool
filterText string
// demoMode renders a stable status dot instead of the animated pulse so
// screenshots/recordings don't capture the spinner mid-frame. Set via UPTOP_DEMO=1.
demoMode bool
}
func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
@@ -155,6 +160,7 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
collapsed: collapsed,
theme: theme,
themeIndex: themeIdx,
demoMode: os.Getenv("UPTOP_DEMO") == "1",
}
}
@@ -754,11 +760,6 @@ func (m *Model) submitForm() {
}
func (m Model) pulseIndicator() string {
frame := m.tickCount % len(pulseFrames)
brightness := int(m.pulsePos*155) + 100
if brightness > 255 {
brightness = 255
}
hasDown := false
for _, s := range m.sites {
if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") {
@@ -766,6 +767,19 @@ func (m Model) pulseIndicator() string {
break
}
}
// Stills can't show animation: render a stable status dot in demo mode.
if m.demoMode {
c := m.theme.Success
if hasDown {
c = m.theme.Danger
}
return lipgloss.NewStyle().Foreground(c).Render("●")
}
frame := m.tickCount % len(pulseFrames)
brightness := int(m.pulsePos*155) + 100
if brightness > 255 {
brightness = 255
}
var color string
if hasDown {
color = fmt.Sprintf("#%02x%02x%02x", brightness, brightness/4, brightness/4)
@@ -953,7 +967,11 @@ func (m Model) viewDashboard() string {
online++
}
}
statusParts = append(statusParts, fmt.Sprintf("%d probes", online))
probeLabel := "probes"
if online == 1 {
probeLabel = "probe"
}
statusParts = append(statusParts, fmt.Sprintf("%d %s", online, probeLabel))
}
statusLine := strings.Join(statusParts, subtleStyle.Render(" · "))