1 Commits

Author SHA1 Message Date
lerko 9eaddbb6e8 refactor: decompose monitor.go and sqlstore.go into focused files
CI / test (pull_request) Successful in 1m53s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 56s
Split monitor.go (1131→350 lines) into alerts.go, checks.go,
sites.go, and maintenance.go by concern. Split sqlstore.go
(833→470 lines) into sqlstore_alerts.go, sqlstore_history.go,
and sqlstore_maintenance.go by domain. Tests move with their
implementation. Pure reorganization — no behavioral changes.
2026-06-27 16:38:53 -04:00
41 changed files with 1516 additions and 2316 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

+8 -8
View File
@@ -43,10 +43,10 @@ func TestKeyCache_AllowsKnownDeniesUnknown(t *testing.T) {
_, unknown := testKey(t)
kc := newKeyCache(&kcMockStore{users: []models.User{{PublicKey: authorized}}})
if !kc.IsAllowed(context.Background(), known) {
if !kc.IsAllowed(known) {
t.Error("known key denied")
}
if kc.IsAllowed(context.Background(), unknown) {
if kc.IsAllowed(unknown) {
t.Error("unknown key allowed")
}
}
@@ -56,7 +56,7 @@ func TestKeyCache_RetainsKeysOnRefreshError(t *testing.T) {
ms := &kcMockStore{users: []models.User{{PublicKey: authorized}}}
kc := newKeyCache(ms)
if !kc.IsAllowed(context.Background(), known) {
if !kc.IsAllowed(known) {
t.Fatal("known key denied on first refresh")
}
@@ -67,7 +67,7 @@ func TestKeyCache_RetainsKeysOnRefreshError(t *testing.T) {
kc.updated = time.Now().Add(-time.Hour)
kc.mu.Unlock()
if !kc.IsAllowed(context.Background(), known) {
if !kc.IsAllowed(known) {
t.Error("transient refresh error locked out a previously valid key")
}
}
@@ -77,7 +77,7 @@ func TestKeyCache_FailsClosedAfterInvalidate(t *testing.T) {
ms := &kcMockStore{users: []models.User{{PublicKey: authorized}}}
kc := newKeyCache(ms)
if !kc.IsAllowed(context.Background(), known) {
if !kc.IsAllowed(known) {
t.Fatal("known key denied on first refresh")
}
@@ -86,7 +86,7 @@ func TestKeyCache_FailsClosedAfterInvalidate(t *testing.T) {
ms.err = errors.New("db down")
kc.Invalidate()
if kc.IsAllowed(context.Background(), known) {
if kc.IsAllowed(known) {
t.Error("revoked key still allowed while DB is down — fails open")
}
}
@@ -97,7 +97,7 @@ func TestUserInvalidatingStore_DeleteDropsKeyCache(t *testing.T) {
kc := newKeyCache(ms)
s := &userInvalidatingStore{Store: ms, kc: kc}
if !kc.IsAllowed(context.Background(), known) {
if !kc.IsAllowed(known) {
t.Fatal("known key denied on first refresh")
}
@@ -109,7 +109,7 @@ func TestUserInvalidatingStore_DeleteDropsKeyCache(t *testing.T) {
ms.users = nil
ms.err = errors.New("db down")
if kc.IsAllowed(context.Background(), known) {
if kc.IsAllowed(known) {
t.Error("deleted user's key still allowed from stale cache")
}
}
+30 -33
View File
@@ -179,7 +179,7 @@ func parseTrustedProxies(raw string) []*net.IPNet {
return cidrs
}
func openStore(ctx context.Context, dbType, dsn string) store.Store {
func openStore(dbType, dsn string) store.Store {
var ss *store.SQLStore
var err error
if dbType == "postgres" {
@@ -201,7 +201,7 @@ func openStore(ctx context.Context, dbType, dsn string) store.Store {
} else {
slog.Warn("no UPTOP_ENCRYPTION_KEY set, alert credentials stored unencrypted")
}
if err := ss.Init(ctx); err != nil {
if err := ss.Init(context.Background()); err != nil {
slog.Error("database init failed", "err", err)
os.Exit(1)
}
@@ -223,8 +223,7 @@ func runApply(args []string) {
os.Exit(1)
}
ctx := context.Background()
s := openStore(ctx, *dbType, *dsn)
s := openStore(*dbType, *dsn)
f, err := config.LoadFile(*filePath)
if err != nil {
@@ -232,7 +231,7 @@ func runApply(args []string) {
os.Exit(1)
}
changes, err := config.Apply(ctx, s, f, config.ApplyOpts{
changes, err := config.Apply(context.Background(), s, f, config.ApplyOpts{
DryRun: *dryRun,
Prune: *prune,
})
@@ -251,10 +250,9 @@ func runExport(args []string) {
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
ctx := context.Background()
s := openStore(ctx, *dbType, *dsn)
s := openStore(*dbType, *dsn)
f, err := config.Export(ctx, s)
f, err := config.Export(context.Background(), s)
if err != nil {
slog.Error("export failed", "err", err)
os.Exit(1)
@@ -283,8 +281,6 @@ func runMigrateSecrets(args []string) {
os.Exit(1)
}
ctx := context.Background()
var ss *store.SQLStore
if *dbType == "postgres" {
ss, err = store.NewPostgresStore(*dsn)
@@ -295,21 +291,21 @@ func runMigrateSecrets(args []string) {
slog.Error("database connection failed", "err", err)
os.Exit(1)
}
if err := ss.Init(ctx); err != nil {
if err := ss.Init(context.Background()); err != nil {
slog.Error("database init failed", "err", err)
os.Exit(1)
}
ss.SetEncryptor(enc)
alerts, err := ss.GetAllAlerts(ctx)
alerts, err := ss.GetAllAlerts(context.Background())
if err != nil {
slog.Error("failed to load alerts", "err", err)
os.Exit(1)
}
migrated := 0
for _, a := range alerts {
if err := ss.UpdateAlert(ctx, a.ID, a.Name, a.Type, a.Settings); err != nil {
if err := ss.UpdateAlert(context.Background(), a.ID, a.Name, a.Type, a.Settings); err != nil {
slog.Error("alert migration failed", "alert", a.Name, "err", err)
os.Exit(1)
}
@@ -396,19 +392,15 @@ func runServe(args []string) {
kc := newKeyCache(ss)
var s store.Store = &userInvalidatingStore{Store: ss, kc: kc}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := s.Init(ctx); err != nil {
if err := s.Init(context.Background()); err != nil {
slog.Error("database init failed", "err", err)
os.Exit(1)
}
if *demo {
seedDemoData(ctx, s)
seedDemoData(s)
}
seedKeysFromEnv(ctx, s)
seedKeysFromEnv(s)
if *importKuma != "" {
kb, err := importer.LoadKumaFile(*importKuma)
@@ -417,7 +409,7 @@ func runServe(args []string) {
os.Exit(1)
}
backup := importer.ConvertKuma(kb)
if err := s.ImportData(ctx, backup); err != nil {
if err := s.ImportData(context.Background(), backup); err != nil {
slog.Error("import failed", "err", err)
os.Exit(1)
}
@@ -437,9 +429,12 @@ func runServe(args []string) {
}
eng.SetMaintRetention(cfg.MaintRetention)
eng.InitHistory(ctx)
eng.InitLogs(ctx)
eng.InitAlertHealth(ctx)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
eng.InitHistory()
eng.InitLogs()
eng.InitAlertHealth()
eng.Start(ctx)
localTUI := isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd())
@@ -455,7 +450,7 @@ func runServe(args []string) {
sshSrv := startSSHServer(*port, s, eng, kc)
if localTUI {
p := tea.NewProgram(tui.InitialModel(ctx, true, s, eng, version), tea.WithAltScreen(), tea.WithMouseCellMotion())
p := tea.NewProgram(tui.InitialModel(true, s, eng, version), tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil {
slog.Error("TUI failed", "err", err)
}
@@ -489,11 +484,11 @@ func startSSHServer(port int, db store.Store, eng *monitor.Engine, kc *keyCache)
wish.WithAddress(fmt.Sprintf(":%d", port)),
wish.WithHostKeyPath(envOrDefault("UPTOP_SSH_HOST_KEY", ".ssh/id_ed25519")),
wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
return kc.IsAllowed(ctx, key)
return kc.IsAllowed(key)
}),
wish.WithMiddleware(
bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
return tui.InitialModel(s.Context(), false, db, eng, version), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()}
return tui.InitialModel(false, db, eng, version), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()}
}),
),
)
@@ -509,7 +504,8 @@ func startSSHServer(port int, db store.Store, eng *monitor.Engine, kc *keyCache)
return s
}
func seedDemoData(ctx context.Context, s store.Store) {
func seedDemoData(s store.Store) {
ctx := context.Background()
existing, _ := s.GetSites(ctx)
if len(existing) > 0 {
return
@@ -570,8 +566,8 @@ func newKeyCache(db store.Store) *keyCache {
return &keyCache{db: db, ttl: 30 * time.Second}
}
func (c *keyCache) refresh(ctx context.Context) {
users, err := c.db.GetAllUsers(ctx)
func (c *keyCache) refresh() {
users, err := c.db.GetAllUsers(context.Background())
if err != nil {
// Keep the previous key set: a transient DB error must not lock every
// admin out. Revocation still fails closed because Invalidate clears
@@ -604,13 +600,13 @@ func (c *keyCache) Invalidate() {
c.mu.Unlock()
}
func (c *keyCache) IsAllowed(ctx context.Context, incomingKey ssh.PublicKey) bool {
func (c *keyCache) IsAllowed(incomingKey ssh.PublicKey) bool {
c.mu.RLock()
stale := time.Since(c.updated) > c.ttl
c.mu.RUnlock()
if stale {
c.refresh(ctx)
c.refresh()
}
c.mu.RLock()
@@ -656,7 +652,8 @@ func (s *userInvalidatingStore) ImportData(ctx context.Context, data models.Back
return err
}
func seedKeysFromEnv(ctx context.Context, s store.Store) {
func seedKeysFromEnv(s store.Store) {
ctx := context.Background()
var keys []string
if v := os.Getenv("UPTOP_ADMIN_KEY"); v != "" {
+1 -3
View File
@@ -18,9 +18,7 @@ import (
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
const alertHTTPTimeout = 10 * time.Second
var alertClient = &http.Client{Timeout: alertHTTPTimeout}
var alertClient = &http.Client{Timeout: 10 * time.Second}
// sanitizeError strips the request URL from transport errors before they are
// stored or displayed. *url.Error embeds the full URL, which for several
+4 -10
View File
@@ -38,20 +38,14 @@ func Start(ctx context.Context, cfg Config, eng *monitor.Engine) {
// "probe" mode is handled directly in main.go before cluster.Start is called
}
const (
followerTimeout = 2 * time.Second
leaderFailureThreshold = 3
followerRetryInterval = 5 * time.Second
)
func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) {
client := http.Client{Timeout: followerTimeout}
client := http.Client{Timeout: 2 * time.Second}
failures := 0
threshold := leaderFailureThreshold
threshold := 3
for {
select {
case <-time.After(followerRetryInterval):
case <-time.After(5 * time.Second):
case <-ctx.Done():
return
}
@@ -65,7 +59,7 @@ func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) {
isLeaderHealthy := false
if err == nil {
isLeaderHealthy = resp.StatusCode == http.StatusOK
isLeaderHealthy = resp.StatusCode == 200
_ = resp.Body.Close()
}
+3 -9
View File
@@ -26,18 +26,12 @@ type ProbeConfig struct {
AllowPrivateTargets bool
}
const (
probeMinInterval = 10
probeDefaultInterval = 30
probeAPITimeout = 10 * time.Second
)
func RunProbe(ctx context.Context, cfg ProbeConfig) error {
if cfg.Interval < probeMinInterval {
cfg.Interval = probeDefaultInterval
if cfg.Interval < 10 {
cfg.Interval = 30
}
apiClient := &http.Client{Timeout: probeAPITimeout}
apiClient := &http.Client{Timeout: 10 * time.Second}
dial := monitor.SafeDialContext(cfg.AllowPrivateTargets)
strictClient := &http.Client{
Transport: &http.Transport{
+7 -7
View File
@@ -19,8 +19,8 @@ type AlertHealth struct {
// 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(ctx context.Context) {
records, err := e.db.LoadAlertHealth(ctx)
func (e *Engine) InitAlertHealth() {
records, err := e.db.LoadAlertHealth(context.Background())
if err != nil {
return
}
@@ -198,7 +198,7 @@ func (e *Engine) triggerAlert(alertID int, title, message string) {
if alertID <= 0 {
return
}
cfg, err := e.db.GetAlert(e.ctx, alertID)
cfg, err := e.db.GetAlert(context.Background(), alertID)
if err != nil {
e.AddLog(fmt.Sprintf("Failed to load alert config %d: %v", alertID, err))
return
@@ -206,7 +206,7 @@ func (e *Engine) triggerAlert(alertID int, title, message string) {
provider := alert.GetProvider(cfg)
if provider != nil {
go func() {
ctx, cancel := context.WithTimeout(e.ctx, alertSendTimeout)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := provider.Send(ctx, title, message); err != nil {
e.AddLog(fmt.Sprintf("Alert send failed (%s): %v", cfg.Name, err))
@@ -250,8 +250,8 @@ func (e *Engine) GetAlertHealth(alertID int) AlertHealth {
return e.alertHealth[alertID]
}
func (e *Engine) TestAlert(ctx context.Context, alertID int) error {
cfg, err := e.db.GetAlert(ctx, alertID)
func (e *Engine) TestAlert(alertID int) error {
cfg, err := e.db.GetAlert(context.Background(), alertID)
if err != nil {
return fmt.Errorf("failed to load alert: %w", err)
}
@@ -259,7 +259,7 @@ func (e *Engine) TestAlert(ctx context.Context, alertID int) error {
if provider == nil {
return fmt.Errorf("no provider for type %q", cfg.Type)
}
ctx, cancel := context.WithTimeout(ctx, alertSendTimeout)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err = provider.Send(ctx, "🧪 Test Alert", fmt.Sprintf("Test notification from uptop for channel '%s'.", cfg.Name))
if err != nil {
-14
View File
@@ -239,24 +239,10 @@ func (e *Engine) checkGroup(_ context.Context, site models.Site) {
status = models.StatusPending
}
var prev models.Status
e.applyState(site.ID, func(s *models.Site) {
prev = s.Status
s.Status = status
if status != prev && prev != models.StatusPending {
s.StatusChangedAt = time.Now()
}
})
e.recordCheck(site.ID, 0, !status.IsBroken())
if status != prev && prev != models.StatusPending {
e.enqueueWrite(writeStateChange{siteID: site.ID, fromStatus: string(prev), toStatus: string(status)})
if status.IsBroken() {
e.AddLog(fmt.Sprintf("Group '%s' is %s", site.Name, status))
} else if prev.IsBroken() {
e.AddLog(fmt.Sprintf("Group '%s' recovered", site.Name))
}
}
}
func (e *Engine) EnqueueProbeCheck(siteID int, nodeID string, latencyNs int64, isUp bool) {
+2 -2
View File
@@ -14,8 +14,8 @@ type SiteHistory struct {
UpChecks int
}
func (e *Engine) InitHistory(ctx context.Context) {
all, err := e.db.LoadAllHistory(ctx, maxHistoryLen)
func (e *Engine) InitHistory() {
all, err := e.db.LoadAllHistory(context.Background(), maxHistoryLen)
if err != nil {
e.AddLog("Failed to load check history: " + err.Error())
return
+4 -12
View File
@@ -22,7 +22,6 @@ const (
maintPruneInterval = 15 * time.Minute
defaultMaintRetention = 7 * 24 * time.Hour
dbWriteBuffer = 4096
alertSendTimeout = 30 * time.Second
dbPruneInterval = 10 * time.Minute
)
@@ -64,7 +63,6 @@ type Engine struct {
dbWrites chan dbWrite
writerWG sync.WaitGroup
checkerWG sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
stopOnce sync.Once
}
@@ -91,7 +89,6 @@ func newEngine(s store.Store, allowPrivateTargets bool) *Engine {
allowPrivateTargets: allowPrivateTargets,
maintRetention: defaultMaintRetention,
dbWrites: make(chan dbWrite, dbWriteBuffer),
ctx: context.Background(),
db: s,
strictClient: &http.Client{
Transport: &http.Transport{
@@ -202,8 +199,6 @@ func (e *Engine) dbWriter(ctx context.Context) {
}
// drainWrites flushes everything still buffered, best-effort, at shutdown.
// Uses context.Background because the engine ctx is already cancelled when
// this runs — writes still need to reach the DB.
func (e *Engine) drainWrites() {
for {
select {
@@ -242,8 +237,8 @@ func (e *Engine) Stop() {
})
}
func (e *Engine) InitLogs(ctx context.Context) {
entries, err := e.db.LoadLogs(ctx, maxLogEntries)
func (e *Engine) InitLogs() {
entries, err := e.db.LoadLogs(context.Background(), maxLogEntries)
if err != nil {
return
}
@@ -268,7 +263,6 @@ func (e *Engine) Start(ctx context.Context) {
// trace the cross-method call, and cancelling the parent reaps this child
// regardless, so the leak it warns about can't occur.
ctx, e.cancel = context.WithCancel(ctx) //nolint:gosec // cancel is called in Stop()
e.ctx = ctx
e.writerWG.Add(1)
go e.dbWriter(ctx)
@@ -283,6 +277,8 @@ func (e *Engine) Start(ctx context.Context) {
default:
}
e.refreshMaintenanceCache(ctx)
configs, err := e.db.GetSites(ctx)
if err != nil {
e.AddLog(fmt.Sprintf("Failed to load sites: %v", err))
@@ -325,10 +321,6 @@ func (e *Engine) Start(ctx context.Context) {
}
}
// Refresh after sites load so the cache covers newly added sites.
// On first iteration liveState was empty before the loop above.
e.refreshMaintenanceCache(ctx)
e.mu.RLock()
var vanished []int
for id := range e.liveState {
+2 -132
View File
@@ -208,7 +208,7 @@ func TestInitHistory_LoadsFromDB(t *testing.T) {
{SiteID: 1, LatencyNs: 3000000, IsUp: false},
}
e := newTestEngine(ms)
e.InitHistory(context.Background())
e.InitHistory()
h, ok := e.GetHistory(1)
if !ok {
@@ -248,7 +248,7 @@ func TestInitLogs_LoadsFromDB(t *testing.T) {
{Message: "old-log-2"},
}
e := newTestEngine(ms)
e.InitLogs(context.Background())
e.InitLogs()
logs := e.GetLogs()
if len(logs) != 2 {
@@ -445,136 +445,6 @@ func TestEngineStop_Idempotent(t *testing.T) {
e.Stop() // must not panic or block
}
// --- Group 11: Group State History ---
func TestCheckGroup_SavesStateChange(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
group := models.Site{
SiteConfig: models.SiteConfig{ID: 100, Name: "Infra", Type: "group"},
SiteState: models.SiteState{Status: models.StatusUp},
}
child := models.Site{
SiteConfig: models.SiteConfig{ID: 101, Name: "Server", Type: "ping", ParentID: 100},
SiteState: models.SiteState{Status: models.StatusUp},
}
injectSite(e, group)
injectSite(e, child)
// Child goes down → group should transition UP→DOWN.
child.Status = models.StatusDown
injectSite(e, child)
e.checkGroup(context.Background(), group)
s, ok := getSite(e, 100)
if !ok {
t.Fatal("group not found")
}
if s.Status != models.StatusDown {
t.Errorf("expected DOWN, got %s", s.Status)
}
if s.StatusChangedAt.IsZero() {
t.Error("expected StatusChangedAt to be set")
}
logs := e.GetLogs()
found := false
for _, l := range logs {
if containsStr(l.Message, "Group 'Infra' is DOWN") {
found = true
break
}
}
if !found {
t.Error("expected log entry for group state change")
}
// Drain the write channel to verify a state change was enqueued.
select {
case w := <-e.dbWrites:
if w.desc() != "state-change" {
// Could be a check write; try one more.
select {
case w2 := <-e.dbWrites:
if w2.desc() != "state-change" {
t.Errorf("expected state-change write, got %s then %s", w.desc(), w2.desc())
}
default:
t.Errorf("expected state-change write, got only %s", w.desc())
}
}
default:
t.Error("no write enqueued for group state change")
}
}
func TestCheckGroup_NoChangeNoWrite(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
group := models.Site{
SiteConfig: models.SiteConfig{ID: 100, Name: "Infra", Type: "group"},
SiteState: models.SiteState{Status: models.StatusUp},
}
child := models.Site{
SiteConfig: models.SiteConfig{ID: 101, Name: "Server", Type: "ping", ParentID: 100},
SiteState: models.SiteState{Status: models.StatusUp},
}
injectSite(e, group)
injectSite(e, child)
e.checkGroup(context.Background(), group)
// Only a check write should be enqueued, no state-change.
drainCount := 0
for {
select {
case w := <-e.dbWrites:
if w.desc() == "state-change" {
t.Error("state-change write enqueued when status didn't change")
}
drainCount++
default:
goto done
}
}
done:
if drainCount == 0 {
t.Error("expected at least a check write")
}
}
func TestCheckGroup_RecoveryLog(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
group := models.Site{
SiteConfig: models.SiteConfig{ID: 100, Name: "Infra", Type: "group"},
SiteState: models.SiteState{Status: models.StatusDown},
}
child := models.Site{
SiteConfig: models.SiteConfig{ID: 101, Name: "Server", Type: "ping", ParentID: 100},
SiteState: models.SiteState{Status: models.StatusUp},
}
injectSite(e, group)
injectSite(e, child)
e.checkGroup(context.Background(), group)
logs := e.GetLogs()
found := false
for _, l := range logs {
if containsStr(l.Message, "Group 'Infra' recovered") {
found = true
break
}
}
if !found {
t.Error("expected recovery log entry for group")
}
}
// --- Utilities ---
func containsStr(s, substr string) bool {
+1 -3
View File
@@ -7,8 +7,6 @@ import (
"time"
)
const dialTimeout = 10 * time.Second
var privateRanges []*net.IPNet
func init() {
@@ -62,7 +60,7 @@ func SafeDialContext(allowPrivate bool) func(ctx context.Context, network, addr
}
}
dialer := &net.Dialer{Timeout: dialTimeout}
dialer := &net.Dialer{Timeout: 10 * time.Second}
for _, ip := range ips {
target := net.JoinHostPort(ip.IP.String(), port)
conn, err := dialer.DialContext(ctx, network, target)
+4 -4
View File
@@ -141,16 +141,16 @@ func (e *Engine) GetDisplayStatus(site models.Site) string {
return string(site.Status)
}
func (e *Engine) GetStateChanges(ctx context.Context, siteID int, limit int) []models.StateChange {
changes, err := e.db.GetStateChanges(ctx, siteID, limit)
func (e *Engine) GetStateChanges(siteID int, limit int) []models.StateChange {
changes, err := e.db.GetStateChanges(context.Background(), siteID, limit)
if err != nil {
return nil
}
return changes
}
func (e *Engine) GetStateChangesSince(ctx context.Context, siteID int, since time.Time) []models.StateChange {
changes, err := e.db.GetStateChangesSince(ctx, siteID, since)
func (e *Engine) GetStateChangesSince(siteID int, since time.Time) []models.StateChange {
changes, err := e.db.GetStateChangesSince(context.Background(), siteID, since)
if err != nil {
return nil
}
+2 -7
View File
@@ -14,11 +14,6 @@ import (
// guard.
const maxVisitors = 10000
const (
visitorCleanupInterval = 5 * time.Minute
visitorIdleCutoff = 10 * time.Minute
)
type visitor struct {
tokens float64
lastSeen time.Time
@@ -95,13 +90,13 @@ func (rl *RateLimiter) evictOldest() {
}
func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(visitorCleanupInterval)
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
rl.mu.Lock()
cutoff := time.Now().Add(-visitorIdleCutoff)
cutoff := time.Now().Add(-10 * time.Minute)
for ip, v := range rl.visitors {
if v.lastSeen.Before(cutoff) {
delete(rl.visitors, ip)
+8 -20
View File
@@ -45,27 +45,15 @@ type Server struct {
statusRL *RateLimiter
}
const (
pushRateLimit = 60
probeRateLimit = 30
backupRateLimit = 10
statusRateLimit = 120
httpReadHeaderTimeout = 10 * time.Second
httpReadTimeout = 30 * time.Second
httpWriteTimeout = 60 * time.Second
httpIdleTimeout = 120 * time.Second
)
func NewServer(cfg ServerConfig, s store.Store, eng *monitor.Engine) *Server {
return &Server{
cfg: cfg,
store: s,
eng: eng,
pushRL: NewRateLimiter(pushRateLimit, cfg.TrustedProxies),
probeRL: NewRateLimiter(probeRateLimit, cfg.TrustedProxies),
backupRL: NewRateLimiter(backupRateLimit, cfg.TrustedProxies),
statusRL: NewRateLimiter(statusRateLimit, cfg.TrustedProxies),
pushRL: NewRateLimiter(60, cfg.TrustedProxies),
probeRL: NewRateLimiter(30, cfg.TrustedProxies),
backupRL: NewRateLimiter(10, cfg.TrustedProxies),
statusRL: NewRateLimiter(120, cfg.TrustedProxies),
}
}
@@ -89,10 +77,10 @@ func (s *Server) Start() *http.Server {
httpSrv := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: httpReadHeaderTimeout,
ReadTimeout: httpReadTimeout,
WriteTimeout: httpWriteTimeout,
IdleTimeout: httpIdleTimeout,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
if s.cfg.TLSCert != "" && s.cfg.TLSKey != "" {
+4 -8
View File
@@ -17,10 +17,6 @@ const (
maxLogRows = 200
maxStateChangesPerSite = 5000
maxMaintenanceExport = 1000
maxOpenConns = 25
maxIdleConns = 5
connMaxLifetime = 5 * time.Minute
tokenByteLen = 16
)
type SQLStore struct {
@@ -35,9 +31,9 @@ func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) {
if err != nil {
return nil, err
}
db.SetMaxOpenConns(maxOpenConns)
db.SetMaxIdleConns(maxIdleConns)
db.SetConnMaxLifetime(connMaxLifetime)
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
_, isDollar := dialect.(*PostgresDialect)
return &SQLStore{db: db, dialect: dialect, dollar: isDollar}, nil
}
@@ -65,7 +61,7 @@ func (s *SQLStore) q(query string) string {
}
func generateToken() (string, error) {
b := make([]byte, tokenByteLen)
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("crypto/rand failed: %w", err)
}
-20
View File
@@ -1,20 +0,0 @@
package tui
import "time"
const (
nodeOnlineThreshold = 60 * time.Second
nodeStaleThreshold = 5 * time.Minute
uptimeGoodPct = 99.0
uptimeExcellentPct = 99.9
uptimeWarnPct = 95.0
uptimePrecisionPct = 99.99
stateHistoryLookback = 30 * 24 * time.Hour
stateHistoryDays = 30
stateHistoryLimit = 100
httpErrorThreshold = 400
errorDetailMaxLen = 30
)
+10 -14
View File
@@ -13,9 +13,9 @@ import (
tea "github.com/charmbracelet/bubbletea"
)
func loadCollapsed(ctx context.Context, s store.Store) map[int]bool {
func loadCollapsed(s store.Store) map[int]bool {
m := make(map[int]bool)
raw, err := s.GetPreference(ctx, "collapsed_groups")
raw, err := s.GetPreference(context.Background(), "collapsed_groups")
if err != nil || raw == "" {
return m
}
@@ -123,10 +123,9 @@ func (m *Model) refreshLive() {
ordered = filterSites(ordered, m.filterText)
}
m.sites = ordered
m.buildMaintSet()
m.refreshLogContent()
if m.selectedID != 0 {
if m.currentTab == tabMonitors && m.selectedID != 0 {
for i, s := range m.sites {
if s.ID == m.selectedID {
m.cursor = i
@@ -138,7 +137,7 @@ func (m *Model) refreshLive() {
}
func (m *Model) syncSelectedID() {
if m.cursor < len(m.sites) {
if m.currentTab == tabMonitors && m.cursor < len(m.sites) {
m.selectedID = m.sites[m.cursor].ID
}
}
@@ -164,9 +163,9 @@ func (m *Model) loadTabDataCmd() tea.Cmd {
m.tabSeq++
seq := m.tabSeq
st := m.store
ctx := m.ctx
isAdmin := m.isAdmin
return func() tea.Msg {
ctx := context.Background()
alerts, err := st.GetAllAlerts(ctx)
if err != nil {
return tabDataMsg{seq: seq, err: err}
@@ -193,7 +192,6 @@ func (m *Model) loadTabDataCmd() tea.Cmd {
// goroutine. View renders the cached result rather than querying the DB.
func (m *Model) loadDetailCmd(siteID int) tea.Cmd {
eng := m.engine
ctx := m.ctx
var currentStatus models.Status
for _, s := range m.sites {
if s.ID == siteID {
@@ -202,10 +200,10 @@ func (m *Model) loadDetailCmd(siteID int) tea.Cmd {
}
}
return func() tea.Msg {
changes := eng.GetStateChanges(ctx, siteID, 5)
changes := eng.GetStateChanges(siteID, 5)
now := time.Now()
allChanges := eng.GetStateChangesSince(ctx, siteID, now.Add(-stateHistoryLookback))
daily := monitor.ComputeDailyBreakdown(allChanges, currentStatus, stateHistoryDays, now)
allChanges := eng.GetStateChangesSince(siteID, now.Add(-30*24*time.Hour))
daily := monitor.ComputeDailyBreakdown(allChanges, currentStatus, 30, now)
return detailDataMsg{siteID: siteID, changes: changes, dailyDays: daily}
}
}
@@ -214,9 +212,8 @@ func (m *Model) loadDetailCmd(siteID int) tea.Cmd {
// the UI goroutine.
func (m *Model) loadHistoryCmd(siteID int) tea.Cmd {
eng := m.engine
ctx := m.ctx
return func() tea.Msg {
return historyDataMsg{siteID: siteID, changes: eng.GetStateChanges(ctx, siteID, stateHistoryLimit)}
return historyDataMsg{siteID: siteID, changes: eng.GetStateChanges(siteID, 100)}
}
}
@@ -225,13 +222,12 @@ func (m *Model) loadHistoryCmd(siteID int) tea.Cmd {
// can be recognized and dropped.
func (m *Model) loadSLACmd(siteID, periodIdx int) tea.Cmd {
eng := m.engine
ctx := m.ctx
since := time.Now().Add(-slaPeriods[periodIdx].duration)
return func() tea.Msg {
return slaDataMsg{
siteID: siteID,
periodIdx: periodIdx,
changes: eng.GetStateChangesSince(ctx, siteID, since),
changes: eng.GetStateChangesSince(siteID, since),
}
}
}
+8 -8
View File
@@ -58,7 +58,7 @@ func classifyError(errorReason string, siteType string, statusCode int) ErrorCat
if strings.HasPrefix(lower, "http ") || strings.Contains(lower, "keyword not found") {
return ErrCatHTTP
}
if statusCode >= httpErrorThreshold {
if statusCode >= 400 {
return ErrCatHTTP
}
@@ -145,7 +145,7 @@ func extractDetail(errorReason string, cat ErrorCategory, statusCode int) string
}
}
if detail == "" {
detail = limitStr(errorReason, errorDetailMaxLen)
detail = limitStr(errorReason, 30)
}
case ErrCatTCP:
for _, keyword := range []string{"connection refused", "connection reset", "no route to host", "network unreachable"} {
@@ -155,7 +155,7 @@ func extractDetail(errorReason string, cat ErrorCategory, statusCode int) string
}
}
if detail == "" {
detail = limitStr(errorReason, errorDetailMaxLen)
detail = limitStr(errorReason, 30)
}
case ErrCatTLS:
for _, keyword := range []string{"certificate expired", "certificate has expired", "handshake failure", "unknown authority"} {
@@ -166,16 +166,16 @@ func extractDetail(errorReason string, cat ErrorCategory, statusCode int) string
}
if detail == "" && strings.Contains(lower, "x509:") {
idx := strings.Index(lower, "x509:")
detail = limitStr(errorReason[idx:], errorDetailMaxLen)
detail = limitStr(errorReason[idx:], 30)
}
if detail == "" {
detail = limitStr(errorReason, errorDetailMaxLen)
detail = limitStr(errorReason, 30)
}
case ErrCatHTTP:
if statusCode > 0 {
detail = fmt.Sprintf("HTTP %d", statusCode)
} else {
detail = limitStr(errorReason, errorDetailMaxLen)
detail = limitStr(errorReason, 30)
}
case ErrCatTimeout:
if strings.Contains(lower, "i/o timeout") {
@@ -187,12 +187,12 @@ func extractDetail(errorReason string, cat ErrorCategory, statusCode int) string
if strings.Contains(lower, "no icmp response") {
detail = "no response"
} else {
detail = limitStr(errorReason, errorDetailMaxLen)
detail = limitStr(errorReason, 30)
}
case ErrCatPrivate:
detail = "private IP blocked"
default:
detail = limitStr(errorReason, errorDetailMaxLen)
detail = limitStr(errorReason, 30)
}
return detail
+4 -32
View File
@@ -49,13 +49,13 @@ func siteOrder(s models.Site) int {
return 3
}
switch s.Status {
case models.StatusDown, models.StatusSSLExp:
case "DOWN", "SSL EXP":
return 0
case models.StatusStale:
case "STALE":
return 1
case models.StatusLate:
case "LATE":
return 1
case models.StatusPending:
case "PENDING":
return 3
default:
return 2
@@ -104,13 +104,6 @@ func (m Model) fmtLatency(d time.Duration) string {
return m.st.dangerStyle.Render(s)
}
func (m Model) fmtUptimeMaint(statuses []bool, siteID int) string {
if m.isMonitorInMaintenance(siteID) {
return m.st.subtleStyle.Render("—")
}
return m.fmtUptime(statuses)
}
func (m Model) fmtUptime(statuses []bool) string {
if len(statuses) == 0 {
return m.st.subtleStyle.Render("—")
@@ -162,27 +155,6 @@ func (m Model) fmtRetries(site models.Site) string {
return s
}
func (m Model) fmtStatusDot(status models.Status, paused bool, inMaint bool) string {
if paused {
return m.st.warnStyle.Render("◇")
}
if inMaint {
return m.st.maintStyle.Render("◼")
}
switch status {
case models.StatusDown, models.StatusSSLExp:
return m.st.dangerStyle.Render("▼")
case models.StatusLate:
return m.st.warnStyle.Render("◆")
case models.StatusStale:
return m.st.staleStyle.Render("◆")
case models.StatusPending:
return m.st.subtleStyle.Render("○")
default:
return m.st.specialStyle.Render("▲")
}
}
func (m Model) fmtStatus(status models.Status, paused bool, inMaint bool) string {
if paused {
return m.st.warnStyle.Render("◇ PAUSED")
-83
View File
@@ -6,85 +6,6 @@ import (
"github.com/charmbracelet/lipgloss"
)
func (m Model) titledPanelH(title, content, footer string, width, height, scrollOffset int, focused bool) string {
if height <= 0 {
return m.titledPanel(title, content, width, focused)
}
borderColor := m.theme.Border
titleColor := m.theme.Muted
if focused {
borderColor = m.theme.Accent
titleColor = m.theme.Accent
}
bc := lipgloss.NewStyle().Foreground(borderColor)
tc := lipgloss.NewStyle().Foreground(titleColor).Bold(true)
innerW := width - 2
if innerW < 10 {
innerW = 10
}
titleRendered := tc.Render(" " + title + " ")
titleLen := len([]rune(title)) + 2
fillLen := innerW - titleLen - 1
if fillLen < 0 {
fillLen = 0
}
top := bc.Render("╭─") + titleRendered + bc.Render(strings.Repeat("─", fillLen)+"╮")
bottom := bc.Render("╰" + strings.Repeat("─", innerW) + "╯")
contentStyle := lipgloss.NewStyle().Width(innerW).MaxWidth(innerW)
inner := contentStyle.Render(content)
contentLines := strings.Split(inner, "\n")
var footerLines []string
if footer != "" {
footerRendered := contentStyle.Render(footer)
footerLines = strings.Split(footerRendered, "\n")
}
bodyH := height - 2 - len(footerLines)
if bodyH < 1 {
bodyH = 1
}
if scrollOffset > len(contentLines)-bodyH {
scrollOffset = len(contentLines) - bodyH
}
if scrollOffset < 0 {
scrollOffset = 0
}
end := scrollOffset + bodyH
if end > len(contentLines) {
end = len(contentLines)
}
visible := contentLines[scrollOffset:end]
borderLine := func(line string) string {
return bc.Render("│") + line + strings.Repeat(" ", max(0, innerW-lipgloss.Width(line))) + bc.Render("│")
}
emptyLine := borderLine(strings.Repeat(" ", innerW))
var lines []string
lines = append(lines, top)
for _, line := range visible {
lines = append(lines, borderLine(line))
}
for len(lines) < height-1-len(footerLines) {
lines = append(lines, emptyLine)
}
for _, line := range footerLines {
lines = append(lines, borderLine(line))
}
lines = append(lines, bottom)
return strings.Join(lines, "\n")
}
func (m Model) titledPanel(title, content string, width int, focused bool) string {
borderColor := m.theme.Border
titleColor := m.theme.Muted
@@ -129,7 +50,3 @@ func max(a, b int) int {
}
return b
}
func placeOverlay(fg string, termW, termH int) string {
return lipgloss.Place(termW, termH, lipgloss.Center, lipgloss.Center, fg)
}
+4 -2
View File
@@ -156,8 +156,9 @@ func resolveSparklineIndex(x, sparkWidth, dataLen int) int {
}
func (m Model) groupSparkline(groupID int, width int, bg lipgloss.TerminalColor) string {
allSites := m.engine.GetAllSites()
var childStatuses [][]bool
for _, s := range m.sites {
for _, s := range allSites {
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
hist, _ := m.engine.GetHistory(s.ID)
if len(hist.Statuses) > 0 {
@@ -208,8 +209,9 @@ func (m Model) groupSparkline(groupID int, width int, bg lipgloss.TerminalColor)
}
func (m Model) groupUptime(groupID int) string {
allSites := m.engine.GetAllSites()
var allStatuses [][]bool
for _, s := range m.sites {
for _, s := range allSites {
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
hist, _ := m.engine.GetHistory(s.ID)
if len(hist.Statuses) > 0 {
+9 -83
View File
@@ -1,6 +1,7 @@
package tui
import (
"context"
"fmt"
neturl "net/url"
"sort"
@@ -223,83 +224,14 @@ func (m Model) viewAlertsTab() string {
summary := fmt.Sprintf("%d channels · %d types · %d sent · %d %s",
len(m.alerts), len(types), totalSent, totalFail, failLabel)
var detail string
if m.cursor < len(m.alerts) {
a := m.alerts[m.cursor]
h := m.engine.GetAlertHealth(a.ID)
detail = m.alertSelectedDetail(a, h)
}
return tbl + "\n " + detail + "\n " + m.st.subtleStyle.Render(summary)
}
func (m Model) alertSelectedDetail(a models.AlertConfig, h monitor.AlertHealth) string {
dot := m.st.subtleStyle.Render(" · ")
label := m.st.subtleStyle
parts := []string{fmtAlertType(a.Type)}
parts = append(parts, m.fmtAlertConfigFull(a))
if !h.LastSendAt.IsZero() {
if h.LastSendOK {
parts = append(parts, m.st.specialStyle.Render("●")+" "+label.Render("sent")+" "+m.fmtTimeAgo(h.LastSendAt))
} else {
parts = append(parts, m.st.dangerStyle.Render("●")+" "+label.Render("failed")+" "+m.fmtTimeAgo(h.LastSendAt))
}
}
if h.SendCount > 0 {
parts = append(parts, label.Render(fmt.Sprintf("%d sent, %d failed", h.SendCount, h.FailCount)))
}
return strings.Join(parts, dot)
}
func (m Model) fmtAlertConfigFull(alert models.AlertConfig) string {
switch alert.Type {
case "email":
host := alert.Settings["host"]
to := alert.Settings["to"]
if host != "" && to != "" {
return fmt.Sprintf("%s → %s", host, to)
}
if host != "" {
return host
}
return m.st.subtleStyle.Render("—")
case "ntfy":
topic := alert.Settings["topic"]
url := alert.Settings["url"]
if url != "" && topic != "" {
return fmt.Sprintf("%s/%s", url, topic)
}
return m.st.subtleStyle.Render("—")
case "telegram":
if id := alert.Settings["chat_id"]; id != "" {
return fmt.Sprintf("chat:%s", id)
}
return m.st.subtleStyle.Render("—")
case "gotify":
if url := alert.Settings["url"]; url != "" {
return url
}
return m.st.subtleStyle.Render("—")
default:
if val, ok := alert.Settings["url"]; ok && val != "" {
return maskWebhookURL(val)
}
return m.st.subtleStyle.Render("—")
}
return tbl + "\n " + m.st.subtleStyle.Render(summary)
}
func (m Model) viewAlertDetailPanel() string {
idx := m.cursor
if m.returnState == stateSettings {
idx = m.settingsCursor
}
if idx >= len(m.alerts) {
if m.cursor >= len(m.alerts) {
return ""
}
a := m.alerts[idx]
a := m.alerts[m.cursor]
h := m.engine.GetAlertHealth(a.ID)
var b strings.Builder
@@ -332,7 +264,7 @@ func (m Model) viewAlertDetailPanel() string {
}
b.WriteString(m.divider() + "\n")
b.WriteString(m.st.titleStyle.Render(" CONFIGURATION") + "\n")
b.WriteString(m.st.subtleStyle.Render(" CONFIGURATION") + "\n")
// Render through the same allowlist the backup export uses — this panel
// ends up in screen shares and asciinema recordings. Keys are sorted so
// rows don't reshuffle every render.
@@ -352,7 +284,7 @@ func (m Model) viewAlertDetailPanel() string {
}
b.WriteString(m.divider() + "\n")
b.WriteString(m.st.subtleStyle.Render(" [e] Edit [t] Test [q/Esc] Back"))
b.WriteString(m.st.subtleStyle.Render(" [q/Esc] Back [e] Edit [t] Test"))
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
}
@@ -605,21 +537,15 @@ func (m *Model) submitAlertForm() tea.Cmd {
}
st := m.store
ctx := m.ctx
id := m.editID
name, aType := d.Name, d.AlertType
if m.returnState == stateSettings {
m.state = stateSettings
} else {
m.state = stateDashboard
}
m.returnState = 0
m.state = stateDashboard
if id > 0 {
return writeCmd("Update alert", func() error {
return st.UpdateAlert(ctx, id, name, aType, settings)
return st.UpdateAlert(context.Background(), id, name, aType, settings)
})
}
return writeCmd("Add alert", func() error {
return st.AddAlert(ctx, name, aType, settings)
return st.AddAlert(context.Background(), name, aType, settings)
})
}
-18
View File
@@ -5,7 +5,6 @@ import (
"strings"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss"
)
type logSeverity int
@@ -73,23 +72,6 @@ func (m Model) renderLogLine(entry models.LogEntry) string {
return fmt.Sprintf(" %s %s %s", ts, tag, entry.Message)
}
func (m Model) viewLogsFullscreen() string {
header := " " + m.st.titleStyle.Render("Logs") + "\n" + m.divider()
filterLabel := m.st.subtleStyle.Render("[f] All")
if m.logFilterImportant {
filterLabel = m.st.subtleStyle.Render("[f] Important only")
}
countLabel := m.st.subtleStyle.Render(fmt.Sprintf("%d/%d", m.logShown, m.logTotal))
footer := m.divider() + "\n " + filterLabel + " " + countLabel + " " + m.st.subtleStyle.Render("[q/Esc] Back")
m.logViewport.Width = m.termWidth - chromePadH
m.logViewport.Height = m.termHeight - 8
return lipgloss.NewStyle().Padding(1, 2).Render(
header + "\n" + m.logViewport.View() + "\n" + footer)
}
func (m *Model) refreshLogContent() {
var rendered []string
total := 0
+3 -3
View File
@@ -40,13 +40,13 @@ func (m Model) renderCompactLogLine(entry models.LogEntry, maxW int) string {
return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg
}
func (m Model) viewLogsStrip(width, maxLines int) string {
func (m Model) viewLogsSidebar(width, maxLines int) string {
logs := m.engine.GetLogs()
if len(logs) == 0 {
return m.st.subtleStyle.Render(" No logs yet")
}
style := lipgloss.NewStyle().Width(width).MaxWidth(width)
sidebarStyle := lipgloss.NewStyle().Width(width).MaxWidth(width)
var all []string
for _, entry := range logs {
@@ -69,7 +69,7 @@ func (m Model) viewLogsStrip(width, maxLines int) string {
}
visible := all[start:end]
return style.Render(strings.Join(visible, "\n"))
return sidebarStyle.Render(strings.Join(visible, "\n"))
}
func (m *Model) scrollLogs(delta int) {
+108 -14
View File
@@ -1,6 +1,7 @@
package tui
import (
"context"
"fmt"
"strconv"
"time"
@@ -20,37 +21,130 @@ type maintFormData struct {
CustomHours string
}
func (m Model) isMonitorInMaintenance(monitorID int) bool {
return m.maintSet[monitorID]
func (m Model) fmtMaintStatus(mw models.MaintenanceWindow) string {
now := time.Now()
if mw.StartTime.After(now) {
return m.st.warnStyle.Render("SCHEDULED")
}
if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
return m.st.subtleStyle.Render("ENDED")
}
return m.st.specialStyle.Render("ACTIVE")
}
func (m *Model) buildMaintSet() {
set := make(map[int]bool)
func (m Model) fmtMaintType(t string) string {
if t == "incident" {
return m.st.dangerStyle.Render("incident")
}
return m.st.maintStyle.Render("maintenance")
}
func fmtMaintMonitorW(monitorID int, sites []models.Site, maxW int) string {
if monitorID == 0 {
return "All"
}
for _, s := range sites {
if s.ID == monitorID {
return limitStr(s.Name, maxW)
}
}
return fmt.Sprintf("#%d", monitorID)
}
func (m Model) fmtMaintTime(t time.Time, colW int) string {
if t.IsZero() {
return m.st.subtleStyle.Render("—")
}
now := time.Now()
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
return t.Format("15:04")
}
if colW >= 14 {
return t.Format("15:04 Jan 02")
}
return t.Format("Jan 02")
}
func (m Model) isMonitorInMaintenance(monitorID int) bool {
for _, mw := range m.maintenanceWindows {
if mw.Type != "maintenance" {
continue
}
now := time.Now()
if mw.StartTime.After(now) {
continue
}
if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
continue
}
if mw.MonitorID == 0 {
for _, s := range m.sites {
set[s.ID] = true
}
break
if mw.MonitorID == 0 || mw.MonitorID == monitorID {
return true
}
set[mw.MonitorID] = true
for _, s := range m.sites {
if s.ParentID == mw.MonitorID {
set[s.ID] = true
if s.ID == monitorID && s.ParentID > 0 && mw.MonitorID == s.ParentID {
return true
}
}
}
m.maintSet = set
return false
}
func (m Model) viewMaintTab() string {
if len(m.maintenanceWindows) == 0 {
return m.emptyState("No maintenance windows or incidents.", "[n] Create one")
}
var headers []string
var widths []int
if m.isWide() {
headers = []string{"#", "TITLE", "TYPE", "MONITORS", "STATUS", "STARTED", "ENDS"}
widths = []int{4, 24, 14, 22, 12, 16, 16}
} else {
headers = []string{"#", "TITLE", "TYPE", "MON", "ST", "START", "ENDS"}
widths = []int{4, 14, 13, 14, 11, 14, 14}
}
titleW := widths[1]
monW := widths[3]
timeW := widths[5]
tbl := m.renderTable(
headers,
len(m.maintenanceWindows),
func(start, end int) [][]string {
var rows [][]string
allSites := m.engine.GetAllSites()
for i := start; i < end; i++ {
mw := m.maintenanceWindows[i]
rows = append(rows, []string{
strconv.Itoa(i + 1),
m.zones.Mark(fmt.Sprintf("maint-%d", i), limitStr(mw.Title, titleW-2)),
m.fmtMaintType(mw.Type),
fmtMaintMonitorW(mw.MonitorID, allSites, monW-2),
m.fmtMaintStatus(mw),
m.fmtMaintTime(mw.StartTime, timeW),
m.fmtMaintTime(mw.EndTime, timeW),
})
}
return rows
},
widths,
nil,
)
now := time.Now()
var active, scheduled, ended int
for _, mw := range m.maintenanceWindows {
if mw.StartTime.After(now) {
scheduled++
} else if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
ended++
} else {
active++
}
}
summary := fmt.Sprintf("%d active · %d scheduled · %d ended", active, scheduled, ended)
return tbl + "\n " + m.st.subtleStyle.Render(summary)
}
func (m *Model) initMaintHuhForm() tea.Cmd {
@@ -160,9 +254,9 @@ func (m *Model) submitMaintForm() tea.Cmd {
}
st := m.store
ctx := m.ctx
m.state = stateDashboard
return writeCmd("Add maintenance window", func() error {
ctx := context.Background()
overlaps, _ := st.GetOverlappingMaintenanceWindows(ctx, mw.MonitorID, mw.StartTime, mw.EndTime)
if len(overlaps) > 0 {
_ = st.SaveLog(ctx, fmt.Sprintf("Overlap: new window %q overlaps with existing %q", mw.Title, overlaps[0].Title))
+10 -10
View File
@@ -8,19 +8,19 @@ import (
func (m Model) viewNodesTab() string {
if len(m.nodes) == 0 {
return m.emptyState("No probe nodes connected.", "Probes auto-register on connection.")
return m.emptyState("No probe nodes connected.", "")
}
var headers []string
var widths []int
if m.isWide() {
headers = []string{"#", "NAME", "REGION", "LAST SEEN", "VERSION", "STATUS"}
widths = []int{4, 24, 14, 16, 12, 10}
headers = []string{"NAME", "REGION", "LAST SEEN", "VERSION", "STATUS"}
widths = []int{24, 14, 16, 12, 10}
} else {
headers = []string{"#", "NAME", "REGION", "SEEN", "VER", "STATUS"}
widths = []int{4, 16, 10, 10, 8, 8}
headers = []string{"NAME", "REGION", "SEEN", "VER", "STATUS"}
widths = []int{16, 10, 10, 8, 8}
}
nameW := widths[1]
nameW := widths[0]
tbl := m.renderTable(
headers,
@@ -43,7 +43,7 @@ func (m Model) viewNodesTab() string {
version = m.st.subtleStyle.Render("—")
}
status := m.fmtNodeStatus(node.LastSeen)
rows = append(rows, []string{fmt.Sprintf("%d", i+1), name, region, lastSeen, version, status})
rows = append(rows, []string{name, region, lastSeen, version, status})
}
return rows
},
@@ -55,7 +55,7 @@ func (m Model) viewNodesTab() string {
regions := make(map[string]bool)
var leader string
for _, n := range m.nodes {
if time.Since(n.LastSeen) < nodeOnlineThreshold {
if time.Since(n.LastSeen) < 60*time.Second {
online++
}
if n.Region != "" {
@@ -81,10 +81,10 @@ func (m Model) fmtNodeStatus(lastSeen time.Time) string {
return m.st.subtleStyle.Render("UNKNOWN")
}
ago := time.Since(lastSeen)
if ago < nodeOnlineThreshold {
if ago < 60*time.Second {
return m.st.specialStyle.Render("ONLINE")
}
if ago < nodeStaleThreshold {
if ago < 5*time.Minute {
return m.st.warnStyle.Render("STALE")
}
return m.st.dangerStyle.Render("OFFLINE")
+8 -6
View File
@@ -1,26 +1,28 @@
package tui
import (
"fmt"
"github.com/charmbracelet/lipgloss"
)
func (m Model) viewSettingsTab() string {
maxSections := 2
if m.isAdmin {
maxSections = 3
}
sections := []string{"Alerts", "Nodes"}
if m.isAdmin {
sections = append(sections, "Users")
}
_ = maxSections
var tabs []string
for i, name := range sections {
var rendered string
if i == m.settingsSection {
rendered = m.st.activeTab.Render(name)
tabs = append(tabs, m.st.activeTab.Render(name))
} else {
rendered = m.st.inactiveTab.Render(name)
tabs = append(tabs, m.st.inactiveTab.Render(name))
}
tabs = append(tabs, m.zones.Mark(fmt.Sprintf("section-%d", i), rendered))
}
header := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
+24 -68
View File
@@ -1,6 +1,7 @@
package tui
import (
"context"
"fmt"
"net/url"
"strconv"
@@ -34,9 +35,10 @@ type siteFormData struct {
type colKey int
const (
colDot colKey = iota
colNum colKey = iota
colName
colType
colStatus
colLatency
colUptime
colHistory
@@ -54,18 +56,17 @@ type columnDef struct {
}
var siteColumns = []columnDef{
{colDot, "", "", 3, 3, 0},
{colNum, "#", "#", 4, 4, 0},
{colName, "NAME", "NAME", 0, 0, 0},
{colType, "TYPE", "TYPE", 10, 8, 0},
{colType, "TYPE", "TYPE", 10, 8, mediumBreakpoint},
{colStatus, "STATUS", "STATUS", 10, 10, 0},
{colLatency, "LATENCY", "LAT", 10, 7, 0},
{colUptime, "UPTIME", "UP%", 8, 8, 0},
{colHistory, "HISTORY", "HISTORY", 0, 0, 0},
{colSSL, "SSL", "SSL", 7, 5, 0},
{colRetries, "RETRIES", "RT", 9, 5, 0},
{colUptime, "UPTIME", "UP%", 8, 8, mediumBreakpoint},
{colHistory, "HISTORY", "HISTORY", 0, 0, mediumBreakpoint},
{colSSL, "SSL", "SSL", 7, 5, wideBreakpoint},
{colRetries, "RETRIES", "RT", 9, 5, wideBreakpoint},
}
var columnDropOrder = []colKey{colHistory, colRetries, colSSL, colUptime, colType}
type tableLayout struct {
nameW, sparkW int
headers []string
@@ -76,51 +77,17 @@ type tableLayout struct {
func (m Model) computeLayout() tableLayout {
wide := m.isWide()
cw := m.contentWidth
if cw == 0 {
cw = m.termWidth
}
dropped := make(map[colKey]bool)
minNameW := 20
minSparkW := 12
for attempt := 0; attempt <= len(columnDropOrder); attempt++ {
var fixed int
var flexCount int
for _, c := range siteColumns {
if dropped[c.key] {
continue
}
w := c.narrowW
if wide {
w = c.wideW
}
if w > 0 {
fixed += w
} else {
flexCount++
}
}
numCols := len(siteColumns) - len(dropped)
borderOverhead := 2 + (numCols - 1)
avail := cw - chromePadH - 2 - borderOverhead - fixed
minFlex := minNameW
if flexCount > 1 {
minFlex += minSparkW
}
if avail >= minFlex || attempt >= len(columnDropOrder) {
break
}
dropped[columnDropOrder[attempt]] = true
}
var active []colKey
var headers []string
var widths []int
var fixed int
cw := m.contentWidth
if cw == 0 {
cw = m.termWidth
}
for _, c := range siteColumns {
if dropped[c.key] {
if c.minTerm > 0 && cw < c.minTerm {
continue
}
active = append(active, c.key)
@@ -140,15 +107,10 @@ func (m Model) computeLayout() tableLayout {
}
sortColMap := map[int]colKey{
sortStatus: colDot,
sortStatus: colStatus,
sortName: colName,
sortLatency: colLatency,
}
sortableKeys := map[colKey]string{
colDot: "sort-status",
colName: "sort-name",
colLatency: "sort-latency",
}
if sortedKey, ok := sortColMap[m.sortColumn]; ok {
arrow := "▼"
if m.sortAsc {
@@ -161,11 +123,6 @@ func (m Model) computeLayout() tableLayout {
}
}
}
for i, k := range active {
if zoneID, ok := sortableKeys[k]; ok {
headers[i] = m.zones.Mark(zoneID, headers[i])
}
}
numCols := len(headers)
borderOverhead := 2 + (numCols - 1)
@@ -279,11 +236,11 @@ func (m Model) viewSitesTab() string {
if site.Type == "group" {
groupRows[i-start] = true
icon := typeIcon("group", m.collapsed[site.ID])
inMaint := m.isMonitorInMaintenance(site.ID)
cells := map[colKey]string{
colDot: m.fmtStatusDot(site.Status, site.Paused, inMaint),
colNum: strconv.Itoa(i + 1),
colName: m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-4)),
colType: "group",
colStatus: m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
colLatency: m.st.subtleStyle.Render("—"),
colUptime: m.groupUptime(site.ID),
colHistory: m.groupSparkline(site.ID, sparkWidth, rowBg),
@@ -327,13 +284,13 @@ func (m Model) viewSitesTab() string {
spark = m.latencySparkline(hist.Latencies, hist.Statuses, sparkWidth, rowBg)
}
inMaint := m.isMonitorInMaintenance(site.ID)
cells := map[colKey]string{
colDot: m.fmtStatusDot(site.Status, site.Paused, inMaint),
colNum: strconv.Itoa(i + 1),
colName: m.zones.Mark(fmt.Sprintf("site-%d", i), name),
colType: typeIcon(site.Type, false) + " " + site.Type,
colStatus: m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
colLatency: m.fmtLatency(site.Latency),
colUptime: m.fmtUptimeMaint(hist.Statuses, site.ID),
colUptime: m.fmtUptime(hist.Statuses),
colHistory: spark,
colSSL: m.fmtSSL(site),
colRetries: m.fmtRetries(site),
@@ -647,11 +604,10 @@ func (m *Model) submitSiteForm() tea.Cmd {
}
st := m.store
ctx := m.ctx
m.state = stateDashboard
if m.editID > 0 {
m.engine.UpdateSiteConfig(cfg)
return writeCmd("Update site", func() error { return st.UpdateSite(ctx, cfg) })
return writeCmd("Update site", func() error { return st.UpdateSite(context.Background(), cfg) })
}
return writeCmd("Add site", func() error { return st.AddSite(ctx, cfg) })
return writeCmd("Add site", func() error { return st.AddSite(context.Background(), cfg) })
}
+4 -9
View File
@@ -1,6 +1,7 @@
package tui
import (
"context"
"fmt"
tea "github.com/charmbracelet/bubbletea"
@@ -113,21 +114,15 @@ func (m *Model) initUserHuhForm() tea.Cmd {
func (m *Model) submitUserForm() tea.Cmd {
d := m.userFormData
st := m.store
ctx := m.ctx
id := m.editID
username, key, role := d.Username, d.PublicKey, d.Role
if m.returnState == stateSettings {
m.state = stateSettings
} else {
m.state = stateDashboard
}
m.returnState = 0
m.state = stateDashboard
if id > 0 {
return writeCmd("Update user", func() error {
return st.UpdateUser(ctx, id, username, key, role)
return st.UpdateUser(context.Background(), id, username, key, role)
})
}
return writeCmd("Add user", func() error {
return st.AddUser(ctx, username, key, role)
return st.AddUser(context.Background(), username, key, role)
})
}
+52 -64
View File
@@ -80,6 +80,14 @@ const (
chromeFooter = 2 // footer: "\n" prefix + text line
chromeTable = 3 // renderTable "\n" prefix + top border + header + bottom border (lipgloss collapses two into three rendered lines)
chromeBase = chromePadV + chromeHeader + chromeGaps + chromeFooter + chromeTable
detailSparkWidth = 40
)
const (
tabMonitors = 0
tabMaint = 1
tabSettings = 2
)
const (
@@ -92,21 +100,6 @@ const (
panelMonitors = 0
panelLogs = 1
panelDetail = 2
panelMaint = 3
)
type bottomPanel int
const (
bottomNone bottomPanel = iota
bottomLogs
bottomMaint
)
const (
detailDefault = 0
detailSLA = 1
detailHistory = 2
)
const (
@@ -119,25 +112,24 @@ const (
type sessionState int
const (
stateDashboard sessionState = 0
stateLogs sessionState = 1
stateDetailFullscreen sessionState = 2
stateAlertDetail sessionState = 3
stateFormSite sessionState = 4
stateFormAlert sessionState = 5
stateFormUser sessionState = 6
stateConfirmDelete sessionState = 7
stateFormMaint sessionState = 8
stateSettings sessionState = 11
stateMaintDetail sessionState = 12
stateDashboard sessionState = iota
stateLogs
stateUsers
stateDetail
stateAlertDetail
stateFormSite
stateFormAlert
stateFormUser
stateConfirmDelete
stateFormMaint
stateHistory
stateSLA
)
type Model struct {
state sessionState
returnState sessionState
currentTab int
settingsSection int
settingsCursor int
settingsOffset int
cursor int
selectedID int
sortColumn int
@@ -148,7 +140,6 @@ type Model struct {
termHeight int
contentWidth int
focusedPanel int
detailMode int
logScrollOffset int
editID int
editToken string
@@ -165,27 +156,25 @@ type Model struct {
logTotal int
logShown int
historyViewport viewport.Model
historyChanges []models.StateChange
historySiteName string
historySiteID int
slaReport monitor.SLAReport
slaDailyBreakdown []monitor.DayReport
slaSiteName string
slaSiteID int
slaPeriodIdx int
detailScrollOffset int
slaViewport viewport.Model
slaReport monitor.SLAReport
slaDailyBreakdown []monitor.DayReport
slaSiteName string
slaSiteID int
slaPeriodIdx int
isAdmin bool
zones *zone.Manager
deleteID int
deleteName string
deleteKind string
deleteTab int
maintDetailID int
ctx context.Context
collapsed map[int]bool
store store.Store
engine *monitor.Engine
@@ -207,31 +196,31 @@ type Model struct {
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
tabSeq int // seq of the newest issued tab-data load
maintSet map[int]bool
bottomPanel bottomPanel
detailOpen bool
maintCursor int
detailChanges []models.StateChange
detailChangesSiteID int
detailDailyDays []monitor.DayReport
detailViewport viewport.Model
filterMode bool
filterText string
sparkTooltipIdx int // clicked sparkline data index, -1 = none
// 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
version string
}
func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor.Engine, version string) Model {
func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version string) Model {
vpLogs := viewport.New(100, 20)
vpLogs.SetContent("Waiting for logs...")
z := zone.New()
spring := harmonica.NewSpring(harmonica.FPS(10), 6.0, 0.4)
collapsed := loadCollapsed(ctx, s)
collapsed := loadCollapsed(s)
themeName, _ := s.GetPreference(ctx, "theme")
themeName, _ := s.GetPreference(context.Background(), "theme")
theme := themeByName(themeName)
themeIdx := 0
for i, t := range themes {
@@ -241,26 +230,25 @@ func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor
}
}
detailPref, _ := s.GetPreference(ctx, "detail_open")
detailPref, _ := s.GetPreference(context.Background(), "detail_open")
return Model{
ctx: ctx,
state: stateDashboard,
logViewport: vpLogs,
maxTableRows: 5,
isAdmin: isAdmin,
store: s,
engine: eng,
zones: z,
pulseSpring: spring,
collapsed: collapsed,
theme: theme,
themeIndex: themeIdx,
st: newStyles(theme),
bottomPanel: bottomLogs,
detailOpen: detailPref == "true",
demoMode: os.Getenv("UPTOP_DEMO") == "1",
version: version,
state: stateDashboard,
logViewport: vpLogs,
maxTableRows: 5,
isAdmin: isAdmin,
store: s,
engine: eng,
zones: z,
pulseSpring: spring,
collapsed: collapsed,
theme: theme,
themeIndex: themeIdx,
st: newStyles(theme),
detailOpen: detailPref == "true",
demoMode: os.Getenv("UPTOP_DEMO") == "1",
version: version,
sparkTooltipIdx: -1,
}
}
+450 -523
View File
File diff suppressed because it is too large Load Diff
+21 -159
View File
@@ -118,10 +118,11 @@ func TestDetailLoad_CachesAndViewDoesNoIO(t *testing.T) {
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "DOWN"}}}
m.cursor = 0
m.detailOpen = true
m.state = stateDetail
m.termWidth = 120
m.termHeight = 40
// Entering detail dispatches the load Cmd.
cmd := m.loadDetailCmd(1)
if cmd == nil {
t.Fatal("loadDetailCmd returned nil")
@@ -135,14 +136,16 @@ func TestDetailLoad_CachesAndViewDoesNoIO(t *testing.T) {
t.Fatalf("expected exactly 1 store hit from the load Cmd, got %d", ms.stateChangeCalls)
}
// Apply the msg through Update (caches into the model).
updated, _ := m.Update(dd)
m = updated.(Model)
if m.detailChangesSiteID != 1 || len(m.detailChanges) != 1 {
t.Fatalf("detail changes not cached: id=%d n=%d", m.detailChangesSiteID, len(m.detailChanges))
}
// Render the detail panel several times — it must read the cache, not the DB.
for i := 0; i < 3; i++ {
_ = m.viewDetailInline(80, 30)
_ = m.viewDetailPanel()
}
if ms.stateChangeCalls != 1 {
t.Errorf("View performed DB IO: store hit %d times (want 1, from the Cmd only)", ms.stateChangeCalls)
@@ -199,16 +202,16 @@ func TestHistoryKey_LoadsOffUIGoroutine(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 7, Name: "site"}}}
m.detailOpen = true
m.state = stateDetail
m.termWidth, m.termHeight = 120, 40
updated, cmd := (&m).handleDashboardKey(keyMsg("h"))
updated, cmd := (&m).handleDetailKey(keyMsg("h"))
if ms.stateChangeCalls != 0 {
t.Fatal("history keypress hit the store synchronously in Update")
}
got := updated.(*Model)
if got.detailMode != detailHistory || got.historySiteID != 7 {
t.Fatalf("history mode not set: mode=%v siteID=%d", got.detailMode, got.historySiteID)
if got.state != stateHistory || got.historySiteID != 7 {
t.Fatalf("history view not opened: state=%v siteID=%d", got.state, got.historySiteID)
}
if cmd == nil {
t.Fatal("expected a history load Cmd")
@@ -226,6 +229,7 @@ func TestHistoryKey_LoadsOffUIGoroutine(t *testing.T) {
t.Fatal("history reply not folded into the model")
}
// A reply for a previously opened site must not clobber the current one.
m2.historySiteID = 9
stale, _ := m2.Update(historyDataMsg{siteID: 7, changes: nil})
if m3 := stale.(Model); len(m3.historyChanges) != 1 {
@@ -237,15 +241,20 @@ func TestSLAData_DropsStaleReply(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.termWidth, m.termHeight = 120, 40
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 3}, SiteState: models.SiteState{Status: "UP"}}}
m.detailOpen = true
m.slaSiteID = 3
m.slaPeriodIdx = 2
if cmd := (&m).openSLAView(m.sites[0]); cmd == nil {
t.Fatal("openSLAView should return a load Cmd")
}
// Reply for a different period than currently selected → dropped.
// (slaDataMsg routes through a pointer-receiver handler, so Update
// returns *Model on this path.)
updated, _ := m.Update(slaDataMsg{siteID: 3, periodIdx: 0})
if mm := updated.(*Model); mm.slaDailyBreakdown != nil {
t.Error("stale SLA reply (old period) was applied")
}
// Matching reply → report computed.
updated, _ = updated.(*Model).Update(slaDataMsg{siteID: 3, periodIdx: m.slaPeriodIdx})
if mm := updated.(*Model); mm.slaDailyBreakdown == nil {
t.Error("matching SLA reply was not applied")
@@ -257,7 +266,7 @@ func TestConfirmDelete_WritesOffUIGoroutine(t *testing.T) {
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 4, Name: "s"}}}
m.state = stateConfirmDelete
m.deleteKind = "site"
m.deleteTab = 0
m.deleteID = 4
updated, cmd := (&m).handleConfirmDelete(keyMsg("y"))
@@ -300,81 +309,17 @@ func TestWriteDoneMsg_LogsErrorAndReloads(t *testing.T) {
}
}
func TestEnterNarrowTerminal_OpensFullscreen(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "UP"}}}
m.termWidth = 80 // narrow, below wideBreakpoint (120)
m.termHeight = 40
m.focusedPanel = panelMonitors
updated, cmd := (&m).handleDashboardKey(keyMsg("enter"))
got := updated.(*Model)
if got.state != stateDetailFullscreen {
t.Fatalf("expected stateDetailFullscreen, got %d", got.state)
}
if cmd == nil {
t.Fatal("expected a detail load Cmd")
}
}
func TestEnterWideTerminal_TogglesSidebar(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "UP"}}}
m.termWidth = 140 // wide, above wideBreakpoint (120)
m.termHeight = 40
m.focusedPanel = panelMonitors
updated, _ := (&m).handleDashboardKey(keyMsg("enter"))
got := updated.(*Model)
if got.state != stateDashboard {
t.Fatalf("expected stateDashboard, got %d", got.state)
}
if !got.detailOpen {
t.Fatal("expected detailOpen to be true")
}
}
func TestDetailFullscreen_EscReturnsToDashboard(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.state = stateDetailFullscreen
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}}}
updated, _ := (&m).handleDetailFullscreen(tea.KeyMsg{Type: tea.KeyEsc})
got := updated.(*Model)
if got.state != stateDashboard {
t.Fatalf("expected stateDashboard after Esc, got %d", got.state)
}
if got.focusedPanel != panelMonitors {
t.Fatalf("expected panelMonitors focus, got %d", got.focusedPanel)
}
}
func TestDetailRefreshCmd_FiresForFullscreen(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 5, Name: "site"}}}
m.state = stateDetailFullscreen
m.detailOpen = false
cmd := (&m).detailRefreshCmd()
if cmd == nil {
t.Fatal("detailRefreshCmd should fire for stateDetailFullscreen")
}
}
func TestDetailRefreshCmd_OnlyWhileDetailOpen(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 5, Name: "site"}}}
m.detailOpen = false
m.state = stateDashboard
if (&m).detailRefreshCmd() != nil {
t.Error("refresh Cmd issued outside the detail view")
}
m.detailOpen = true
m.state = stateDetail
cmd := (&m).detailRefreshCmd()
if cmd == nil {
t.Fatal("open detail panel should refresh on the tab-data cadence")
@@ -389,86 +334,3 @@ func TestDetailRefreshCmd_OnlyWhileDetailOpen(t *testing.T) {
t.Error("refresh Cmd issued for an out-of-range cursor")
}
}
func TestBuildMaintSet_GlobalWindow(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.sites = []models.Site{
{SiteConfig: models.SiteConfig{ID: 1, Name: "a"}},
{SiteConfig: models.SiteConfig{ID: 2, Name: "b"}},
}
m.maintenanceWindows = []models.MaintenanceWindow{
{ID: 1, MonitorID: 0, Type: "maintenance", StartTime: time.Now().Add(-time.Hour)},
}
m.buildMaintSet()
if !m.maintSet[1] || !m.maintSet[2] {
t.Error("global maint window should mark all monitors")
}
}
func TestBuildMaintSet_TargetedWindow(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.sites = []models.Site{
{SiteConfig: models.SiteConfig{ID: 1, Name: "a"}},
{SiteConfig: models.SiteConfig{ID: 2, Name: "b"}},
}
m.maintenanceWindows = []models.MaintenanceWindow{
{ID: 1, MonitorID: 1, Type: "maintenance", StartTime: time.Now().Add(-time.Hour)},
}
m.buildMaintSet()
if !m.maintSet[1] {
t.Error("targeted window should mark monitor 1")
}
if m.maintSet[2] {
t.Error("targeted window should NOT mark monitor 2")
}
}
func TestBuildMaintSet_GroupPropagates(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.sites = []models.Site{
{SiteConfig: models.SiteConfig{ID: 10, Name: "group", Type: "group"}},
{SiteConfig: models.SiteConfig{ID: 11, Name: "child1", ParentID: 10}},
{SiteConfig: models.SiteConfig{ID: 12, Name: "child2", ParentID: 10}},
{SiteConfig: models.SiteConfig{ID: 20, Name: "other"}},
}
m.maintenanceWindows = []models.MaintenanceWindow{
{ID: 1, MonitorID: 10, Type: "maintenance", StartTime: time.Now().Add(-time.Hour)},
}
m.buildMaintSet()
if !m.maintSet[10] || !m.maintSet[11] || !m.maintSet[12] {
t.Error("group maint window should mark group + children")
}
if m.maintSet[20] {
t.Error("unrelated monitor should NOT be marked")
}
}
func TestBuildMaintSet_ExpiredIgnored(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.sites = []models.Site{
{SiteConfig: models.SiteConfig{ID: 1, Name: "a"}},
}
m.maintenanceWindows = []models.MaintenanceWindow{
{ID: 1, MonitorID: 1, Type: "maintenance",
StartTime: time.Now().Add(-2 * time.Hour),
EndTime: time.Now().Add(-1 * time.Hour)},
}
m.buildMaintSet()
if m.maintSet[1] {
t.Error("expired maint window should not mark monitor")
}
}
func TestBuildMaintSet_IncidentIgnored(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.sites = []models.Site{
{SiteConfig: models.SiteConfig{ID: 1, Name: "a"}},
}
m.maintenanceWindows = []models.MaintenanceWindow{
{ID: 1, MonitorID: 0, Type: "incident", StartTime: time.Now().Add(-time.Hour)},
}
m.buildMaintSet()
if m.maintSet[1] {
t.Error("incident windows should not mark monitors as in maintenance")
}
}
+4 -4
View File
@@ -26,9 +26,9 @@ func (m Model) uptimeTimeline(days []monitor.DayReport, width int) string {
for _, d := range display {
ch := "█"
switch {
case d.UptimePct >= uptimeGoodPct:
case d.UptimePct >= 99.0:
sb.WriteString(m.st.specialStyle.Render(ch))
case d.UptimePct >= uptimeWarnPct:
case d.UptimePct >= 95.0:
sb.WriteString(m.st.warnStyle.Render(ch))
case d.UptimePct > 0:
sb.WriteString(m.st.dangerStyle.Render(ch))
@@ -39,9 +39,9 @@ func (m Model) uptimeTimeline(days []monitor.DayReport, width int) string {
pct := days[len(days)-1].UptimePct
pctStyle := m.st.specialStyle
if pct < uptimeGoodPct {
if pct < 99.0 {
pctStyle = m.st.dangerStyle
} else if pct < uptimeExcellentPct {
} else if pct < 99.9 {
pctStyle = m.st.warnStyle
}
sb.WriteString(" " + pctStyle.Render(fmt.Sprintf("%.2f%%", pct)))
+164 -139
View File
@@ -47,12 +47,12 @@ func (m Model) View() string {
switch m.state {
case stateConfirmDelete:
kind := "monitor"
switch m.deleteKind {
case "maint":
kind = "maintenance window"
case "alert":
switch m.deleteTab {
case 1:
kind = "alert"
case "user":
case 4:
kind = "maintenance window"
case 5:
kind = "user"
}
msg := m.st.dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName))
@@ -90,16 +90,14 @@ func (m Model) View() string {
return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer)
}
return ""
case stateLogs:
return m.viewLogsFullscreen()
case stateDetailFullscreen:
return m.viewDetailFullscreen()
case stateDetail:
return m.zones.Scan(m.viewDetailPanel())
case stateHistory:
return m.viewHistoryPanel()
case stateSLA:
return m.viewSLAPanel()
case stateAlertDetail:
return m.viewAlertDetailPanel()
case stateSettings:
return m.viewSettingsOverlay()
case stateMaintDetail:
return m.viewMaintDetailPanel()
default:
return m.zones.Scan(m.viewDashboard())
}
@@ -132,7 +130,7 @@ func (m Model) computeStats() dashboardStats {
}
}
for _, n := range m.nodes {
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) > nodeStaleThreshold {
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) > 5*time.Minute {
s.offlineNodes++
}
}
@@ -145,67 +143,61 @@ func (m Model) computeStats() dashboardStats {
return s
}
func (m Model) viewMonitorsLayout() string {
availW := m.termWidth - chromePadH
wide := m.termWidth >= wideBreakpoint
showDetail := m.detailOpen && wide
var detailW, monW int
if showDetail {
monW = availW * 60 / 100
detailW = availW - monW
} else {
monW = availW
}
m.contentWidth = monW - 2
monTargetH := m.maxTableRows + 5
monitors := m.viewSitesTab()
monPanel := m.zones.Mark("panel-monitors", m.titledPanelH("Monitors", monitors, "", monW, monTargetH, 0, m.focusedPanel == panelMonitors))
var topParts []string
topParts = append(topParts, monPanel)
if showDetail {
title := ""
if m.cursor < len(m.sites) {
title = m.sites[m.cursor].Name
}
switch m.detailMode {
case detailSLA:
title = "SLA · " + title
case detailHistory:
title = "History · " + title
}
monHeight := lipgloss.Height(monPanel)
detail := m.viewDetailInline(detailW-2, monHeight)
footer := m.detailFooter(detailW - 2)
detailPanel := m.zones.Mark("panel-detail", m.titledPanelH(title, detail, footer, detailW, monHeight, m.detailScrollOffset, m.focusedPanel == panelDetail))
topParts = append(topParts, detailPanel)
}
top := lipgloss.JoinHorizontal(lipgloss.Top, topParts...)
switch m.bottomPanel {
case bottomLogs:
logContent := m.viewLogsStrip(availW-2, logsStripHeight-2)
logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", logContent, availW, m.focusedPanel == panelLogs))
return top + "\n" + logPanel
case bottomMaint:
maintContent := m.viewMaintStrip(availW-2, logsStripHeight-2)
maintPanel := m.zones.Mark("panel-maint", m.titledPanel("Maint", maintContent, availW, m.focusedPanel == panelMaint))
return top + "\n" + maintPanel
}
return top
}
func (m Model) viewDashboard() string {
stats := m.computeStats()
header := m.renderStatusLine(stats)
header := m.renderTabBar(stats)
header = m.pulseIndicator() + " " + header
content := m.viewMonitorsLayout()
var content string
switch m.currentTab {
case tabMonitors:
showSidebar := m.termWidth >= wideBreakpoint
if showSidebar {
availW := m.termWidth - chromePadH
leftW := availW * 70 / 100
rightW := availW - leftW
m.contentWidth = leftW - 2
monitors := m.viewSitesTab()
monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, leftW, m.focusedPanel == panelMonitors))
sidebarContent := m.viewLogsSidebar(rightW-2, m.maxTableRows)
logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", sidebarContent, rightW, m.focusedPanel == panelLogs))
top := lipgloss.JoinHorizontal(lipgloss.Top, monPanel, logPanel)
if m.detailOpen {
site := ""
if m.cursor < len(m.sites) {
site = m.sites[m.cursor].Name
}
detail := m.viewDetailInline(availW - 2)
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail))
content = top + "\n" + detailPanel
} else {
content = top
}
} else {
m.contentWidth = m.termWidth - 2
monitors := m.viewSitesTab()
availW := m.termWidth - chromePadH
monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, availW, m.focusedPanel == panelMonitors))
if m.detailOpen {
site := ""
if m.cursor < len(m.sites) {
site = m.sites[m.cursor].Name
}
detail := m.viewDetailInline(availW - 2)
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail))
content = monPanel + "\n" + detailPanel
} else {
content = monPanel
}
}
case tabMaint:
m.contentWidth = m.termWidth
content = m.viewMaintTab()
case tabSettings:
m.contentWidth = m.termWidth
content = m.viewSettingsTab()
}
content = strings.TrimSpace(content)
footer := m.renderFooter(stats)
@@ -217,17 +209,74 @@ func (m Model) viewDashboard() string {
availHeight = 5
}
contentHeight := availHeight - lipgloss.Height(header) - lipgloss.Height(footer)
divW := m.termWidth - chromePadH
if divW < 40 {
divW = 40
}
tabDivider := m.st.subtleStyle.Render(strings.Repeat("─", divW))
contentHeight := availHeight - lipgloss.Height(header) - 1 - lipgloss.Height(footer)
if contentHeight < 1 {
contentHeight = 1
}
paddedContent := lipgloss.NewStyle().Height(contentHeight).MaxHeight(contentHeight).Render(content)
return outerPad.Render(lipgloss.JoinVertical(lipgloss.Top, header, paddedContent, footer))
return outerPad.Render(lipgloss.JoinVertical(lipgloss.Top, header, tabDivider, paddedContent, footer))
}
func (m Model) renderStatusLine(stats dashboardStats) string {
dot := m.st.subtleStyle.Render(" · ")
type tabEntry struct {
name string
count int
warn int
}
func (m Model) renderTabBar(stats dashboardStats) string {
settingsCount := len(m.alerts) + len(m.nodes)
settingsWarn := stats.offlineNodes
for _, a := range m.alerts {
h := m.engine.GetAlertHealth(a.ID)
if !h.LastSendOK && !h.LastSendAt.IsZero() {
settingsWarn++
}
}
if m.isAdmin {
settingsCount += len(m.users)
}
tabs := []tabEntry{
{"Monitors", stats.totalMonitors, stats.downCount + stats.lateCount},
{"Maint", len(m.maintenanceWindows), stats.activeMaint},
{"Settings", settingsCount, settingsWarn},
}
countStyle := lipgloss.NewStyle().Foreground(m.theme.Muted)
var renderedTabs []string
for i, t := range tabs {
label := t.name
if t.count > 0 {
badge := countStyle.Render(fmt.Sprintf(" %d", t.count))
if t.warn > 0 {
badge = m.st.dangerStyle.Render(fmt.Sprintf(" %d", t.warn))
}
label += badge
}
var rendered string
if i == m.currentTab {
rendered = m.st.activeTab.Render(label)
} else {
rendered = m.st.inactiveTab.Render(label)
}
renderedTabs = append(renderedTabs, m.zones.Mark(fmt.Sprintf("tab-%d", i), rendered))
}
return lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...)
}
func (m Model) renderFooter(stats dashboardStats) string {
if m.filterMode {
cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│")
return "\n" + m.st.titleStyle.Render("/") + " " + m.filterText + cursor + " " + m.st.subtleStyle.Render("[Enter]Apply [Esc]Clear")
}
upCount := stats.totalMonitors - stats.downCount - stats.lateCount
var upStr string
@@ -238,83 +287,59 @@ func (m Model) renderStatusLine(stats dashboardStats) string {
} else {
upStr = m.st.specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors))
}
parts := []string{m.pulseIndicator() + " " + upStr}
statusParts := []string{upStr}
if stats.lateCount > 0 {
parts = append(parts, m.st.warnStyle.Render(fmt.Sprintf("%d late", stats.lateCount)))
}
if stats.activeMaint > 0 {
parts = append(parts, m.st.maintStyle.Render(fmt.Sprintf("%d maint", stats.activeMaint)))
statusParts = append(statusParts, m.st.warnStyle.Render(fmt.Sprintf("%d LATE", stats.lateCount)))
}
if len(m.nodes) > 0 {
online := 0
for _, n := range m.nodes {
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) < nodeOnlineThreshold {
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) < 60*time.Second {
online++
}
}
label := "probes"
probeLabel := "probes"
if online == 1 {
label = "probe"
probeLabel = "probe"
}
parts = append(parts, fmt.Sprintf("%d %s", online, label))
statusParts = append(statusParts, fmt.Sprintf("%d %s", online, probeLabel))
}
statusLine := strings.Join(statusParts, m.st.subtleStyle.Render(" · "))
var keys string
switch m.currentTab {
case tabMonitors:
if m.focusedPanel == panelLogs {
keys = "[↑/↓]Scroll [l/Esc]Back [T]Theme [q]Quit"
} else if m.detailOpen {
keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [l]Logs [↑/↓]Select [T]Theme [q]Quit"
} else {
keys = "[/]Filter [i]Info [Enter]Detail [</>]Sort [r]Reverse [n]New [e]Edit [d]Del [l]Logs [T]Theme [Tab]Switch [q]Quit"
}
case tabMaint:
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
case tabSettings:
switch m.settingsSection {
case sectionAlerts:
keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [←/→]Section [T]Theme [Tab]Switch [q]Quit"
case sectionUsers:
keys = "[n]Add [d]Revoke [←/→]Section [T]Theme [Tab]Switch [q]Quit"
default:
keys = "[←/→]Section [T]Theme [Tab]Switch [q]Quit"
}
default:
keys = "[T]Theme [Tab]Switch [q]Quit"
}
left := strings.Join(parts, dot)
ver := m.st.subtleStyle.Render("v" + m.version)
padW := m.termWidth - chromePadH - lipgloss.Width(left) - lipgloss.Width(ver)
if padW < 2 {
padW = 2
line := statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
if m.filterText != "" && m.currentTab == tabMonitors {
line = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
}
return left + strings.Repeat(" ", padW) + ver
}
func (m Model) hotkey(key, desc string) string {
k := lipgloss.NewStyle().Foreground(m.theme.Accent).Render(key)
d := m.st.subtleStyle.Render(desc)
return k + " " + d
}
func (m Model) renderFooter(_ dashboardStats) string {
dot := m.st.subtleStyle.Render(" · ")
if m.filterMode {
cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│")
keys := m.hotkey("Enter", "Apply") + dot + m.hotkey("Esc", "Clear")
return "\n" + m.st.titleStyle.Render("/") + " " + m.filterText + cursor + " " + keys
}
var parts []string
if m.focusedPanel == panelMaint {
parts = []string{m.hotkey("n", "New"), m.hotkey("Enter", "Detail"), m.hotkey("x", "End"), m.hotkey("d", "Del"), m.hotkey("m/Esc", "Back")}
} else if m.focusedPanel == panelLogs {
parts = []string{m.hotkey("↑/↓", "Scroll"), m.hotkey("Enter", "Expand"), m.hotkey("l/Esc", "Back")}
} else if m.detailOpen && m.detailMode == detailSLA {
parts = []string{m.hotkey("1-4", "Period"), m.hotkey("Esc", "Back")}
} else if m.detailOpen && m.detailMode == detailHistory {
parts = []string{m.hotkey("Esc", "Back")}
} else if m.detailOpen {
parts = []string{m.hotkey("Enter", "Close"), m.hotkey("h", "History"), m.hotkey("s", "SLA"), m.hotkey("e", "Edit")}
} else {
parts = []string{m.hotkey("/", "Filter"), m.hotkey("Enter", "Detail")}
if m.cursor < len(m.sites) && m.sites[m.cursor].Type == "group" {
if m.collapsed[m.sites[m.cursor].ID] {
parts = append(parts, m.hotkey("Space", "Expand"))
} else {
parts = append(parts, m.hotkey("Space", "Collapse"))
}
}
parts = append(parts, m.hotkey("n", "New"), m.hotkey("e", "Edit"), m.hotkey("d", "Del"))
}
parts = append(parts, m.hotkey("m", "Maint"), m.hotkey("l", "Logs"), m.hotkey("S", "Settings"), m.hotkey("T", "Theme"), m.hotkey("q", "Quit"))
line := strings.Join(parts, dot)
if m.filterText != "" {
line = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s ", m.filterText)) + line
}
return "\n" + line
divW := m.termWidth - chromePadH
if divW < 40 {
divW = 40
}
return m.st.subtleStyle.Render(strings.Repeat("─", divW)) + "\n" + line
}
+334
View File
@@ -0,0 +1,334 @@
package tui
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss"
)
func (m Model) viewDetailPanel() string {
if m.cursor >= len(m.sites) {
return ""
}
site := m.sites[m.cursor]
hist, _ := m.engine.GetHistory(site.ID)
var b strings.Builder
totalW := m.termWidth - chromePadH
var breadcrumb string
if site.ParentID > 0 {
for _, s := range m.sites {
if s.ID == site.ParentID {
breadcrumb = m.st.subtleStyle.Render(" Monitors > "+s.Name+" > ") + m.st.titleStyle.Render(site.Name)
break
}
}
}
if breadcrumb == "" {
breadcrumb = m.st.subtleStyle.Render(" Monitors > ") + m.st.titleStyle.Render(site.Name)
}
b.WriteString(breadcrumb + "\n")
b.WriteString(m.divider() + "\n")
// Two-column layout for key info
colW := (totalW - 4) / 2
if colW < 30 {
colW = 30
}
row := func(label, value string) string {
return fmt.Sprintf(" %-16s %s", m.st.subtleStyle.Render(label), value)
}
divW := totalW - 4
if divW < 20 {
divW = 20
}
sectionDiv := m.st.subtleStyle.Render(strings.Repeat("─", divW))
sectionHead := func(title string) string {
return m.st.titleStyle.Render(" "+title) + " " + m.st.subtleStyle.Render(strings.Repeat("─", divW-len(title)-3))
}
// Left column: endpoint details
var left []string
left = append(left, m.st.titleStyle.Render(" ENDPOINT"))
left = append(left, row("Type", site.Type))
if site.URL != "" {
left = append(left, row("URL", limitStr(site.URL, colW-19)))
}
if site.Hostname != "" {
left = append(left, row("Host", site.Hostname))
}
if site.Port > 0 {
left = append(left, row("Port", strconv.Itoa(site.Port)))
}
left = append(left, row("Interval", fmt.Sprintf("%ds", site.Interval)))
if site.MaxRetries > 0 {
left = append(left, row("Retries", m.fmtRetries(site)))
}
if site.Regions != "" {
left = append(left, row("Regions", site.Regions))
}
if site.Description != "" {
left = append(left, row("Description", limitStr(site.Description, colW-19)))
}
// Right column: status + timing + HTTP
var right []string
right = append(right, m.st.titleStyle.Render(" STATUS"))
right = append(right, row("Status", m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))))
right = append(right, row("Latency", m.fmtLatency(site.Latency)))
right = append(right, row("Uptime", m.fmtUptime(hist.Statuses)))
if !site.StatusChangedAt.IsZero() {
dur := time.Since(site.StatusChangedAt)
right = append(right, row("State Since", fmtDuration(dur)+" ago"))
}
if !site.LastCheck.IsZero() {
right = append(right, row("Last Check", m.fmtTimeAgo(site.LastCheck)))
}
if !site.LastSuccessAt.IsZero() {
right = append(right, row("Last Success", m.fmtTimeAgo(site.LastSuccessAt)))
}
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
errW := colW - 19
if errW < 20 {
errW = 20
}
right = append(right, row("Error", m.st.dangerStyle.Render(limitStr(site.LastError, errW))))
}
if site.Type == "http" {
if site.StatusCode > 0 {
right = append(right, row("HTTP Code", strconv.Itoa(site.StatusCode)))
}
codes := site.AcceptedCodes
if codes == "" {
codes = "200-299"
}
right = append(right, row("Codes", codes))
right = append(right, row("SSL", m.fmtSSL(site)))
if site.Method != "" && site.Method != "GET" {
right = append(right, row("Method", site.Method))
}
}
// Pad shorter column
for len(left) < len(right) {
left = append(left, "")
}
for len(right) < len(left) {
right = append(right, "")
}
leftCol := lipgloss.NewStyle().Width(colW).Render(strings.Join(left, "\n"))
rightCol := lipgloss.NewStyle().Width(colW).Render(strings.Join(right, "\n"))
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol) + "\n")
b.WriteString("\n" + sectionDiv + "\n")
// Connection chain (full width, only on errors)
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp) && site.LastError != "" {
chain := connectionChain(site.LastError, site.Type, site.StatusCode, strings.HasPrefix(site.URL, "https"))
if len(chain) > 0 {
b.WriteString("\n")
for _, step := range chain {
var icon string
switch step.Status {
case stepPassed:
icon = m.st.specialStyle.Render("✓")
case stepFailed:
icon = m.st.dangerStyle.Render("✗")
case stepSkipped:
icon = m.st.subtleStyle.Render("·")
}
line := fmt.Sprintf(" %s %-16s", icon, step.Name)
if step.Detail != "" {
switch step.Status {
case stepFailed:
line += " " + m.st.dangerStyle.Render(step.Detail)
case stepSkipped:
line += " " + m.st.subtleStyle.Render(step.Detail)
}
}
b.WriteString(line + "\n")
}
}
}
// Maintenance
if m.isMonitorInMaintenance(site.ID) {
for _, mw := range m.maintenanceWindows {
if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) {
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Maintenance"), m.st.maintStyle.Render(mw.Title))
break
}
}
}
// Push token
if site.Type == "push" && site.Token != "" {
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Token"), site.Token)
}
// Probe results
probeResults := m.engine.GetProbeResults(site.ID)
if len(probeResults) > 0 {
nodeIDs := make([]string, 0, len(probeResults))
for id := range probeResults {
nodeIDs = append(nodeIDs, id)
}
sort.Strings(nodeIDs)
b.WriteString("\n" + sectionHead("PROBE RESULTS") + "\n")
for _, nodeID := range nodeIDs {
result := probeResults[nodeID]
status := m.st.specialStyle.Render("UP")
if !result.IsUp {
status = m.st.dangerStyle.Render("DN")
}
latency := time.Duration(result.LatencyNs).Milliseconds()
ago := time.Since(result.CheckedAt).Truncate(time.Second)
line := fmt.Sprintf(" %-14s %s %dms %s ago", nodeID, status, latency, ago)
if !result.IsUp && result.ErrorReason != "" {
line += " " + m.st.dangerStyle.Render(result.ErrorReason)
}
b.WriteString(line + "\n")
}
}
// Bottom two-column: graphs left, state changes right
graphW := (totalW - 4) * 70 / 100
changeW := totalW - 4 - graphW
if graphW < 30 {
graphW = 30
}
if changeW < 20 {
changeW = 20
}
bottomColW := graphW
// Left: latency + histogram
var graphLines []string
sectionLabel := func(title string) string {
return m.st.titleStyle.Render(" " + title)
}
graphLines = append(graphLines, sectionLabel("LATENCY"))
if site.Type == "push" {
sparkW := bottomColW - 4
if sparkW > detailSparkWidth {
sparkW = detailSparkWidth
}
graphLines = append(graphLines, " "+m.heartbeatSparkline(hist.Statuses, sparkW, nil))
if len(hist.Statuses) > 0 {
up := 0
for _, s := range hist.Statuses {
if s {
up++
}
}
graphLines = append(graphLines, fmt.Sprintf(" %s %d/%d checks up",
m.st.subtleStyle.Render("Heartbeats"), up, len(hist.Statuses)))
}
} else {
sparkW := bottomColW - 4
if sparkW > detailSparkWidth {
sparkW = detailSparkWidth
}
graphLines = append(graphLines, " "+m.latencySparkline(hist.Latencies, hist.Statuses, sparkW, nil))
var minL, maxL, total time.Duration
count := 0
for i, l := range hist.Latencies {
if i < len(hist.Statuses) && !hist.Statuses[i] {
continue
}
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)
graphLines = append(graphLines, fmt.Sprintf(" %s %dms %s %dms %s %dms",
m.st.subtleStyle.Render("Min"), minL.Milliseconds(),
m.st.subtleStyle.Render("Avg"), avg.Milliseconds(),
m.st.subtleStyle.Render("Max"), maxL.Milliseconds()))
}
}
if site.Type != "push" && len(hist.Latencies) > 5 {
graphLines = append(graphLines, "")
graphLines = append(graphLines, sectionLabel("DISTRIBUTION"))
graphLines = append(graphLines, m.latencyHistogram(hist.Latencies, hist.Statuses, bottomColW))
}
// Right: state changes
var changeLines []string
var stateChanges []models.StateChange
if m.detailChangesSiteID == site.ID {
stateChanges = m.detailChanges
}
changeLines = append(changeLines, sectionLabel("STATE CHANGES"))
if len(stateChanges) > 0 {
for i, sc := range stateChanges {
from := m.fmtStatusWord(string(sc.FromStatus))
to := m.fmtStatusWord(string(sc.ToStatus))
ago := fmtDuration(time.Since(sc.ChangedAt))
line := fmt.Sprintf(" %s → %s %s ago", from, to, ago)
if sc.ToStatus == "UP" {
dur := computeOutageDuration(stateChanges, i)
if dur > 0 {
line += " " + m.st.warnStyle.Render("outage "+fmtDuration(dur))
}
}
if sc.ErrorReason != "" {
line += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, changeW-30))
}
changeLines = append(changeLines, line)
}
} else {
changeLines = append(changeLines, m.st.subtleStyle.Render(" No state changes"))
}
// Pad and join
for len(graphLines) < len(changeLines) {
graphLines = append(graphLines, "")
}
for len(changeLines) < len(graphLines) {
changeLines = append(changeLines, "")
}
graphCol := lipgloss.NewStyle().Width(graphW).Render(strings.Join(graphLines, "\n"))
changeCol := lipgloss.NewStyle().Width(changeW).Render(strings.Join(changeLines, "\n"))
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, graphCol, changeCol) + "\n")
b.WriteString("\n")
b.WriteString(m.divider() + "\n")
b.WriteString(m.st.subtleStyle.Render(" [q/Esc] Back [e] Edit [h] History [s] SLA [click] Inspect"))
// Wrap in a viewport for scrolling
content := b.String()
contentH := m.termHeight - 4
if contentH < 10 {
contentH = 10
}
lines := strings.Split(content, "\n")
if len(lines) > contentH {
m.detailViewport.SetContent(content)
m.detailViewport.Width = totalW
m.detailViewport.Height = contentH
return lipgloss.NewStyle().Padding(1, 2).Render(m.detailViewport.View())
}
return lipgloss.NewStyle().Padding(1, 2).Render(content)
}
+43 -498
View File
@@ -2,265 +2,53 @@ package tui
import (
"fmt"
"sort"
"strings"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
"github.com/charmbracelet/lipgloss"
)
func (m Model) viewDetailInline(width, height int) string {
func (m Model) viewDetailInline(width int) string {
if m.cursor >= len(m.sites) {
return ""
}
switch m.detailMode {
case detailSLA:
return m.viewSLASidebar(width, height)
case detailHistory:
return m.viewHistorySidebar(width, height)
default:
site := m.sites[m.cursor]
hist, _ := m.engine.GetHistory(site.ID)
return m.buildDetailContent(site, hist, width, false)
}
}
func (m Model) viewDetailFullscreen() string {
if m.cursor >= len(m.sites) {
return ""
}
availW := m.termWidth - chromePadH
site := m.sites[m.cursor]
var title string
switch m.detailMode {
case detailSLA:
title = "SLA · " + site.Name
case detailHistory:
title = "History · " + site.Name
default:
title = site.Name
}
if site.ParentID > 0 {
for _, s := range m.sites {
if s.ID == site.ParentID {
title = s.Name + " > " + title
break
}
}
}
innerW := availW - 2
var content string
switch m.detailMode {
case detailSLA:
content = m.viewSLASidebar(innerW, 0)
case detailHistory:
content = m.viewHistorySidebar(innerW, 0)
default:
hist, _ := m.engine.GetHistory(site.ID)
content = m.buildDetailContent(site, hist, innerW, true)
}
footer := m.detailFooter(innerW)
panelH := m.termHeight - chromePadV
if panelH < 10 {
panelH = 10
}
return lipgloss.NewStyle().Padding(1, 2).Render(
m.titledPanelH(title, content, footer, availW, panelH, m.detailScrollOffset, true))
}
func (m Model) buildDetailContent(site models.Site, hist monitor.SiteHistory, width int, fullscreen bool) string {
dot := m.st.subtleStyle.Render(" · ")
label := m.st.subtleStyle
innerW := width - 4
if innerW < 20 {
innerW = 20
}
hist, _ := m.engine.GetHistory(site.ID)
var b strings.Builder
// Status + latency + last check + state since
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
statusParts := []string{status}
latency := m.fmtLatency(site.Latency)
uptime := m.fmtUptime(hist.Statuses)
line1Parts := []string{status}
if site.Latency > 0 {
statusParts = append(statusParts, m.fmtLatency(site.Latency))
line1Parts = append(line1Parts, latency)
}
line1Parts = append(line1Parts, fmt.Sprintf("Uptime %s", uptime))
if !site.LastCheck.IsZero() {
statusParts = append(statusParts, m.fmtTimeAgo(site.LastCheck))
line1Parts = append(line1Parts, fmt.Sprintf("Checked %s", m.fmtTimeAgo(site.LastCheck)))
}
if !site.StatusChangedAt.IsZero() {
dur := time.Since(site.StatusChangedAt)
statusParts = append(statusParts, label.Render("for")+" "+fmtDuration(dur))
}
b.WriteString(" " + strings.Join(statusParts, dot) + "\n")
b.WriteString(" " + strings.Join(line1Parts, m.st.subtleStyle.Render(" · ")) + "\n")
// Type-specific details
typeParts := m.detailTypeLine(site)
if len(typeParts) > 0 {
b.WriteString(" " + strings.Join(typeParts, dot) + "\n")
}
// Extended endpoint fields
m.writeEndpointFields(&b, site, label, innerW, fullscreen)
// Uptime + retries + last success
uptimeStr := m.fmtUptime(hist.Statuses)
if m.isMonitorInMaintenance(site.ID) {
uptimeStr = m.st.subtleStyle.Render("—")
}
uptimeParts := []string{label.Render("Uptime") + " " + uptimeStr}
if site.Type != "group" && site.MaxRetries > 0 {
uptimeParts = append(uptimeParts, label.Render("Retries")+" "+m.fmtRetries(site))
}
if site.Type != "push" && !site.LastSuccessAt.IsZero() {
uptimeParts = append(uptimeParts, label.Render("Last OK")+" "+m.fmtTimeAgo(site.LastSuccessAt))
}
b.WriteString(" " + strings.Join(uptimeParts, dot) + "\n")
// Maintenance window name
if m.isMonitorInMaintenance(site.ID) {
for _, mw := range m.maintenanceWindows {
if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) {
b.WriteString(" " + label.Render("Maint") + " " + m.st.maintStyle.Render(mw.Title) + "\n")
break
}
}
}
// Error line
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
errW := innerW
errW := width - 12
if errW < 20 {
errW = 20
}
b.WriteString(" " + label.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n")
errMsg := limitStr(site.LastError, errW)
b.WriteString(" " + m.st.subtleStyle.Render("Error") + " " + m.st.dangerStyle.Render(errMsg) + "\n")
}
// Connection chain
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp) && site.LastError != "" {
chain := connectionChain(site.LastError, site.Type, site.StatusCode, strings.HasPrefix(site.URL, "https"))
if len(chain) > 0 {
b.WriteString("\n")
for _, step := range chain {
var icon string
switch step.Status {
case stepPassed:
icon = m.st.specialStyle.Render("✓")
case stepFailed:
icon = m.st.dangerStyle.Render("✗")
case stepSkipped:
icon = m.st.subtleStyle.Render("·")
}
line := fmt.Sprintf(" %s %-16s", icon, step.Name)
if step.Detail != "" {
switch step.Status {
case stepFailed:
line += " " + m.st.dangerStyle.Render(step.Detail)
case stepSkipped:
line += " " + m.st.subtleStyle.Render(step.Detail)
}
}
b.WriteString(line + "\n")
}
}
}
b.WriteString("\n")
// Probe results
probeResults := m.engine.GetProbeResults(site.ID)
if len(probeResults) > 0 {
nodeIDs := make([]string, 0, len(probeResults))
for id := range probeResults {
nodeIDs = append(nodeIDs, id)
}
sort.Strings(nodeIDs)
for _, nodeID := range nodeIDs {
result := probeResults[nodeID]
probeStatus := m.st.specialStyle.Render("UP")
if !result.IsUp {
probeStatus = m.st.dangerStyle.Render("DN")
}
latency := time.Duration(result.LatencyNs).Milliseconds()
ago := time.Since(result.CheckedAt).Truncate(time.Second)
line := fmt.Sprintf(" %-14s %s %dms %s ago", nodeID, probeStatus, latency, ago)
if !result.IsUp && result.ErrorReason != "" {
line += " " + m.st.dangerStyle.Render(result.ErrorReason)
}
b.WriteString(line + "\n")
}
b.WriteString("\n")
}
// Latency chart
if len(hist.Latencies) > 0 {
chart := m.latencyChart(hist.Latencies, hist.Statuses, innerW, 3)
if chart != "" {
b.WriteString(chart + "\n")
}
}
// 30d uptime timeline
if len(m.detailDailyDays) > 0 && m.detailChangesSiteID == site.ID {
b.WriteString(" " + label.Render("30d") + " " + m.uptimeTimeline(m.detailDailyDays, innerW) + "\n")
}
// Sparkline + min/avg/max
if site.Type != "push" && len(hist.Latencies) > 0 {
b.WriteString(" " + m.latencySparkline(hist.Latencies, hist.Statuses, innerW, nil) + "\n")
var minL, maxL, total time.Duration
count := 0
for i, l := range hist.Latencies {
if i < len(hist.Statuses) && !hist.Statuses[i] {
continue
}
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, " %s %dms %s %dms %s %dms\n",
label.Render("Min"), minL.Milliseconds(),
label.Render("Avg"), avg.Milliseconds(),
label.Render("Max"), maxL.Milliseconds())
}
}
// Latency histogram
if site.Type != "push" && len(hist.Latencies) > 5 {
histContent := m.latencyHistogram(hist.Latencies, hist.Statuses, innerW)
if histContent != "" {
b.WriteString("\n")
b.WriteString(histContent)
}
}
b.WriteString("\n")
// State changes
var stateChanges []models.StateChange
if m.detailChangesSiteID == site.ID {
stateChanges = m.detailChanges
}
if len(stateChanges) > 0 {
limit := 5
var parts []string
limit := 3
if len(stateChanges) < limit {
limit = len(stateChanges)
}
@@ -269,290 +57,47 @@ func (m Model) buildDetailContent(site models.Site, hist monitor.SiteHistory, wi
arrow := m.st.subtleStyle.Render("→")
from := m.fmtStatusWord(sc.FromStatus)
to := m.fmtStatusWord(sc.ToStatus)
entry := from + " " + arrow + " " + to + " " + label.Render(ago+" ago")
entry := from + " " + arrow + " " + to + " " + m.st.subtleStyle.Render(ago+" ago")
if sc.ErrorReason != "" {
reasonW := innerW - 25
if reasonW < 15 {
reasonW = 15
}
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonW))
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, 30))
}
b.WriteString(" " + entry + "\n")
parts = append(parts, entry)
}
} else {
b.WriteString(" " + label.Render("No state changes") + "\n")
b.WriteString(" " + strings.Join(parts, m.st.subtleStyle.Render(" · ")) + "\n")
}
if len(hist.Latencies) > 0 {
chartW := width - 4
if chartW < 20 {
chartW = 20
}
chart := m.latencyChart(hist.Latencies, hist.Statuses, chartW, 3)
if chart != "" {
b.WriteString(chart + "\n")
}
}
if len(m.detailDailyDays) > 0 && m.detailChangesSiteID == site.ID {
timelineW := width - 4
if timelineW < 20 {
timelineW = 20
}
b.WriteString(" " + m.st.subtleStyle.Render("30d") + " " + m.uptimeTimeline(m.detailDailyDays, timelineW) + "\n")
}
keys := m.st.subtleStyle.Render("[h] History [s] SLA [e] Edit [esc] Close")
b.WriteString(" " + keys + "\n")
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
}
func (m Model) writeEndpointFields(b *strings.Builder, site models.Site, label lipgloss.Style, innerW int, fullscreen bool) {
dot := m.st.subtleStyle.Render(" · ")
var fields []string
if site.Interval > 0 {
fields = append(fields, label.Render("Every")+" "+fmt.Sprintf("%ds", site.Interval))
}
if site.Timeout > 0 {
fields = append(fields, label.Render("Timeout")+" "+fmt.Sprintf("%ds", site.Timeout))
}
if site.Type == "http" && site.Method != "" && site.Method != "GET" {
fields = append(fields, label.Render("Method")+" "+site.Method)
}
if site.Type == "http" {
codes := site.AcceptedCodes
if codes == "" {
codes = "200-299"
}
fields = append(fields, label.Render("Codes")+" "+codes)
}
if site.Regions != "" {
fields = append(fields, label.Render("Regions")+" "+site.Regions)
}
if len(fields) > 0 {
b.WriteString(" " + strings.Join(fields, dot) + "\n")
}
if site.Description != "" {
maxDescW := innerW
if !fullscreen && maxDescW > 60 {
maxDescW = 60
}
b.WriteString(" " + label.Render(limitStr(site.Description, maxDescW)) + "\n")
}
if site.Type == "push" && site.Token != "" {
b.WriteString(" " + label.Render("Token") + " " + site.Token + "\n")
}
}
func (m Model) detailTypeLine(site models.Site) []string {
label := m.st.subtleStyle
var parts []string
switch site.Type {
case "http":
if site.StatusCode > 0 {
codeStr := fmt.Sprintf("HTTP %d", site.StatusCode)
if site.StatusCode >= httpErrorThreshold {
parts = append(parts, m.st.dangerStyle.Render(codeStr))
} else {
parts = append(parts, m.st.specialStyle.Render(codeStr))
}
}
if site.CheckSSL && site.HasSSL {
days := int(time.Until(site.CertExpiry).Hours() / 24)
sslStr := fmt.Sprintf("SSL %dd", days)
switch {
case days <= 0:
parts = append(parts, m.st.dangerStyle.Render("SSL EXPIRED"))
case days <= site.ExpiryThreshold:
parts = append(parts, m.st.warnStyle.Render(sslStr))
default:
parts = append(parts, m.st.specialStyle.Render(sslStr))
}
}
if site.URL != "" {
parts = append(parts, label.Render(limitStr(site.URL, 40)))
}
case "push":
parts = append(parts, label.Render("Push"))
if site.Interval > 0 {
parts = append(parts, label.Render(fmt.Sprintf("every %s", fmtDuration(time.Duration(site.Interval)*time.Second))))
}
if !site.LastSuccessAt.IsZero() {
parts = append(parts, label.Render("last")+" "+m.fmtTimeAgo(site.LastSuccessAt))
}
case "ping":
parts = append(parts, label.Render("Ping"))
if site.Hostname != "" {
parts = append(parts, label.Render(site.Hostname))
}
case "port":
parts = append(parts, label.Render("Port"))
if site.Hostname != "" {
target := site.Hostname
if site.Port > 0 {
target = fmt.Sprintf("%s:%d", site.Hostname, site.Port)
}
parts = append(parts, label.Render(target))
}
case "dns":
parts = append(parts, label.Render("DNS"))
if site.DNSResolveType != "" {
parts = append(parts, label.Render(site.DNSResolveType))
}
if site.DNSServer != "" {
parts = append(parts, label.Render(site.DNSServer))
}
}
return parts
}
func (m Model) detailFooter(width int) string {
dot := m.st.subtleStyle.Render(" · ")
var parts []string
switch m.detailMode {
case detailSLA:
for i, p := range slaPeriods {
if i == m.slaPeriodIdx {
parts = append(parts, m.st.titleStyle.Render(p.key)+" "+m.st.titleStyle.Render(p.label))
} else {
parts = append(parts, m.hotkey(p.key, p.label))
}
}
parts = append(parts, m.hotkey("Esc", "Back"))
case detailHistory:
parts = append(parts, m.hotkey("Esc", "Back"))
default:
parts = append(parts, m.hotkey("e", "Edit"), m.hotkey("h", "History"), m.hotkey("s", "SLA"), m.hotkey("Esc", "Back"))
}
content := " " + strings.Join(parts, dot)
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(content)
}
func (m Model) fmtStatusWord(status string) string {
switch status {
case "DOWN", "SSL EXP":
return m.st.dangerStyle.Render(status)
case "DOWN":
return m.st.dangerStyle.Render("DOWN")
case "UP":
return m.st.specialStyle.Render("UP")
case "LATE":
return m.st.warnStyle.Render("LATE")
case "STALE":
return m.st.staleStyle.Render("STALE")
case "PENDING":
return m.st.subtleStyle.Render("PENDING")
case "PAUSED":
return m.st.warnStyle.Render("PAUSED")
default:
return m.st.subtleStyle.Render(status)
}
}
func (m Model) viewSLASidebar(width, _ int) string {
var b strings.Builder
label := m.st.subtleStyle
innerW := width - 4
if innerW < 20 {
innerW = 20
}
period := slaPeriods[m.slaPeriodIdx]
b.WriteString(" " + label.Render("Period: Last "+period.label) + "\n\n")
r := m.slaReport
barWidth := innerW - 25
if barWidth < 10 {
barWidth = 10
}
bar := m.uptimeBar(r.UptimePct, barWidth)
uptimeColor := m.st.specialStyle
if r.UptimePct < uptimeExcellentPct {
uptimeColor = m.st.warnStyle
}
if r.UptimePct < uptimeGoodPct {
uptimeColor = m.st.dangerStyle
}
fmt.Fprintf(&b, " %-14s %s %s\n", label.Render("Uptime"), uptimeColor.Render(fmtPct(r.UptimePct)+"%"), bar)
fmt.Fprintf(&b, " %-14s %s\n", label.Render("Downtime"), fmtDuration(r.Downtime))
fmt.Fprintf(&b, " %-14s %d\n", label.Render("Outages"), r.OutageCount)
if r.OutageCount > 0 {
fmt.Fprintf(&b, " %-14s %s\n", label.Render("Longest"), fmtDuration(r.LongestOut))
fmt.Fprintf(&b, " %-14s %s\n", label.Render("MTTR"), fmtDuration(r.MTTR))
fmt.Fprintf(&b, " %-14s %s\n", label.Render("MTBF"), fmtDuration(r.MTBF))
}
b.WriteString("\n")
if len(m.slaDailyBreakdown) > 0 {
b.WriteString(" " + m.st.titleStyle.Render("DAILY BREAKDOWN") + "\n")
dayBarW := innerW - 20
if dayBarW < 10 {
dayBarW = 10
}
for _, day := range m.slaDailyBreakdown {
dateStr := day.Date.Format("Jan 02")
dayBar := m.uptimeBar(day.UptimePct, dayBarW)
pctStr := fmtPct(day.UptimePct) + "%"
color := m.st.specialStyle
if day.UptimePct < uptimeExcellentPct {
color = m.st.warnStyle
}
if day.UptimePct < uptimeGoodPct {
color = m.st.dangerStyle
}
fmt.Fprintf(&b, " %-8s %s %s\n", label.Render(dateStr), dayBar, color.Render(pctStr))
}
}
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
}
func (m Model) viewHistorySidebar(width, _ int) string {
var b strings.Builder
label := m.st.subtleStyle
innerW := width - 4
if innerW < 20 {
innerW = 20
}
sparkline := m.stateChangeSparkline(m.historyChanges, innerW)
if sparkline != "" {
b.WriteString(" " + sparkline + "\n\n")
}
if len(m.historyChanges) == 0 {
b.WriteString(" " + label.Render("No state changes recorded") + "\n")
} else {
reasonW := innerW - 45
if reasonW < 10 {
reasonW = 10
}
for i, sc := range m.historyChanges {
ts := sc.ChangedAt.Format("01/02 15:04")
arrow := label.Render(sc.FromStatus) + " → "
switch sc.ToStatus {
case string(models.StatusUp):
arrow += m.st.specialStyle.Render(sc.ToStatus)
case string(models.StatusLate):
arrow += m.st.warnStyle.Render(sc.ToStatus)
case string(models.StatusStale):
arrow += m.st.staleStyle.Render(sc.ToStatus)
default:
arrow += m.st.dangerStyle.Render(sc.ToStatus)
}
durStr := ""
if dur := computeOutageDuration(m.historyChanges, i); dur > 0 {
durStr = m.st.warnStyle.Render(fmtDuration(dur))
}
reason := ""
if sc.ErrorReason != "" && sc.ToStatus != string(models.StatusUp) {
reason = m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonW))
}
fmt.Fprintf(&b, " %-12s %s %s %s\n", ts, arrow, durStr, reason)
}
}
b.WriteString("\n")
stats := computeHistoryStats(m.historyChanges)
statParts := []string{fmt.Sprintf("%d events", stats.totalEvents)}
if stats.outageCount > 0 {
statParts = append(statParts, fmt.Sprintf("%d outages", stats.outageCount))
avg := stats.totalDowntime / time.Duration(stats.outageCount)
statParts = append(statParts, "avg "+fmtDuration(avg))
}
b.WriteString(" " + label.Render(strings.Join(statParts, " │ ")) + "\n")
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
}
+87
View File
@@ -1,10 +1,12 @@
package tui
import (
"fmt"
"strings"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss"
)
type historyStats struct {
@@ -103,3 +105,88 @@ func (m Model) stateChangeSparkline(changes []models.StateChange, width int) str
}
return sb.String()
}
func (m Model) buildHistoryContent() string {
var b strings.Builder
reasonWidth := m.termWidth - chromePadH - 55
if reasonWidth < 10 {
reasonWidth = 10
}
if reasonWidth > 60 {
reasonWidth = 60
}
for i, sc := range m.historyChanges {
ts := sc.ChangedAt.Format("2006-01-02 15:04")
arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → "
switch sc.ToStatus {
case string(models.StatusUp):
arrow += m.st.specialStyle.Render(sc.ToStatus)
case string(models.StatusLate):
arrow += m.st.warnStyle.Render(sc.ToStatus)
case string(models.StatusStale):
arrow += m.st.staleStyle.Render(sc.ToStatus)
default:
arrow += m.st.dangerStyle.Render(sc.ToStatus)
}
durStr := ""
if dur := computeOutageDuration(m.historyChanges, i); dur > 0 {
durStr = m.st.warnStyle.Render("outage " + fmtDuration(dur))
}
reason := ""
if sc.ErrorReason != "" && sc.ToStatus != string(models.StatusUp) {
reason = m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonWidth))
}
fmt.Fprintf(&b, " %-18s %s %-12s %s\n", ts, arrow, durStr, reason)
}
return b.String()
}
func (m Model) viewHistoryPanel() string {
var b strings.Builder
header := " " + m.st.titleStyle.Render("STATE HISTORY: "+m.historySiteName)
header += " " + m.st.subtleStyle.Render("[q] Back")
b.WriteString(header + "\n")
divWidth := m.dividerWidth()
b.WriteString(m.divider() + "\n")
sparkline := m.stateChangeSparkline(m.historyChanges, divWidth)
if sparkline != "" {
b.WriteString(" " + sparkline + "\n")
b.WriteString(m.divider() + "\n")
}
fmt.Fprintf(&b, " %-18s %-17s %-12s %s\n",
m.st.subtleStyle.Render("TIME"),
m.st.subtleStyle.Render("TRANSITION"),
m.st.subtleStyle.Render("DURATION"),
m.st.subtleStyle.Render("REASON"))
if len(m.historyChanges) == 0 {
b.WriteString("\n " + m.st.subtleStyle.Render("No state changes recorded") + "\n")
} else {
b.WriteString(m.historyViewport.View())
}
b.WriteString("\n" + m.divider() + "\n")
stats := computeHistoryStats(m.historyChanges)
parts := []string{fmt.Sprintf("%d events", stats.totalEvents)}
if stats.outageCount > 0 {
parts = append(parts, fmt.Sprintf("%d outages", stats.outageCount))
avg := stats.totalDowntime / time.Duration(stats.outageCount)
parts = append(parts, "avg outage "+fmtDuration(avg))
}
b.WriteString(" " + m.st.subtleStyle.Render(strings.Join(parts, " │ ")) + "\n")
b.WriteString(" " + m.st.subtleStyle.Render("[j/k/↑/↓] Scroll [q/Esc] Back"))
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
}
-199
View File
@@ -1,199 +0,0 @@
package tui
import (
"fmt"
"strings"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
)
func (m Model) viewMaintDetailPanel() string {
mw := m.findMaintWindow(m.maintDetailID)
if mw == nil {
return lipgloss.NewStyle().Padding(1, 2).Render(m.st.subtleStyle.Render("Maintenance window not found"))
}
var b strings.Builder
header := " " + m.st.subtleStyle.Render("Maintenance >") + " " + m.st.titleStyle.Render(mw.Title)
b.WriteString(header + "\n")
b.WriteString(m.divider() + "\n")
row := func(label, value string) {
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render(label), value)
}
maintType := m.st.maintStyle.Render("maintenance")
if mw.Type == "incident" {
maintType = m.st.dangerStyle.Render("incident")
}
row("Type", maintType)
now := time.Now()
if mw.StartTime.After(now) {
row("Status", m.st.warnStyle.Render("SCHEDULED"))
} else if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
row("Status", m.st.subtleStyle.Render("ENDED"))
} else {
row("Status", m.st.specialStyle.Render("ACTIVE"))
}
if mw.MonitorID == 0 {
row("Monitors", "All")
} else {
name := fmt.Sprintf("#%d", mw.MonitorID)
allSites := m.engine.GetAllSites()
for _, s := range allSites {
if s.ID == mw.MonitorID {
name = s.Name
break
}
}
row("Monitors", name)
}
row("Started", mw.StartTime.Format("2006-01-02 15:04"))
if mw.EndTime.IsZero() {
row("Ends", m.st.subtleStyle.Render("indefinite"))
} else {
row("Ends", mw.EndTime.Format("2006-01-02 15:04"))
if mw.EndTime.After(now) && !mw.StartTime.After(now) {
remaining := time.Until(mw.EndTime)
row("Remaining", fmtDuration(remaining))
}
dur := mw.EndTime.Sub(mw.StartTime)
row("Duration", fmtDuration(dur))
}
if mw.Description != "" {
b.WriteString("\n")
row("Description", mw.Description)
}
b.WriteString("\n" + m.divider() + "\n")
var keys []string
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
if isActive {
keys = append(keys, "[x] End")
}
keys = append(keys, "[d] Delete", "[q/Esc] Back")
b.WriteString(" " + m.st.subtleStyle.Render(strings.Join(keys, " ")))
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
}
func (m Model) monitorNameByID(id int) string {
for _, s := range m.engine.GetAllSites() {
if s.ID == id {
return s.Name
}
}
return fmt.Sprintf("#%d", id)
}
func (m Model) activeMaintWindows() []models.MaintenanceWindow {
now := time.Now()
var out []models.MaintenanceWindow
for _, mw := range m.maintenanceWindows {
if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
continue
}
out = append(out, mw)
}
return out
}
func (m Model) viewMaintStrip(width, maxLines int) string {
windows := m.activeMaintWindows()
if len(windows) == 0 {
return m.st.subtleStyle.Render(" No active maintenance")
}
now := time.Now()
end := maxLines
if end > len(windows) {
end = len(windows)
}
selectedVisual := -1
if m.focusedPanel == panelMaint {
selectedVisual = m.maintCursor
}
var rows [][]string
for i := 0; i < end; i++ {
mw := windows[i]
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
var icon string
if isActive {
icon = m.st.specialStyle.Render("●")
} else {
icon = m.st.warnStyle.Render("○")
}
monName := "All Monitors"
if mw.MonitorID > 0 {
monName = m.monitorNameByID(mw.MonitorID)
}
var status string
if isActive {
if mw.EndTime.IsZero() {
status = "indefinite"
} else {
status = fmtDuration(time.Until(mw.EndTime)) + " left"
}
} else {
status = "starts " + mw.StartTime.Format("Jan 02 15:04")
}
rows = append(rows, []string{icon, mw.Title, monName, status})
}
colWidths := []int{3, 0, 0, 0}
remaining := width - colWidths[0] - 6
colWidths[1] = remaining * 40 / 100
colWidths[2] = remaining * 30 / 100
colWidths[3] = remaining - colWidths[1] - colWidths[2]
t := table.New().
Border(lipgloss.HiddenBorder()).
Width(width).
Rows(rows...).
StyleFunc(func(row, col int) lipgloss.Style {
isSelected := row == selectedVisual
base := m.st.tableCellStyle
if row%2 == 1 {
base = m.st.tableZebraStyle
}
if isSelected {
base = m.st.tableSelectedStyle
}
if col < len(colWidths) && colWidths[col] > 0 {
base = base.Width(colWidths[col]).MaxWidth(colWidths[col])
}
return base
})
return t.Render()
}
func (m *Model) scrollMaintCursor(delta int) {
windows := m.activeMaintWindows()
total := len(windows)
if total == 0 {
return
}
m.maintCursor += delta
if m.maintCursor < 0 {
m.maintCursor = 0
}
if m.maintCursor >= total {
m.maintCursor = total - 1
}
}
-87
View File
@@ -1,87 +0,0 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
func (m Model) viewSettingsOverlay() string {
overlayW := m.termWidth - 8
if overlayW > 90 {
overlayW = 90
}
if overlayW < 40 {
overlayW = 40
}
overlayH := m.termHeight - 6
if overlayH < 10 {
overlayH = 10
}
savedCursor, savedOffset := m.cursor, m.tableOffset
savedContentW := m.contentWidth
m.cursor = m.settingsCursor
m.tableOffset = m.settingsOffset
m.contentWidth = overlayW - 4
content := m.viewSettingsTab()
m.cursor = savedCursor
m.tableOffset = savedOffset
m.contentWidth = savedContentW
settingsListLen := m.settingsListLen()
footer := m.settingsFooterKeys()
if settingsListLen > 0 {
footer = fmt.Sprintf("%d items %s", settingsListLen, footer)
}
footerLine := m.st.subtleStyle.Render(footer)
inner := content + "\n" + footerLine
box := m.titledPanel("Settings", inner, overlayW, true)
boxLines := lipgloss.Height(box)
if boxLines > overlayH {
lines := splitLines(box, overlayH)
box = lines
}
return placeOverlay(box, m.termWidth, m.termHeight)
}
func (m Model) settingsFooterKeys() string {
switch m.settingsSection {
case sectionAlerts:
return "[n]New [e]Edit [i]Info [d]Del [t]Test [←/→]Section [Esc]Close"
case sectionUsers:
if m.isAdmin {
return "[n]Add [d]Revoke [←/→]Section [Esc]Close"
}
return "[←/→]Section [Esc]Close"
default:
return "[←/→]Section [Esc]Close"
}
}
func (m Model) settingsListLen() int {
switch m.settingsSection {
case sectionAlerts:
return len(m.alerts)
case sectionNodes:
return len(m.nodes)
case sectionUsers:
return len(m.users)
}
return 0
}
func splitLines(s string, maxLines int) string {
lines := strings.Split(s, "\n")
if len(lines) > maxLines {
lines = lines[:maxLines]
}
return strings.Join(lines, "\n")
}
+89 -1
View File
@@ -5,6 +5,8 @@ import (
"math"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
)
var slaPeriods = []struct {
@@ -19,6 +21,92 @@ var slaPeriods = []struct {
{"90d", "4", 90 * 24 * time.Hour, 90},
}
func (m Model) viewSLAPanel() string {
var b strings.Builder
header := " " + m.st.titleStyle.Render("SLA REPORT: "+m.slaSiteName)
header += " " + m.st.subtleStyle.Render("[q] Back")
b.WriteString(header + "\n")
b.WriteString(m.divider() + "\n")
period := slaPeriods[m.slaPeriodIdx]
b.WriteString(" " + m.st.subtleStyle.Render("Period: Last "+period.label) + "\n\n")
r := m.slaReport
barWidth := m.dividerWidth() - 30
if barWidth < 10 {
barWidth = 10
}
bar := m.uptimeBar(r.UptimePct, barWidth)
uptimeColor := m.st.specialStyle
if r.UptimePct < 99.9 {
uptimeColor = m.st.warnStyle
}
if r.UptimePct < 99.0 {
uptimeColor = m.st.dangerStyle
}
fmt.Fprintf(&b, " %-16s %s %s\n", m.st.subtleStyle.Render("Uptime"), uptimeColor.Render(fmt.Sprintf("%s%%", fmtPct(r.UptimePct))), bar)
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Downtime"), fmtDuration(r.Downtime))
fmt.Fprintf(&b, " %-16s %d\n", m.st.subtleStyle.Render("Outages"), r.OutageCount)
if r.OutageCount > 0 {
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Longest"), fmtDuration(r.LongestOut))
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("MTTR"), fmtDuration(r.MTTR))
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("MTBF"), fmtDuration(r.MTBF))
}
b.WriteString("\n" + m.divider() + "\n")
if len(m.slaDailyBreakdown) > 0 {
b.WriteString(m.slaViewport.View())
}
b.WriteString("\n" + m.divider() + "\n")
var keys []string
for i, p := range slaPeriods {
label := fmt.Sprintf("[%s] %s", p.key, p.label)
if i == m.slaPeriodIdx {
keys = append(keys, m.st.titleStyle.Render(label))
} else {
keys = append(keys, m.st.subtleStyle.Render(label))
}
}
b.WriteString(" " + strings.Join(keys, " "))
b.WriteString(" " + m.st.subtleStyle.Render("[j/k/↑/↓] Scroll [q/Esc] Back"))
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
}
func (m Model) buildSLADailyContent() string {
var b strings.Builder
barWidth := m.dividerWidth() - 30
if barWidth < 10 {
barWidth = 10
}
b.WriteString(" " + m.st.subtleStyle.Render("DAILY BREAKDOWN") + "\n")
for _, day := range m.slaDailyBreakdown {
dateStr := day.Date.Format("Jan 02")
bar := m.uptimeBar(day.UptimePct, barWidth)
pctStr := fmtPct(day.UptimePct) + "%"
color := m.st.specialStyle
if day.UptimePct < 99.9 {
color = m.st.warnStyle
}
if day.UptimePct < 99.0 {
color = m.st.dangerStyle
}
fmt.Fprintf(&b, " %-8s %s %s\n", m.st.subtleStyle.Render(dateStr), bar, color.Render(pctStr))
}
return b.String()
}
func (m Model) uptimeBar(pct float64, width int) string {
filled := int(math.Round(pct / 100 * float64(width)))
if filled > width {
@@ -40,7 +128,7 @@ func fmtPct(pct float64) string {
if pct == 100 {
return "100.00"
}
if pct >= uptimePrecisionPct {
if pct >= 99.99 {
return fmt.Sprintf("%.3f", pct)
}
return fmt.Sprintf("%.2f", pct)