diff --git a/.gitignore b/.gitignore index a958ed7..4691cb7 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,7 @@ go.work # End of https://www.toptal.com/developers/gitignore/api/go /uptop -uptop.db +uptop.db* .ssh diff --git a/CHANGELOG.md b/CHANGELOG.md index cb9f2a1..1f9dcdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,46 +1,88 @@ # Changelog -## [2026.05.2] — 2026-05-23 +## [2026.05.5] — 2026-05-29 ### Added -- Comprehensive test suite (94 tests across monitor, server, cluster) -- golangci-lint config with CI enforcement -- Gitea Actions CI pipeline (test + lint) -- Graceful shutdown for HTTP and SSH servers -- Context-aware alert delivery with timeout -- Request size limits on all POST endpoints -- Constant-time secret comparison -- Check interval jitter to prevent thundering herd -- `--version` flag with build metadata injection +- Error reason display when monitors go DOWN (#33) +- Push monitor lifecycle — PENDING, LATE, DOWN states (#34) +- Logs tab overhaul — severity tags, filtering, recovery durations (#35) +- Alert channel health indicator and test alerts (#36) -### Fixed -- Silent JSON unmarshal failures in alert settings -- Panic on crypto/rand failure replaced with error return -- Alert delivery errors now logged instead of swallowed -- log.Fatalf in goroutines replaced with log.Printf -- Deprecated LineUp/LineDown API calls +### Changed +- Visual polish — detail sections, column headers, alert detail (#37) + +## [2026.05.4] — 2026-05-27 + +### Added +- SSH user seeding from `UPTOP_ADMIN_KEY` env var and `UPTOP_KEYS` file (#31) +- GoReleaser for binary releases +- govulncheck in CI pipeline +- Multi-arch Docker builds (amd64 + arm64) + +### Changed +- CI overhaul — Go 1.26, build caching, streamlined pipeline (#30) +- Bumped golang.org/x/crypto v0.47.0 → v0.52.0 +- Bumped Alpine 3.21 → 3.23 ### Security -- Cluster secret compared with crypto/subtle (timing-safe) -- http.MaxBytesReader on all JSON endpoints -- ReadHeaderTimeout added to HTTP server +- Phase 1: SSRF protection, input validation, safe dial (#26) +- Phase 2: TLS hardening, auth bypass fixes, rate limiting (#27) +- Phase 3: Graceful degradation, connection limits, timeout enforcement (#28) +- Phase 4: Code quality, error handling, linter fixes (#29) -## [2026.05.1] — 2026-05-14 +## [2026.05.3] — 2026-05-25 + +### Added +- Theme system with 5 dark palettes — Default, Dracula, Nord, Tokyo Night, Gruvbox (#24) +- `--version` flag with build metadata injection +- Gitea Actions CI pipeline — test + lint (#20) +- golangci-lint configuration +- Comprehensive test suite — 94 tests across monitor, server, cluster (#19) +- CONTRIBUTING.md and SECURITY.md + +### Changed +- Renamed project from go-upkeep to uptop (#25) +- Updated LICENSE with dual copyright for independent fork + +### Fixed +- Form validators scoped to relevant monitor types (#23) +- Graceful shutdown for HTTP, SSH servers and database (#19) +- Constant-time secret comparison, request size limits (#19) +- Check interval jitter to prevent thundering herd (#19) +- TUI visual polish — zebra striping, group icons, sparkline stats (#18) + +## [2026.05.2] — 2026-05-22 + +### Added +- Incident management and maintenance windows (#17) +- Production docker-compose.yml + +### Fixed +- Viewport sizing and dynamic chrome calculation (#16) +- Form height constrained to terminal with resize forwarding +- Maintenance'd monitors excluded from down count and pulse +- Group status correctly skips children in maintenance + +## [2026.05.1] — 2026-05-16 ### Added - Distributed probing with leader + probe nodes -- Config-as-code (YAML apply/export with dry-run, prune) -- TUI visual polish (zebra striping, sparklines, breadcrumbs) -- Incident management and maintenance windows -- 9 alert providers (Discord, Slack, Email, Ntfy, Telegram, PagerDuty, Pushover, Gotify, Webhook) +- Config-as-code — YAML apply/export with dry-run and prune +- TUI polish — status bar, tab badges, detail panel, modals +- DOWN-first sort, health pulse, site filter +- Type icons in sites table +- Sparkline history graphs +- Persistent state — uptime, status, latency, and logs survive restarts +- Push token stripping from /status/json response -## [2026.04.1] — Initial independent fork +## [2026.04.1] — 2026-04-01 ### Added -- SSH-accessible TUI (Bubble Tea + Wish) -- 6 check types (HTTP, Push, Ping, Port, DNS, Group) +- SSH-accessible TUI built on Bubble Tea + Wish +- 6 check types — HTTP, Push, Ping, Port, DNS, Group +- 9 alert providers — Discord, Slack, Email, Ntfy, Telegram, PagerDuty, Pushover, Gotify, Webhook - SQLite and PostgreSQL support - HA clustering with automatic failover -- Prometheus metrics endpoint -- Public status page -- Uptime Kuma import +- Prometheus /metrics endpoint +- Public status page (HTML + JSON) +- Uptime Kuma backup import diff --git a/README.md b/README.md index 6553aac..270e267 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,49 @@ -# uptop +
+

