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`.
+
+
+
+
+
-Built on the foundation of [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep).
+

+
-## 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
+
+
## 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(" · "))