chore: TUI screenshots, README polish, changelog rewrite #32
+1
-1
@@ -27,7 +27,7 @@ go.work
|
|||||||
# End of https://www.toptal.com/developers/gitignore/api/go
|
# End of https://www.toptal.com/developers/gitignore/api/go
|
||||||
|
|
||||||
/uptop
|
/uptop
|
||||||
uptop.db
|
uptop.db*
|
||||||
|
|
||||||
.ssh
|
.ssh
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+29
-13
@@ -60,14 +60,18 @@ type siteFormData struct {
|
|||||||
Regions string
|
Regions string
|
||||||
}
|
}
|
||||||
|
|
||||||
func latencySparkline(latencies []time.Duration, width int) string {
|
func latencySparkline(latencies []time.Duration, statuses []bool, width int) string {
|
||||||
if len(latencies) == 0 {
|
if len(latencies) == 0 {
|
||||||
return subtleStyle.Render(strings.Repeat("·", width))
|
return subtleStyle.Render(strings.Repeat("·", width))
|
||||||
}
|
}
|
||||||
|
|
||||||
samples := latencies
|
samples := latencies
|
||||||
|
sampledStatuses := statuses
|
||||||
if len(samples) > width {
|
if len(samples) > width {
|
||||||
samples = samples[len(samples)-width:]
|
samples = samples[len(samples)-width:]
|
||||||
|
if len(sampledStatuses) > width {
|
||||||
|
sampledStatuses = sampledStatuses[len(sampledStatuses)-width:]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
minL, maxL := samples[0], samples[0]
|
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)))
|
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
|
||||||
}
|
}
|
||||||
spread := maxL - minL
|
spread := maxL - minL
|
||||||
for _, l := range samples {
|
for i, l := range samples {
|
||||||
idx := 0
|
idx := 0
|
||||||
if spread > 0 {
|
if spread > 0 {
|
||||||
idx = int(float64(l-minL) / float64(spread) * 7)
|
idx = int(float64(l-minL) / float64(spread) * 7)
|
||||||
@@ -94,6 +98,10 @@ func latencySparkline(latencies []time.Duration, width int) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ch := string(sparkChars[idx])
|
ch := string(sparkChars[idx])
|
||||||
|
isDown := i < len(sampledStatuses) && !sampledStatuses[i]
|
||||||
|
if isDown {
|
||||||
|
sb.WriteString(dangerStyle.Render(ch))
|
||||||
|
} else {
|
||||||
ms := l.Milliseconds()
|
ms := l.Milliseconds()
|
||||||
if ms < 200 {
|
if ms < 200 {
|
||||||
sb.WriteString(specialStyle.Render(ch))
|
sb.WriteString(specialStyle.Render(ch))
|
||||||
@@ -103,6 +111,7 @@ func latencySparkline(latencies []time.Duration, width int) string {
|
|||||||
sb.WriteString(dangerStyle.Render(ch))
|
sb.WriteString(dangerStyle.Render(ch))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,7 +483,7 @@ func (m Model) viewSitesTab() string {
|
|||||||
if site.Type == "push" {
|
if site.Type == "push" {
|
||||||
spark = heartbeatSparkline(hist.Statuses, sparkWidth)
|
spark = heartbeatSparkline(hist.Statuses, sparkWidth)
|
||||||
} else {
|
} else {
|
||||||
spark = latencySparkline(hist.Latencies, sparkWidth)
|
spark = latencySparkline(hist.Latencies, hist.Statuses, sparkWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows = append(rows, []string{
|
rows = append(rows, []string{
|
||||||
@@ -949,20 +958,27 @@ func (m Model) viewDetailPanel() string {
|
|||||||
up, len(hist.Statuses))
|
up, len(hist.Statuses))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
b.WriteString(" " + latencySparkline(hist.Latencies, 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(),
|
||||||
|
|||||||
+24
-6
@@ -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(" · "))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user