uptop

+

Self-hosted uptime monitoring with a TUI over SSH.

+

No browser. No client install. Just ssh -p 23234 your-server.

-Self-hosted uptime monitor with a TUI you can access over SSH. No browser, no install on the client — just `ssh -p 23234 your-server`. +

+ MIT License + Go 1.26 + Docker Pulls +

-Built on the foundation of [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep). + uptop monitors view +
-## What it does +## What is this -- **6 check types**: HTTP, Push (heartbeat), Ping, Port, DNS, Groups -- **9 alert providers**: Discord, Slack, Email, Ntfy, Webhook, Telegram, PagerDuty, Pushover, Gotify -- **Config as code**: define monitors in YAML, apply declaratively, version control your setup -- **HA clustering**: leader/follower with automatic failover -- **Prometheus metrics**: `/metrics` endpoint for Grafana dashboards -- **Public status page**: HTML + JSON, toggle with an env var -- **SQLite or Postgres**: SQLite for single-node, Postgres for production -- **Uptime Kuma import**: migrate from Kuma with one command +An uptime monitor you manage entirely from the terminal. It runs as a server, exposes an SSH endpoint, and drops you into a full TUI — monitors, alerts, logs, nodes, all there. + +Built on [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep). Rewritten for clustering, config-as-code, and a proper dashboard. + +## Features + +- **6 check types** — HTTP, Push (heartbeat), Ping, Port, DNS, Groups +- **9 alert providers** — Discord, Slack, Email, Ntfy, Webhook, Telegram, PagerDuty, Pushover, Gotify +- **Config as code** — define monitors in YAML, apply declaratively, version control your setup +- **HA clustering** — leader/follower with automatic failover +- **Prometheus metrics** — `/metrics` endpoint, wire it straight to Grafana +- **Public status page** — HTML + JSON, toggle with an env var +- **SQLite or Postgres** — SQLite for single-node, Postgres for production +- **Uptime Kuma import** — migrate from Kuma with one command + +## Screenshots + + + + + + + + + + + + + +
detail panelalerts view
logs viewcluster nodes
theme selection
## Quick start @@ -22,7 +52,7 @@ go run cmd/uptop/main.go ssh -p 23234 localhost ``` -Seed some demo data to see it in action: +Want some data to look at first: ```bash go run cmd/uptop/main.go -demo @@ -30,22 +60,45 @@ go run cmd/uptop/main.go -demo ## Install -### From source +
+Docker (recommended) + +```yaml +services: + uptop: + image: lerkolabs/uptop:latest + restart: unless-stopped + ports: + - "23234:23234" + - "8080:8080" + environment: + - UPTOP_DB_TYPE=sqlite + - UPTOP_DB_DSN=/data/uptop.db + - UPTOP_STATUS_ENABLED=true + # - UPTOP_ADMIN_KEY=ssh-ed25519 AAAA... you@host + volumes: + - ./data:/data +``` + +First run: set `UPTOP_ADMIN_KEY` to your SSH public key, or attach to the container and add it in the Users tab. + +
+ +
+Binary + +Download from [Releases](https://gitea.lerkolabs.com/lerko/uptop/releases). + +
+ +
+From source ```bash go install gitea.lerkolabs.com/lerko/uptop/cmd/uptop@latest ``` -### Docker - -```bash -docker pull lerko/uptop:latest -docker run -p 23234:23234 -p 8080:8080 -v ./data:/data lerko/uptop -``` - -### Binary - -Download from [Releases](https://gitea.lerkolabs.com/lerko/uptop/releases). +
## Config as code @@ -63,35 +116,11 @@ uptop apply -f monitors.yaml --dry-run # see what would change uptop apply -f monitors.yaml --prune # delete anything not in the YAML ``` -See [docs/config-as-code.md](docs/config-as-code.md) for the full reference. - -## Docker - -```yaml -services: - monitor: - build: . - restart: unless-stopped - stdin_open: true - tty: true - ports: - - "23234:23234" - - "8080:8080" - volumes: - - ./data:/data - - ./ssh_keys:/app/.ssh - environment: - - UPTOP_DB_TYPE=sqlite - - UPTOP_DB_DSN=/data/uptop.db - - UPTOP_STATUS_ENABLED=true - - UPTOP_CLUSTER_SECRET=change-me -``` - -First run: attach to the container (`docker attach uptop`), go to the Users tab, add your SSH public key. Then detach with `Ctrl+P, Ctrl+Q` and connect normally over SSH. +Full reference in [docs/config-as-code.md](docs/config-as-code.md). ## Environment variables -| Variable | Default | What it does | +| Variable | Default | Description | |---|---|---| | `UPTOP_PORT` | `23234` | SSH server port | | `UPTOP_HTTP_PORT` | `8080` | HTTP server port (status page, push, metrics) | @@ -103,6 +132,7 @@ First run: attach to the container (`docker attach uptop`), go to the Users tab, | `UPTOP_PEER_URL` | | Leader URL for follower nodes | | `UPTOP_CLUSTER_SECRET` | | Shared key for cluster + API auth | | `UPTOP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks | +| `UPTOP_ADMIN_KEY` | | SSH public key seeded as first admin on startup | ## Migrating from Uptime Kuma diff --git a/assets/alerts.png b/assets/alerts.png new file mode 100644 index 0000000..9a4e0fe Binary files /dev/null and b/assets/alerts.png differ diff --git a/assets/detail.png b/assets/detail.png new file mode 100644 index 0000000..0c8e8ca Binary files /dev/null and b/assets/detail.png differ diff --git a/assets/logs.png b/assets/logs.png new file mode 100644 index 0000000..d0aab0a Binary files /dev/null and b/assets/logs.png differ diff --git a/assets/monitors.png b/assets/monitors.png new file mode 100644 index 0000000..867a875 Binary files /dev/null and b/assets/monitors.png differ diff --git a/assets/nodes.png b/assets/nodes.png new file mode 100644 index 0000000..53efc1b Binary files /dev/null and b/assets/nodes.png differ diff --git a/assets/theme.png b/assets/theme.png new file mode 100644 index 0000000..9bdd920 Binary files /dev/null and b/assets/theme.png differ diff --git a/cmd/uptop/main.go b/cmd/uptop/main.go index 65ad2f4..d88b2e7 100644 --- a/cmd/uptop/main.go +++ b/cmd/uptop/main.go @@ -385,6 +385,7 @@ func runServe(args []string) { eng.InitHistory() eng.InitLogs() + eng.InitAlertHealth() eng.Start(ctx) tlsCert := os.Getenv("UPTOP_TLS_CERT") diff --git a/internal/cluster/cluster_test.go b/internal/cluster/cluster_test.go index d665bbb..a7fb8a8 100644 --- a/internal/cluster/cluster_test.go +++ b/internal/cluster/cluster_test.go @@ -53,8 +53,12 @@ func (m *mockStore) GetNode(string) (models.ProbeNode, error) { return models.Pr func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil } func (m *mockStore) UpdateNodeLastSeen(string) error { return nil } func (m *mockStore) DeleteNode(string) error { return nil } -func (m *mockStore) SaveLog(string) error { return nil } -func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil } +func (m *mockStore) LoadAlertHealth() (map[int]models.AlertHealthRecord, error) { + return nil, nil +} +func (m *mockStore) SaveAlertHealth(models.AlertHealthRecord) error { return nil } +func (m *mockStore) SaveLog(string) error { return nil } +func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil } func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) { return nil, nil } diff --git a/internal/metrics/prometheus_test.go b/internal/metrics/prometheus_test.go index 24f6567..7effdc8 100644 --- a/internal/metrics/prometheus_test.go +++ b/internal/metrics/prometheus_test.go @@ -51,8 +51,12 @@ func (m *mockStore) GetNode(string) (models.ProbeNode, error) { return m func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil } func (m *mockStore) UpdateNodeLastSeen(string) error { return nil } func (m *mockStore) DeleteNode(string) error { return nil } -func (m *mockStore) SaveLog(string) error { return nil } -func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil } +func (m *mockStore) LoadAlertHealth() (map[int]models.AlertHealthRecord, error) { + return nil, nil +} +func (m *mockStore) SaveAlertHealth(models.AlertHealthRecord) error { return nil } +func (m *mockStore) SaveLog(string) error { return nil } +func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil } func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) { return nil, nil } diff --git a/internal/models/models.go b/internal/models/models.go index 571d555..98c1be5 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -79,6 +79,17 @@ type ProbeNode struct { Version string } +// AlertHealthRecord is the persisted send health of an alert channel. It lets the +// "last sent" / health indicators survive restarts instead of resetting to "never". +type AlertHealthRecord struct { + AlertID int + LastSendAt time.Time + LastSendOK bool + LastError string + SendCount int + FailCount int +} + type MaintenanceWindow struct { ID int MonitorID int diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 1a1c8d2..91a8ef6 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -146,6 +146,26 @@ func (e *Engine) InitLogs() { e.logStore = logs } +// InitAlertHealth restores persisted alert send health so the dashboard shows real +// "last sent" / health state on startup instead of resetting every channel to "never". +func (e *Engine) InitAlertHealth() { + records, err := e.db.LoadAlertHealth() + if err != nil { + return + } + e.alertHealthMu.Lock() + defer e.alertHealthMu.Unlock() + for id, r := range records { + e.alertHealth[id] = AlertHealth{ + LastSendAt: r.LastSendAt, + LastSendOK: r.LastSendOK, + LastError: r.LastError, + SendCount: r.SendCount, + FailCount: r.FailCount, + } + } +} + func (e *Engine) GetLogs() []string { e.logMu.RLock() defer e.logMu.RUnlock() @@ -612,6 +632,18 @@ func (e *Engine) recordAlertResult(alertID int, ok bool, errMsg string) { h.FailCount++ } e.alertHealth[alertID] = h + + // Persist best-effort so health survives restarts; DB IO off the alert path. + go func(rec models.AlertHealthRecord) { + _ = e.db.SaveAlertHealth(rec) + }(models.AlertHealthRecord{ + AlertID: alertID, + LastSendAt: h.LastSendAt, + LastSendOK: h.LastSendOK, + LastError: h.LastError, + SendCount: h.SendCount, + FailCount: h.FailCount, + }) } func (e *Engine) GetAlertHealth(alertID int) AlertHealth { diff --git a/internal/monitor/monitor_test.go b/internal/monitor/monitor_test.go index 4792bf2..9425826 100644 --- a/internal/monitor/monitor_test.go +++ b/internal/monitor/monitor_test.go @@ -63,6 +63,10 @@ func (m *mockStore) GetNode(string) (models.ProbeNode, error) { return m func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil } func (m *mockStore) UpdateNodeLastSeen(string) error { return nil } func (m *mockStore) DeleteNode(string) error { return nil } +func (m *mockStore) LoadAlertHealth() (map[int]models.AlertHealthRecord, error) { + return nil, nil +} +func (m *mockStore) SaveAlertHealth(models.AlertHealthRecord) error { return nil } func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) { return nil, nil } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 2e9de56..73b7152 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -65,8 +65,12 @@ func (m *mockStore) AddAlertReturningID(string, string, map[string]string) (int, func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil } func (m *mockStore) UpdateNodeLastSeen(string) error { return nil } func (m *mockStore) DeleteNode(string) error { return nil } -func (m *mockStore) SaveLog(string) error { return nil } -func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil } +func (m *mockStore) LoadAlertHealth() (map[int]models.AlertHealthRecord, error) { + return nil, nil +} +func (m *mockStore) SaveAlertHealth(models.AlertHealthRecord) error { return nil } +func (m *mockStore) SaveLog(string) error { return nil } +func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil } func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) { return nil, nil } diff --git a/internal/store/dialect.go b/internal/store/dialect.go index 4a4f8e8..2e9ce2c 100644 --- a/internal/store/dialect.go +++ b/internal/store/dialect.go @@ -14,6 +14,7 @@ type Dialect interface { ImportWipe(tx *sql.Tx) ImportResetSequences(tx *sql.Tx) UpsertNodeSQL() string + UpsertAlertHealthSQL() string } func rewritePlaceholders(query string, dollarStyle bool) string { diff --git a/internal/store/postgres.go b/internal/store/postgres.go index 320fb51..c6e896d 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -81,6 +81,14 @@ func (d *PostgresDialect) CreateTablesSQL() []string { changed_at TIMESTAMP DEFAULT NOW() )`, `CREATE INDEX IF NOT EXISTS idx_state_changes_site ON state_changes(site_id, changed_at DESC)`, + `CREATE TABLE IF NOT EXISTS alert_health ( + alert_id INTEGER PRIMARY KEY, + last_send_at TIMESTAMP, + last_send_ok BOOLEAN DEFAULT FALSE, + last_error TEXT DEFAULT '', + send_count INTEGER DEFAULT 0, + fail_count INTEGER DEFAULT 0 + )`, } } @@ -106,6 +114,10 @@ func (d *PostgresDialect) UpsertNodeSQL() string { return "INSERT INTO nodes (id, name, region, last_seen, version) VALUES ($1, $2, $3, NOW(), $4) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, region = EXCLUDED.region, last_seen = NOW(), version = EXCLUDED.version" } +func (d *PostgresDialect) UpsertAlertHealthSQL() string { + return "INSERT INTO alert_health (alert_id, last_send_at, last_send_ok, last_error, send_count, fail_count) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (alert_id) DO UPDATE SET last_send_at = EXCLUDED.last_send_at, last_send_ok = EXCLUDED.last_send_ok, last_error = EXCLUDED.last_error, send_count = EXCLUDED.send_count, fail_count = EXCLUDED.fail_count" +} + func (d *PostgresDialect) ResetSequenceOnEmpty(db *sql.DB, table string) {} func (d *PostgresDialect) ImportWipe(tx *sql.Tx) { diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index beadc40..ee2d65e 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -88,6 +88,14 @@ func (d *SQLiteDialect) CreateTablesSQL() []string { changed_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, `CREATE INDEX IF NOT EXISTS idx_state_changes_site ON state_changes(site_id, changed_at DESC)`, + `CREATE TABLE IF NOT EXISTS alert_health ( + alert_id INTEGER PRIMARY KEY, + last_send_at DATETIME, + last_send_ok BOOLEAN DEFAULT 0, + last_error TEXT DEFAULT '', + send_count INTEGER DEFAULT 0, + fail_count INTEGER DEFAULT 0 + )`, } } @@ -113,6 +121,10 @@ func (d *SQLiteDialect) UpsertNodeSQL() string { return "INSERT OR REPLACE INTO nodes (id, name, region, last_seen, version) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?)" } +func (d *SQLiteDialect) UpsertAlertHealthSQL() string { + return "INSERT OR REPLACE INTO alert_health (alert_id, last_send_at, last_send_ok, last_error, send_count, fail_count) VALUES (?, ?, ?, ?, ?, ?)" +} + func (d *SQLiteDialect) ResetSequenceOnEmpty(db *sql.DB, table string) { var count int _ = db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) //nolint:errcheck diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go index e24c9f1..bf6a27c 100644 --- a/internal/store/sqlstore.go +++ b/internal/store/sqlstore.go @@ -430,6 +430,37 @@ func (s *SQLStore) DeleteNode(id string) error { return err } +func (s *SQLStore) LoadAlertHealth() (map[int]models.AlertHealthRecord, error) { + rows, err := s.db.Query("SELECT alert_id, last_send_at, last_send_ok, last_error, send_count, fail_count FROM alert_health") + if err != nil { + return nil, err + } + defer rows.Close() + out := make(map[int]models.AlertHealthRecord) + for rows.Next() { + var r models.AlertHealthRecord + var lastSend sql.NullTime + if err := rows.Scan(&r.AlertID, &lastSend, &r.LastSendOK, &r.LastError, &r.SendCount, &r.FailCount); err != nil { + return out, err + } + if lastSend.Valid { + r.LastSendAt = lastSend.Time + } + out[r.AlertID] = r + } + return out, rows.Err() +} + +func (s *SQLStore) SaveAlertHealth(h models.AlertHealthRecord) error { + var lastSend interface{} + if !h.LastSendAt.IsZero() { + lastSend = h.LastSendAt + } + _, err := s.db.Exec(s.dialect.UpsertAlertHealthSQL(), + h.AlertID, lastSend, h.LastSendOK, h.LastError, h.SendCount, h.FailCount) + return err +} + func (s *SQLStore) SaveLog(message string) error { _, err := s.db.Exec(s.q("INSERT INTO logs (message) VALUES (?)"), message) if err != nil { diff --git a/internal/store/store.go b/internal/store/store.go index 8321486..2d00880 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -49,6 +49,10 @@ type Store interface { UpdateNodeLastSeen(id string) error DeleteNode(id string) error + // Alert Health + LoadAlertHealth() (map[int]models.AlertHealthRecord, error) + SaveAlertHealth(h models.AlertHealthRecord) error + // Logs SaveLog(message string) error LoadLogs(limit int) ([]string, error) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 13f45a9..ad1c19e 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -60,14 +60,18 @@ type siteFormData struct { Regions string } -func latencySparkline(latencies []time.Duration, width int) string { +func latencySparkline(latencies []time.Duration, statuses []bool, width int) string { if len(latencies) == 0 { return subtleStyle.Render(strings.Repeat("·", width)) } samples := latencies + sampledStatuses := statuses if len(samples) > width { samples = samples[len(samples)-width:] + if len(sampledStatuses) > width { + sampledStatuses = sampledStatuses[len(sampledStatuses)-width:] + } } minL, maxL := samples[0], samples[0] @@ -85,7 +89,7 @@ func latencySparkline(latencies []time.Duration, width int) string { sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining))) } spread := maxL - minL - for _, l := range samples { + for i, l := range samples { idx := 0 if spread > 0 { idx = int(float64(l-minL) / float64(spread) * 7) @@ -94,13 +98,18 @@ func latencySparkline(latencies []time.Duration, width int) string { } } ch := string(sparkChars[idx]) - ms := l.Milliseconds() - if ms < 200 { - sb.WriteString(specialStyle.Render(ch)) - } else if ms < 500 { - sb.WriteString(warnStyle.Render(ch)) - } else { + isDown := i < len(sampledStatuses) && !sampledStatuses[i] + if isDown { sb.WriteString(dangerStyle.Render(ch)) + } else { + ms := l.Milliseconds() + if ms < 200 { + sb.WriteString(specialStyle.Render(ch)) + } else if ms < 500 { + sb.WriteString(warnStyle.Render(ch)) + } else { + sb.WriteString(dangerStyle.Render(ch)) + } } } return sb.String() @@ -474,7 +483,7 @@ func (m Model) viewSitesTab() string { if site.Type == "push" { spark = heartbeatSparkline(hist.Statuses, sparkWidth) } else { - spark = latencySparkline(hist.Latencies, sparkWidth) + spark = latencySparkline(hist.Latencies, hist.Statuses, sparkWidth) } rows = append(rows, []string{ @@ -949,20 +958,27 @@ func (m Model) viewDetailPanel() string { up, len(hist.Statuses)) } } else { - b.WriteString(" " + latencySparkline(hist.Latencies, 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 - } + b.WriteString(" " + latencySparkline(hist.Latencies, hist.Statuses, sparkWidth)) + // 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(), diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 0677643..1bddca1 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -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(" · "))