chore(tui): polish demo + regenerate screenshots
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
@@ -385,6 +385,7 @@ func runServe(args []string) {
|
|||||||
|
|
||||||
eng.InitHistory()
|
eng.InitHistory()
|
||||||
eng.InitLogs()
|
eng.InitLogs()
|
||||||
|
eng.InitAlertHealth()
|
||||||
eng.Start(ctx)
|
eng.Start(ctx)
|
||||||
|
|
||||||
tlsCert := os.Getenv("UPTOP_TLS_CERT")
|
tlsCert := os.Getenv("UPTOP_TLS_CERT")
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ func (m *mockStore) GetNode(string) (models.ProbeNode, error) { return models.Pr
|
|||||||
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
|
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
|
||||||
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
|
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
|
||||||
func (m *mockStore) DeleteNode(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) SaveLog(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) LoadLogs(int) ([]string, error) { return nil, nil }
|
||||||
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
|
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ func (m *mockStore) GetNode(string) (models.ProbeNode, error) { return m
|
|||||||
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
|
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
|
||||||
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
|
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
|
||||||
func (m *mockStore) DeleteNode(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) SaveLog(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) LoadLogs(int) ([]string, error) { return nil, nil }
|
||||||
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
|
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
|
||||||
|
|||||||
@@ -79,6 +79,17 @@ type ProbeNode struct {
|
|||||||
Version string
|
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 {
|
type MaintenanceWindow struct {
|
||||||
ID int
|
ID int
|
||||||
MonitorID int
|
MonitorID int
|
||||||
|
|||||||
@@ -146,6 +146,26 @@ func (e *Engine) InitLogs() {
|
|||||||
e.logStore = logs
|
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 {
|
func (e *Engine) GetLogs() []string {
|
||||||
e.logMu.RLock()
|
e.logMu.RLock()
|
||||||
defer e.logMu.RUnlock()
|
defer e.logMu.RUnlock()
|
||||||
@@ -612,6 +632,18 @@ func (e *Engine) recordAlertResult(alertID int, ok bool, errMsg string) {
|
|||||||
h.FailCount++
|
h.FailCount++
|
||||||
}
|
}
|
||||||
e.alertHealth[alertID] = h
|
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 {
|
func (e *Engine) GetAlertHealth(alertID int) AlertHealth {
|
||||||
|
|||||||
@@ -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) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
|
||||||
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
|
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
|
||||||
func (m *mockStore) DeleteNode(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) {
|
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ func (m *mockStore) AddAlertReturningID(string, string, map[string]string) (int,
|
|||||||
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
|
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
|
||||||
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
|
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
|
||||||
func (m *mockStore) DeleteNode(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) SaveLog(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) LoadLogs(int) ([]string, error) { return nil, nil }
|
||||||
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
|
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Dialect interface {
|
|||||||
ImportWipe(tx *sql.Tx)
|
ImportWipe(tx *sql.Tx)
|
||||||
ImportResetSequences(tx *sql.Tx)
|
ImportResetSequences(tx *sql.Tx)
|
||||||
UpsertNodeSQL() string
|
UpsertNodeSQL() string
|
||||||
|
UpsertAlertHealthSQL() string
|
||||||
}
|
}
|
||||||
|
|
||||||
func rewritePlaceholders(query string, dollarStyle bool) string {
|
func rewritePlaceholders(query string, dollarStyle bool) string {
|
||||||
|
|||||||
@@ -81,6 +81,14 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
|
|||||||
changed_at TIMESTAMP DEFAULT NOW()
|
changed_at TIMESTAMP DEFAULT NOW()
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_state_changes_site ON state_changes(site_id, changed_at DESC)`,
|
`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"
|
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) ResetSequenceOnEmpty(db *sql.DB, table string) {}
|
||||||
|
|
||||||
func (d *PostgresDialect) ImportWipe(tx *sql.Tx) {
|
func (d *PostgresDialect) ImportWipe(tx *sql.Tx) {
|
||||||
|
|||||||
@@ -88,6 +88,14 @@ func (d *SQLiteDialect) CreateTablesSQL() []string {
|
|||||||
changed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
changed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_state_changes_site ON state_changes(site_id, changed_at DESC)`,
|
`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, ?)"
|
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) {
|
func (d *SQLiteDialect) ResetSequenceOnEmpty(db *sql.DB, table string) {
|
||||||
var count int
|
var count int
|
||||||
_ = db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) //nolint:errcheck
|
_ = db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) //nolint:errcheck
|
||||||
|
|||||||
@@ -430,6 +430,37 @@ func (s *SQLStore) DeleteNode(id string) error {
|
|||||||
return err
|
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 {
|
func (s *SQLStore) SaveLog(message string) error {
|
||||||
_, err := s.db.Exec(s.q("INSERT INTO logs (message) VALUES (?)"), message)
|
_, err := s.db.Exec(s.q("INSERT INTO logs (message) VALUES (?)"), message)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ type Store interface {
|
|||||||
UpdateNodeLastSeen(id string) error
|
UpdateNodeLastSeen(id string) error
|
||||||
DeleteNode(id string) error
|
DeleteNode(id string) error
|
||||||
|
|
||||||
|
// Alert Health
|
||||||
|
LoadAlertHealth() (map[int]models.AlertHealthRecord, error)
|
||||||
|
SaveAlertHealth(h models.AlertHealthRecord) error
|
||||||
|
|
||||||
// Logs
|
// Logs
|
||||||
SaveLog(message string) error
|
SaveLog(message string) error
|
||||||
LoadLogs(limit int) ([]string, error)
|
LoadLogs(limit int) ([]string, error)
|
||||||
|
|||||||
@@ -959,19 +959,26 @@ func (m Model) viewDetailPanel() string {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
b.WriteString(" " + latencySparkline(hist.Latencies, hist.Statuses, sparkWidth))
|
b.WriteString(" " + latencySparkline(hist.Latencies, hist.Statuses, sparkWidth))
|
||||||
if len(hist.Latencies) > 0 {
|
// Stats over successful checks only — a failed check is stored as 0ns latency
|
||||||
minL, maxL := hist.Latencies[0], hist.Latencies[0]
|
// and would otherwise drag Min to 0ms and skew the average.
|
||||||
var total time.Duration
|
var minL, maxL, total time.Duration
|
||||||
for _, l := range hist.Latencies {
|
count := 0
|
||||||
total += l
|
for i, l := range hist.Latencies {
|
||||||
if l < minL {
|
if i < len(hist.Statuses) && !hist.Statuses[i] {
|
||||||
minL = l
|
continue
|
||||||
}
|
}
|
||||||
if l > maxL {
|
if count == 0 {
|
||||||
|
minL, maxL = l, l
|
||||||
|
} else if l < minL {
|
||||||
|
minL = l
|
||||||
|
} else if l > maxL {
|
||||||
maxL = l
|
maxL = l
|
||||||
}
|
}
|
||||||
|
total += l
|
||||||
|
count++
|
||||||
}
|
}
|
||||||
avg := total / time.Duration(len(hist.Latencies))
|
if count > 0 {
|
||||||
|
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(),
|
subtleStyle.Render("Min"), minL.Milliseconds(),
|
||||||
subtleStyle.Render("Avg"), avg.Milliseconds(),
|
subtleStyle.Render("Avg"), avg.Milliseconds(),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -122,6 +123,10 @@ type Model struct {
|
|||||||
|
|
||||||
filterMode bool
|
filterMode bool
|
||||||
filterText string
|
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 {
|
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,
|
collapsed: collapsed,
|
||||||
theme: theme,
|
theme: theme,
|
||||||
themeIndex: themeIdx,
|
themeIndex: themeIdx,
|
||||||
|
demoMode: os.Getenv("UPTOP_DEMO") == "1",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -754,11 +760,6 @@ func (m *Model) submitForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) pulseIndicator() string {
|
func (m Model) pulseIndicator() string {
|
||||||
frame := m.tickCount % len(pulseFrames)
|
|
||||||
brightness := int(m.pulsePos*155) + 100
|
|
||||||
if brightness > 255 {
|
|
||||||
brightness = 255
|
|
||||||
}
|
|
||||||
hasDown := false
|
hasDown := false
|
||||||
for _, s := range m.sites {
|
for _, s := range m.sites {
|
||||||
if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") {
|
if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") {
|
||||||
@@ -766,6 +767,19 @@ func (m Model) pulseIndicator() string {
|
|||||||
break
|
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
|
var color string
|
||||||
if hasDown {
|
if hasDown {
|
||||||
color = fmt.Sprintf("#%02x%02x%02x", brightness, brightness/4, brightness/4)
|
color = fmt.Sprintf("#%02x%02x%02x", brightness, brightness/4, brightness/4)
|
||||||
@@ -953,7 +967,11 @@ func (m Model) viewDashboard() string {
|
|||||||
online++
|
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(" · "))
|
statusLine := strings.Join(statusParts, subtleStyle.Render(" · "))
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,15 @@ func main() {
|
|||||||
fmt.Fprintf(os.Stderr, "maintenance: %v\n", err)
|
fmt.Fprintf(os.Stderr, "maintenance: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
alertIDs, err := loadAlertIDs(db)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "load alert IDs: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := backfillAlertHealth(db, now, alertIDs); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "alert health: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
var count int
|
var count int
|
||||||
_ = db.QueryRow("SELECT COUNT(*) FROM check_history").Scan(&count)
|
_ = db.QueryRow("SELECT COUNT(*) FROM check_history").Scan(&count)
|
||||||
@@ -67,6 +76,18 @@ func loadSiteIDs(db *sql.DB) (map[string]int, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return scanNameIDs(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAlertIDs(db *sql.DB) (map[string]int, error) {
|
||||||
|
rows, err := db.Query("SELECT id, name FROM alerts")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return scanNameIDs(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanNameIDs(rows *sql.Rows) (map[string]int, error) {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
ids := make(map[string]int)
|
ids := make(map[string]int)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
@@ -80,27 +101,73 @@ func loadSiteIDs(db *sql.DB) (map[string]int, error) {
|
|||||||
return ids, rows.Err()
|
return ids, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// backfillAlertHealth seeds realistic send health so the Alerts tab shows recent,
|
||||||
|
// healthy "last sent" times and green health dots instead of "never" across the board.
|
||||||
|
func backfillAlertHealth(db *sql.DB, now time.Time, alertIDs map[string]int) error {
|
||||||
|
type health struct {
|
||||||
|
name string
|
||||||
|
sentAgo time.Duration
|
||||||
|
ok bool
|
||||||
|
sends int
|
||||||
|
fails int
|
||||||
|
}
|
||||||
|
rows := []health{
|
||||||
|
{"Discord Homelab", 4 * time.Minute, true, 37, 0},
|
||||||
|
{"Slack Ops", 9 * time.Minute, true, 21, 1},
|
||||||
|
{"Ntfy Alerts", 1 * time.Hour, true, 12, 0},
|
||||||
|
{"Email Oncall", 3 * time.Hour, true, 5, 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
stmt, err := tx.Prepare("INSERT OR REPLACE INTO alert_health (alert_id, last_send_at, last_send_ok, last_error, send_count, fail_count) VALUES (?, ?, ?, ?, ?, ?)")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for _, r := range rows {
|
||||||
|
id, ok := alertIDs[r.name]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sentAt := now.Add(-r.sentAgo).Format("2006-01-02 15:04:05")
|
||||||
|
if _, err := stmt.Exec(id, sentAt, r.ok, "", r.sends, r.fails); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
type monitorProfile struct {
|
type monitorProfile struct {
|
||||||
name string
|
name string
|
||||||
minMs int
|
minMs int
|
||||||
maxMs int
|
maxMs int
|
||||||
downFrom int // check index where DOWN starts (-1 = never)
|
downFrom int // first DOWN check index (-1 = always up)
|
||||||
|
downTo int // exclusive end of the DOWN window; use 60 (total) for a still-down monitor
|
||||||
}
|
}
|
||||||
|
|
||||||
func backfillHistory(db *sql.DB, rng *rand.Rand, now time.Time, ids map[string]int) error {
|
func backfillHistory(db *sql.DB, rng *rand.Rand, now time.Time, ids map[string]int) error {
|
||||||
|
// Latency ranges reflect monitoring public services over the internet, so the
|
||||||
|
// detail histogram brackets the live latency the engine measures at capture time.
|
||||||
|
// 60 checks * 24m spacing = a 24h window; dip indices place outages within it.
|
||||||
profiles := []monitorProfile{
|
profiles := []monitorProfile{
|
||||||
{"Nextcloud", 40, 80, -1},
|
{"Nextcloud", 200, 600, 47, 48}, // brief blip ~5h ago, recovered
|
||||||
{"Jellyfin", 80, 200, -1},
|
{"Jellyfin", 40, 180, 15, 16}, // brief blip ~18h ago, recovered
|
||||||
{"Home Assistant", 15, 45, -1},
|
{"Home Assistant", 30, 120, -1, 0}, //
|
||||||
{"Gitea", 40, 90, -1},
|
{"Gitea", 50, 200, -1, 0}, //
|
||||||
{"Traefik Dashboard", 5, 25, -1},
|
{"Traefik Dashboard", 60, 200, -1, 0}, //
|
||||||
{"Vaultwarden", 50, 130, -1},
|
{"Vaultwarden", 80, 250, -1, 0}, //
|
||||||
{"Personal Blog", 25, 65, -1},
|
{"Personal Blog", 40, 160, -1, 0}, //
|
||||||
{"Immich", 100, 280, -1}, // spikes handled below
|
{"Immich", 60, 300, 30, 31}, // brief blip ~12h ago; periodic spikes below
|
||||||
{"Auth Portal", 30, 70, 40}, // DOWN after check 40
|
{"Auth Portal", 30, 90, 40, 60}, // DOWN ~8h ago, still down
|
||||||
{"Edge Router", 5, 15, -1}, // ping
|
{"Edge Router", 5, 20, -1, 0}, // ping
|
||||||
{"Postgres", 1, 5, -1}, // port
|
{"Postgres", 1, 6, -1, 0}, // port
|
||||||
{"DNS Primary", 10, 30, -1},
|
{"DNS Primary", 8, 30, -1, 0}, // dns
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := db.Begin()
|
tx, err := db.Begin()
|
||||||
@@ -128,7 +195,7 @@ func backfillHistory(db *sql.DB, rng *rand.Rand, now time.Time, ids map[string]i
|
|||||||
var latencyNs int64
|
var latencyNs int64
|
||||||
isUp := true
|
isUp := true
|
||||||
|
|
||||||
if p.downFrom >= 0 && i >= p.downFrom {
|
if p.downFrom >= 0 && i >= p.downFrom && i < p.downTo {
|
||||||
latencyNs = 0
|
latencyNs = 0
|
||||||
isUp = false
|
isUp = false
|
||||||
} else {
|
} else {
|
||||||
@@ -155,14 +222,16 @@ func backfillStateChanges(db *sql.DB, now time.Time, ids map[string]int) error {
|
|||||||
reason string
|
reason string
|
||||||
at time.Time
|
at time.Time
|
||||||
}
|
}
|
||||||
|
// Timed to line up with the history dips (Nextcloud ~5h, Immich ~12h, Jellyfin ~18h)
|
||||||
|
// and the still-down Auth Portal (~8h), so detail panels read coherently.
|
||||||
changes := []sc{
|
changes := []sc{
|
||||||
{"Nextcloud", "UP", "DOWN", "read timeout", now.Add(-3 * 24 * time.Hour).Add(-5 * time.Minute)},
|
{"Nextcloud", "UP", "DOWN", "read timeout", now.Add(-5 * time.Hour).Add(-8 * time.Minute)},
|
||||||
{"Nextcloud", "DOWN", "UP", "", now.Add(-3 * 24 * time.Hour)},
|
{"Nextcloud", "DOWN", "UP", "", now.Add(-5 * time.Hour)},
|
||||||
{"Jellyfin", "UP", "DOWN", "connection reset", now.Add(-18 * time.Hour).Add(-3 * time.Minute)},
|
{"Auth Portal", "UP", "DOWN", "no such host", now.Add(-8 * time.Hour)},
|
||||||
{"Jellyfin", "DOWN", "UP", "", now.Add(-18 * time.Hour)},
|
|
||||||
{"Auth Portal", "UP", "DOWN", "connection refused", now.Add(-8 * time.Hour)},
|
|
||||||
{"Immich", "UP", "DOWN", "502 Bad Gateway", now.Add(-12 * time.Hour).Add(-8 * time.Minute)},
|
{"Immich", "UP", "DOWN", "502 Bad Gateway", now.Add(-12 * time.Hour).Add(-8 * time.Minute)},
|
||||||
{"Immich", "DOWN", "UP", "", now.Add(-12 * time.Hour)},
|
{"Immich", "DOWN", "UP", "", now.Add(-12 * time.Hour)},
|
||||||
|
{"Jellyfin", "UP", "DOWN", "connection reset", now.Add(-18 * time.Hour).Add(-5 * time.Minute)},
|
||||||
|
{"Jellyfin", "DOWN", "UP", "", now.Add(-18 * time.Hour)},
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := db.Begin()
|
tx, err := db.Begin()
|
||||||
@@ -191,25 +260,35 @@ func backfillStateChanges(db *sql.DB, now time.Time, ids map[string]int) error {
|
|||||||
|
|
||||||
func backfillLogs(db *sql.DB, now time.Time) error {
|
func backfillLogs(db *sql.DB, now time.Time) error {
|
||||||
type logEntry struct {
|
type logEntry struct {
|
||||||
msg string
|
text string
|
||||||
at time.Time
|
at time.Time
|
||||||
}
|
}
|
||||||
|
ago := func(h, m, s int) time.Time {
|
||||||
|
return now.Add(-(time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(s)*time.Second))
|
||||||
|
}
|
||||||
|
// Ordered newest-first. The bracket time is derived from `at` (not hardcoded), so the
|
||||||
|
// Logs view — which renders the leading [HH:MM] — reads chronologically. Outage times
|
||||||
|
// line up with the state changes and history dips above.
|
||||||
logs := []logEntry{
|
logs := []logEntry{
|
||||||
{"[06:12] Monitor 'Auth Portal' confirmed DOWN: connection refused", now.Add(-8 * time.Hour)},
|
{"Monitor 'Nextcloud' recovered (was down 8m)", ago(5, 0, 0)},
|
||||||
{"[06:12] Monitor 'Auth Portal' failed check 2/2", now.Add(-8*time.Hour - 30*time.Second)},
|
{"Monitor 'Nextcloud' confirmed DOWN: read timeout", ago(5, 8, 0)},
|
||||||
{"[06:11] Monitor 'Auth Portal' failed check 1/2", now.Add(-8*time.Hour - 60*time.Second)},
|
{"Monitor 'Nextcloud' failed check 2/2", ago(5, 8, 30)},
|
||||||
{"[12:33] Monitor 'Immich' recovered (was down 8m)", now.Add(-12 * time.Hour)},
|
{"Monitor 'Nextcloud' failed check 1/2", ago(5, 9, 0)},
|
||||||
{"[12:25] Monitor 'Immich' confirmed DOWN: 502 Bad Gateway", now.Add(-12*time.Hour - 8*time.Minute)},
|
{"Monitor 'Auth Portal' confirmed DOWN: no such host", ago(8, 0, 0)},
|
||||||
{"[12:25] Monitor 'Immich' failed check 3/3", now.Add(-12*time.Hour - 8*time.Minute - 30*time.Second)},
|
{"Monitor 'Auth Portal' failed check 2/2", ago(8, 0, 30)},
|
||||||
{"[12:25] Monitor 'Immich' failed check 2/3", now.Add(-12*time.Hour - 8*time.Minute - 60*time.Second)},
|
{"Monitor 'Auth Portal' failed check 1/2", ago(8, 1, 0)},
|
||||||
{"[12:24] Monitor 'Immich' failed check 1/3", now.Add(-12*time.Hour - 9*time.Minute)},
|
{"Monitor 'Immich' recovered (was down 8m)", ago(12, 0, 0)},
|
||||||
{"[06:14] Monitor 'Jellyfin' recovered (was down 3m)", now.Add(-18 * time.Hour)},
|
{"Monitor 'Immich' confirmed DOWN: 502 Bad Gateway", ago(12, 8, 0)},
|
||||||
{"[06:11] Monitor 'Jellyfin' confirmed DOWN: connection reset", now.Add(-18*time.Hour - 3*time.Minute)},
|
{"Monitor 'Immich' failed check 3/3", ago(12, 8, 30)},
|
||||||
{"[06:11] Monitor 'Jellyfin' failed check 2/2", now.Add(-18*time.Hour - 3*time.Minute - 30*time.Second)},
|
{"Monitor 'Immich' failed check 2/3", ago(12, 9, 0)},
|
||||||
{"[06:10] Monitor 'Jellyfin' failed check 1/2", now.Add(-18*time.Hour - 4*time.Minute)},
|
{"Monitor 'Immich' failed check 1/3", ago(12, 9, 30)},
|
||||||
{"[23:45] SSL certificate for 'Personal Blog' expires in 42 days", now.Add(-28 * time.Hour)},
|
{"Monitor 'Jellyfin' recovered (was down 5m)", ago(18, 0, 0)},
|
||||||
{"[08:00] Loaded check history from database", now.Add(-32*time.Hour - 30*time.Minute)},
|
{"Monitor 'Jellyfin' confirmed DOWN: connection reset", ago(18, 5, 0)},
|
||||||
{"[08:00] Engine RESUMED (Active)", now.Add(-32*time.Hour - 30*time.Minute - 5*time.Second)},
|
{"Monitor 'Jellyfin' failed check 2/2", ago(18, 5, 30)},
|
||||||
|
{"Monitor 'Jellyfin' failed check 1/2", ago(18, 6, 0)},
|
||||||
|
{"SSL warning: certificate for 'Personal Blog' expires in 9 days", ago(20, 0, 0)},
|
||||||
|
{"Engine RESUMED (Active)", ago(22, 0, 0)},
|
||||||
|
{"Loaded check history from database", ago(22, 0, 5)},
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := db.Begin()
|
tx, err := db.Begin()
|
||||||
@@ -225,7 +304,10 @@ func backfillLogs(db *sql.DB, now time.Time) error {
|
|||||||
defer stmt.Close()
|
defer stmt.Close()
|
||||||
|
|
||||||
for _, l := range logs {
|
for _, l := range logs {
|
||||||
if _, err := stmt.Exec(l.msg, l.at.Format("2006-01-02 15:04:05")); err != nil {
|
// Bracket in local time to match the engine's live AddLog timestamps;
|
||||||
|
// created_at stays UTC to match the store's CURRENT_TIMESTAMP ordering.
|
||||||
|
msg := "[" + l.at.Local().Format("15:04") + "] " + l.text
|
||||||
|
if _, err := stmt.Exec(msg, l.at.Format("2006-01-02 15:04:05")); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,12 +315,24 @@ func backfillLogs(db *sql.DB, now time.Time) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func backfillNodes(db *sql.DB, now time.Time) error {
|
func backfillNodes(db *sql.DB, now time.Time) error {
|
||||||
_, err := db.Exec(
|
// Multiple regions to show distributed probes. All seen "now" so they read ONLINE
|
||||||
|
// for the whole capture window (kept under the 60s freshness threshold by the tape).
|
||||||
|
nodes := []struct{ id, name, region string }{
|
||||||
|
{"node-use1", "leader", "us-east"},
|
||||||
|
{"node-euw1", "probe-eu", "eu-west"},
|
||||||
|
{"node-apse1", "probe-ap", "ap-southeast"},
|
||||||
|
}
|
||||||
|
ts := now.Format("2006-01-02 15:04:05")
|
||||||
|
for _, n := range nodes {
|
||||||
|
if _, err := db.Exec(
|
||||||
"INSERT OR REPLACE INTO nodes (id, name, region, last_seen, version) VALUES (?, ?, ?, ?, ?)",
|
"INSERT OR REPLACE INTO nodes (id, name, region, last_seen, version) VALUES (?, ?, ?, ?, ?)",
|
||||||
"node-1", "leader", "us-east", now.Format("2006-01-02 15:04:05"), "2026.05.1",
|
n.id, n.name, n.region, ts, "2026.05.1",
|
||||||
)
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func backfillMaintenance(db *sql.DB, now time.Time, ids map[string]int) error {
|
func backfillMaintenance(db *sql.DB, now time.Time, ids map[string]int) error {
|
||||||
tx, err := db.Begin()
|
tx, err := db.Begin()
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// Command crop trims the uniform background border around each VHS screenshot so the
|
||||||
|
// content fills the frame instead of floating in a large empty terminal. Sparse views
|
||||||
|
// (alerts, detail, nodes) would otherwise sit in a sea of dead space.
|
||||||
|
//
|
||||||
|
// Usage: crop [dir] (dir defaults to vhs/screenshots)
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pad is the margin (px) left around the detected content. tol is the per-channel
|
||||||
|
// colour distance (summed) above which a pixel counts as content rather than background.
|
||||||
|
const (
|
||||||
|
pad = 24
|
||||||
|
tol = 28
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dir := "vhs/screenshots"
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
dir = os.Args[1]
|
||||||
|
}
|
||||||
|
paths, err := filepath.Glob(filepath.Join(dir, "*.png"))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "glob: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if len(paths) == 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "no PNGs in %s\n", dir)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
for _, p := range paths {
|
||||||
|
w, h, err := cropFile(p)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "crop %s: %v\n", p, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("cropped %s -> %dx%d\n", filepath.Base(p), w, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cropFile(path string) (int, int, error) {
|
||||||
|
f, err := os.Open(path) //nolint:gosec // dev tool: paths come from a trusted local glob
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
src, err := png.Decode(f)
|
||||||
|
_ = f.Close()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
b := src.Bounds()
|
||||||
|
// Background colour sampled from a corner — always inside VHS's blank padding.
|
||||||
|
bgR, bgG, bgB := rgb(src.At(b.Min.X+2, b.Min.Y+2))
|
||||||
|
|
||||||
|
minX, minY := b.Max.X, b.Max.Y
|
||||||
|
maxX, maxY := b.Min.X, b.Min.Y
|
||||||
|
found := false
|
||||||
|
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||||
|
for x := b.Min.X; x < b.Max.X; x++ {
|
||||||
|
r, g, bl := rgb(src.At(x, y))
|
||||||
|
if abs(r-bgR)+abs(g-bgG)+abs(bl-bgB) > tol {
|
||||||
|
found = true
|
||||||
|
minX, minY = min(minX, x), min(minY, y)
|
||||||
|
maxX, maxY = max(maxX, x), max(maxY, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return b.Dx(), b.Dy(), nil // blank frame — leave untouched
|
||||||
|
}
|
||||||
|
|
||||||
|
minX = clamp(minX-pad, b.Min.X, b.Max.X)
|
||||||
|
minY = clamp(minY-pad, b.Min.Y, b.Max.Y)
|
||||||
|
maxX = clamp(maxX+pad+1, b.Min.X, b.Max.X)
|
||||||
|
maxY = clamp(maxY+pad+1, b.Min.Y, b.Max.Y)
|
||||||
|
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, maxX-minX, maxY-minY))
|
||||||
|
for y := minY; y < maxY; y++ {
|
||||||
|
for x := minX; x < maxX; x++ {
|
||||||
|
dst.Set(x-minX, y-minY, src.At(x, y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(path) //nolint:gosec // dev tool: paths come from a trusted local glob
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
defer out.Close() //nolint:errcheck // best-effort close on write path
|
||||||
|
if err := png.Encode(out, dst); err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return dst.Bounds().Dx(), dst.Bounds().Dy(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rgb(c color.Color) (int, int, int) {
|
||||||
|
r, g, b, _ := c.RGBA()
|
||||||
|
return int(r >> 8), int(g >> 8), int(b >> 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
func abs(x int) int {
|
||||||
|
if x < 0 {
|
||||||
|
return -x
|
||||||
|
}
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp(v, lo, hi int) int {
|
||||||
|
if v < lo {
|
||||||
|
return lo
|
||||||
|
}
|
||||||
|
if v > hi {
|
||||||
|
return hi
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -6,49 +6,67 @@ Set Padding 20
|
|||||||
Set Framerate 15
|
Set Framerate 15
|
||||||
Set TypingSpeed 50ms
|
Set TypingSpeed 50ms
|
||||||
|
|
||||||
|
# Seed demo data + start uptop (UPTOP_DEMO=1 → stable pulse dot for stills).
|
||||||
Hide
|
Hide
|
||||||
Type "bash vhs/setup.sh /tmp/uptop-vhs.db"
|
Type "bash vhs/setup.sh /tmp/uptop-vhs.db"
|
||||||
Enter
|
Enter
|
||||||
Sleep 45s
|
# Warm-up: push heartbeat lands (~10s) and initial checks settle. Kept short so every
|
||||||
|
# capture stays inside the 60s node-freshness window (consistent "3 probes" footer).
|
||||||
|
Sleep 18s
|
||||||
Show
|
Show
|
||||||
Sleep 5s
|
Sleep 2s
|
||||||
|
|
||||||
# Sites tab — hero shot with mixed monitor states
|
# 1. Sites — hero shot: mixed states, history sparklines, SSL, retries.
|
||||||
Screenshot vhs/screenshots/monitors.png
|
Screenshot vhs/screenshots/monitors.png
|
||||||
Sleep 1s
|
Sleep 1s
|
||||||
|
|
||||||
# Navigate to Nextcloud (row 6: group + 3 children + Auth Portal)
|
# 2. Detail — drill into Nextcloud (6th row from the top).
|
||||||
Down
|
Down
|
||||||
Sleep 200ms
|
Sleep 150ms
|
||||||
Down
|
Down
|
||||||
Sleep 200ms
|
Sleep 150ms
|
||||||
Down
|
Down
|
||||||
Sleep 200ms
|
Sleep 150ms
|
||||||
Down
|
Down
|
||||||
Sleep 200ms
|
Sleep 150ms
|
||||||
Down
|
Down
|
||||||
Sleep 200ms
|
Sleep 300ms
|
||||||
Type "i"
|
Type "i"
|
||||||
Sleep 3s
|
Sleep 2s
|
||||||
Screenshot vhs/screenshots/detail.png
|
Screenshot vhs/screenshots/detail.png
|
||||||
Sleep 1s
|
Sleep 500ms
|
||||||
|
|
||||||
# Close detail
|
|
||||||
Escape
|
Escape
|
||||||
Sleep 1s
|
Sleep 1s
|
||||||
|
|
||||||
# Tab to Alerts
|
# 3. Alerts — channels with health dots + recent "last sent".
|
||||||
Tab
|
Tab
|
||||||
Sleep 2s
|
Sleep 1500ms
|
||||||
Screenshot vhs/screenshots/alerts.png
|
Screenshot vhs/screenshots/alerts.png
|
||||||
Sleep 1s
|
Sleep 500ms
|
||||||
|
|
||||||
# Tab to Logs
|
# 4. Logs — chronological, severity-coloured event stream.
|
||||||
Tab
|
Tab
|
||||||
Sleep 2s
|
Sleep 1500ms
|
||||||
Screenshot vhs/screenshots/logs.png
|
Screenshot vhs/screenshots/logs.png
|
||||||
Sleep 1s
|
Sleep 500ms
|
||||||
|
|
||||||
|
# 5. Nodes — distributed probes across regions.
|
||||||
|
Tab
|
||||||
|
Sleep 1500ms
|
||||||
|
Screenshot vhs/screenshots/nodes.png
|
||||||
|
Sleep 500ms
|
||||||
|
|
||||||
|
# 6. Theme — cycle to the next theme, return to Sites for an alternate-palette hero.
|
||||||
|
Type "T"
|
||||||
|
Sleep 500ms
|
||||||
|
Tab
|
||||||
|
Sleep 200ms
|
||||||
|
Tab
|
||||||
|
Sleep 200ms
|
||||||
|
Tab
|
||||||
|
Sleep 1s
|
||||||
|
Screenshot vhs/screenshots/theme.png
|
||||||
|
Sleep 500ms
|
||||||
|
|
||||||
# Quit
|
|
||||||
Type "q"
|
Type "q"
|
||||||
Sleep 1s
|
Sleep 1s
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 253 KiB |
@@ -28,7 +28,7 @@ monitors:
|
|||||||
# HTTP — homelab services
|
# HTTP — homelab services
|
||||||
- name: Nextcloud
|
- name: Nextcloud
|
||||||
type: http
|
type: http
|
||||||
url: https://example.com
|
url: https://nextcloud.com
|
||||||
interval: 30
|
interval: 30
|
||||||
alert: Discord Homelab
|
alert: Discord Homelab
|
||||||
check_ssl: true
|
check_ssl: true
|
||||||
@@ -37,21 +37,21 @@ monitors:
|
|||||||
|
|
||||||
- name: Jellyfin
|
- name: Jellyfin
|
||||||
type: http
|
type: http
|
||||||
url: https://example.com
|
url: https://jellyfin.org
|
||||||
interval: 30
|
interval: 30
|
||||||
alert: Discord Homelab
|
alert: Discord Homelab
|
||||||
max_retries: 2
|
max_retries: 2
|
||||||
|
|
||||||
- name: Home Assistant
|
- name: Home Assistant
|
||||||
type: http
|
type: http
|
||||||
url: https://example.com
|
url: https://www.home-assistant.io
|
||||||
interval: 30
|
interval: 30
|
||||||
alert: Discord Homelab
|
alert: Discord Homelab
|
||||||
max_retries: 3
|
max_retries: 3
|
||||||
|
|
||||||
- name: Gitea
|
- name: Gitea
|
||||||
type: http
|
type: http
|
||||||
url: https://example.com
|
url: https://about.gitea.com
|
||||||
interval: 60
|
interval: 60
|
||||||
alert: Discord Homelab
|
alert: Discord Homelab
|
||||||
check_ssl: true
|
check_ssl: true
|
||||||
@@ -60,14 +60,14 @@ monitors:
|
|||||||
|
|
||||||
- name: Traefik Dashboard
|
- name: Traefik Dashboard
|
||||||
type: http
|
type: http
|
||||||
url: https://example.com
|
url: https://traefik.io
|
||||||
interval: 60
|
interval: 60
|
||||||
alert: Discord Homelab
|
alert: Discord Homelab
|
||||||
max_retries: 1
|
max_retries: 1
|
||||||
|
|
||||||
- name: Vaultwarden
|
- name: Vaultwarden
|
||||||
type: http
|
type: http
|
||||||
url: https://example.com
|
url: https://bitwarden.com
|
||||||
interval: 30
|
interval: 30
|
||||||
alert: Discord Homelab
|
alert: Discord Homelab
|
||||||
check_ssl: true
|
check_ssl: true
|
||||||
@@ -76,7 +76,7 @@ monitors:
|
|||||||
|
|
||||||
- name: Personal Blog
|
- name: Personal Blog
|
||||||
type: http
|
type: http
|
||||||
url: https://example.com
|
url: https://jvns.ca
|
||||||
interval: 120
|
interval: 120
|
||||||
alert: Discord Homelab
|
alert: Discord Homelab
|
||||||
check_ssl: true
|
check_ssl: true
|
||||||
@@ -85,17 +85,17 @@ monitors:
|
|||||||
|
|
||||||
- name: Immich
|
- name: Immich
|
||||||
type: http
|
type: http
|
||||||
url: https://example.com
|
url: https://immich.app
|
||||||
interval: 60
|
interval: 60
|
||||||
alert: Discord Homelab
|
alert: Discord Homelab
|
||||||
check_ssl: true
|
check_ssl: true
|
||||||
expiry_threshold: 7
|
expiry_threshold: 7
|
||||||
max_retries: 3
|
max_retries: 3
|
||||||
|
|
||||||
# HTTP — deliberate failure
|
# HTTP — deliberate failure (non-resolving homelab host → stays DOWN)
|
||||||
- name: Auth Portal
|
- name: Auth Portal
|
||||||
type: http
|
type: http
|
||||||
url: http://localhost:1
|
url: https://auth.home.arpa
|
||||||
interval: 30
|
interval: 30
|
||||||
alert: Discord Homelab
|
alert: Discord Homelab
|
||||||
max_retries: 2
|
max_retries: 2
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ echo "==> Seeding monitors and alerts..."
|
|||||||
UPTOP_DB_DSN="$DB" ./uptop apply -f vhs/seed.yaml 2>&1
|
UPTOP_DB_DSN="$DB" ./uptop apply -f vhs/seed.yaml 2>&1
|
||||||
|
|
||||||
echo "==> Backfilling check history..."
|
echo "==> Backfilling check history..."
|
||||||
BACKFILL_OUT=$(go run ./vhs/backfill/ "$DB")
|
# Build first so the backfill's `now` (node last_seen, heartbeat timing) isn't racing
|
||||||
|
# a cold compile — keeps the capture window deterministic.
|
||||||
|
go build -o /tmp/uptop-backfill ./vhs/backfill/
|
||||||
|
BACKFILL_OUT=$(/tmp/uptop-backfill "$DB")
|
||||||
echo "$BACKFILL_OUT"
|
echo "$BACKFILL_OUT"
|
||||||
|
|
||||||
PUSH_TOKEN=$(echo "$BACKFILL_OUT" | grep '^PUSH_TOKEN=' | cut -d= -f2)
|
PUSH_TOKEN=$(echo "$BACKFILL_OUT" | grep '^PUSH_TOKEN=' | cut -d= -f2)
|
||||||
if [ -n "$PUSH_TOKEN" ]; then
|
if [ -n "$PUSH_TOKEN" ]; then
|
||||||
echo "==> Sending push heartbeat in 15s (background)..."
|
echo "==> Sending push heartbeat in 10s (background)..."
|
||||||
(sleep 15 && curl -s "http://localhost:18099/api/push" -H "Authorization: Bearer $PUSH_TOKEN" > /dev/null 2>&1) &
|
(sleep 10 && curl -s "http://localhost:18099/api/push" -H "Authorization: Bearer $PUSH_TOKEN" > /dev/null 2>&1) &
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Starting uptop server..."
|
echo "==> Starting uptop server..."
|
||||||
@@ -24,4 +27,5 @@ exec env \
|
|||||||
UPTOP_PORT=23299 \
|
UPTOP_PORT=23299 \
|
||||||
UPTOP_HTTP_PORT=18099 \
|
UPTOP_HTTP_PORT=18099 \
|
||||||
UPTOP_ALLOW_PRIVATE_TARGETS=true \
|
UPTOP_ALLOW_PRIVATE_TARGETS=true \
|
||||||
|
UPTOP_DEMO=1 \
|
||||||
./uptop serve 2>/dev/null
|
./uptop serve 2>/dev/null
|
||||||
|
|||||||