Compare commits
35 Commits
9eaddbb6e8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
a32a443a4a
|
|||
|
b90033c7f0
|
|||
|
989dd1fb39
|
|||
|
faf7d36c64
|
|||
|
16f0c2eb66
|
|||
|
f0d97f5562
|
|||
|
0484153103
|
|||
|
e3d681311f
|
|||
|
f7303c946c
|
|||
|
6e936ecce3
|
|||
|
04cf12f52b
|
|||
|
33df597dda
|
|||
|
1d14f640f4
|
|||
|
631f07c242
|
|||
|
0badc2ddf5
|
|||
|
be14436701
|
|||
|
efa8894b18
|
|||
|
ef54b36e0d
|
|||
|
835844314e
|
|||
|
dc79e2baaa
|
|||
|
3e02833df4
|
|||
|
8581662237
|
|||
|
2779f9f532
|
|||
|
4321e094a3
|
|||
|
1856820c3e
|
|||
|
aeee2cd0ce
|
|||
|
7ce5af5bf6
|
|||
|
18e2d60210
|
|||
|
ab1194f74d
|
|||
|
25f4b20b87
|
|||
|
7171dcd6f8
|
|||
|
822eda4dfd
|
|||
|
a5fd3aec90
|
|||
|
8f16a09da1
|
|||
|
50f77da131
|
Binary file not shown.
|
Before Width: | Height: | Size: 312 KiB |
@@ -43,10 +43,10 @@ func TestKeyCache_AllowsKnownDeniesUnknown(t *testing.T) {
|
||||
_, unknown := testKey(t)
|
||||
kc := newKeyCache(&kcMockStore{users: []models.User{{PublicKey: authorized}}})
|
||||
|
||||
if !kc.IsAllowed(known) {
|
||||
if !kc.IsAllowed(context.Background(), known) {
|
||||
t.Error("known key denied")
|
||||
}
|
||||
if kc.IsAllowed(unknown) {
|
||||
if kc.IsAllowed(context.Background(), 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(known) {
|
||||
if !kc.IsAllowed(context.Background(), 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(known) {
|
||||
if !kc.IsAllowed(context.Background(), 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(known) {
|
||||
if !kc.IsAllowed(context.Background(), 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(known) {
|
||||
if kc.IsAllowed(context.Background(), 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(known) {
|
||||
if !kc.IsAllowed(context.Background(), 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(known) {
|
||||
if kc.IsAllowed(context.Background(), known) {
|
||||
t.Error("deleted user's key still allowed from stale cache")
|
||||
}
|
||||
}
|
||||
|
||||
+33
-30
@@ -179,7 +179,7 @@ func parseTrustedProxies(raw string) []*net.IPNet {
|
||||
return cidrs
|
||||
}
|
||||
|
||||
func openStore(dbType, dsn string) store.Store {
|
||||
func openStore(ctx context.Context, dbType, dsn string) store.Store {
|
||||
var ss *store.SQLStore
|
||||
var err error
|
||||
if dbType == "postgres" {
|
||||
@@ -201,7 +201,7 @@ func openStore(dbType, dsn string) store.Store {
|
||||
} else {
|
||||
slog.Warn("no UPTOP_ENCRYPTION_KEY set, alert credentials stored unencrypted")
|
||||
}
|
||||
if err := ss.Init(context.Background()); err != nil {
|
||||
if err := ss.Init(ctx); err != nil {
|
||||
slog.Error("database init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -223,7 +223,8 @@ func runApply(args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
s := openStore(*dbType, *dsn)
|
||||
ctx := context.Background()
|
||||
s := openStore(ctx, *dbType, *dsn)
|
||||
|
||||
f, err := config.LoadFile(*filePath)
|
||||
if err != nil {
|
||||
@@ -231,7 +232,7 @@ func runApply(args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
changes, err := config.Apply(context.Background(), s, f, config.ApplyOpts{
|
||||
changes, err := config.Apply(ctx, s, f, config.ApplyOpts{
|
||||
DryRun: *dryRun,
|
||||
Prune: *prune,
|
||||
})
|
||||
@@ -250,9 +251,10 @@ 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
|
||||
|
||||
s := openStore(*dbType, *dsn)
|
||||
ctx := context.Background()
|
||||
s := openStore(ctx, *dbType, *dsn)
|
||||
|
||||
f, err := config.Export(context.Background(), s)
|
||||
f, err := config.Export(ctx, s)
|
||||
if err != nil {
|
||||
slog.Error("export failed", "err", err)
|
||||
os.Exit(1)
|
||||
@@ -281,6 +283,8 @@ func runMigrateSecrets(args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
var ss *store.SQLStore
|
||||
if *dbType == "postgres" {
|
||||
ss, err = store.NewPostgresStore(*dsn)
|
||||
@@ -291,21 +295,21 @@ func runMigrateSecrets(args []string) {
|
||||
slog.Error("database connection failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := ss.Init(context.Background()); err != nil {
|
||||
if err := ss.Init(ctx); err != nil {
|
||||
slog.Error("database init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ss.SetEncryptor(enc)
|
||||
|
||||
alerts, err := ss.GetAllAlerts(context.Background())
|
||||
alerts, err := ss.GetAllAlerts(ctx)
|
||||
if err != nil {
|
||||
slog.Error("failed to load alerts", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
migrated := 0
|
||||
for _, a := range alerts {
|
||||
if err := ss.UpdateAlert(context.Background(), a.ID, a.Name, a.Type, a.Settings); err != nil {
|
||||
if err := ss.UpdateAlert(ctx, a.ID, a.Name, a.Type, a.Settings); err != nil {
|
||||
slog.Error("alert migration failed", "alert", a.Name, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -392,15 +396,19 @@ func runServe(args []string) {
|
||||
|
||||
kc := newKeyCache(ss)
|
||||
var s store.Store = &userInvalidatingStore{Store: ss, kc: kc}
|
||||
if err := s.Init(context.Background()); err != nil {
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := s.Init(ctx); err != nil {
|
||||
slog.Error("database init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if *demo {
|
||||
seedDemoData(s)
|
||||
seedDemoData(ctx, s)
|
||||
}
|
||||
|
||||
seedKeysFromEnv(s)
|
||||
seedKeysFromEnv(ctx, s)
|
||||
|
||||
if *importKuma != "" {
|
||||
kb, err := importer.LoadKumaFile(*importKuma)
|
||||
@@ -409,7 +417,7 @@ func runServe(args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
backup := importer.ConvertKuma(kb)
|
||||
if err := s.ImportData(context.Background(), backup); err != nil {
|
||||
if err := s.ImportData(ctx, backup); err != nil {
|
||||
slog.Error("import failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -429,12 +437,9 @@ func runServe(args []string) {
|
||||
}
|
||||
eng.SetMaintRetention(cfg.MaintRetention)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
eng.InitHistory()
|
||||
eng.InitLogs()
|
||||
eng.InitAlertHealth()
|
||||
eng.InitHistory(ctx)
|
||||
eng.InitLogs(ctx)
|
||||
eng.InitAlertHealth(ctx)
|
||||
eng.Start(ctx)
|
||||
|
||||
localTUI := isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd())
|
||||
@@ -450,7 +455,7 @@ func runServe(args []string) {
|
||||
sshSrv := startSSHServer(*port, s, eng, kc)
|
||||
|
||||
if localTUI {
|
||||
p := tea.NewProgram(tui.InitialModel(true, s, eng, version), tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
p := tea.NewProgram(tui.InitialModel(ctx, true, s, eng, version), tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
if _, err := p.Run(); err != nil {
|
||||
slog.Error("TUI failed", "err", err)
|
||||
}
|
||||
@@ -484,11 +489,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(key)
|
||||
return kc.IsAllowed(ctx, key)
|
||||
}),
|
||||
wish.WithMiddleware(
|
||||
bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||
return tui.InitialModel(false, db, eng, version), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()}
|
||||
return tui.InitialModel(s.Context(), false, db, eng, version), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()}
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -504,8 +509,7 @@ func startSSHServer(port int, db store.Store, eng *monitor.Engine, kc *keyCache)
|
||||
return s
|
||||
}
|
||||
|
||||
func seedDemoData(s store.Store) {
|
||||
ctx := context.Background()
|
||||
func seedDemoData(ctx context.Context, s store.Store) {
|
||||
existing, _ := s.GetSites(ctx)
|
||||
if len(existing) > 0 {
|
||||
return
|
||||
@@ -566,8 +570,8 @@ func newKeyCache(db store.Store) *keyCache {
|
||||
return &keyCache{db: db, ttl: 30 * time.Second}
|
||||
}
|
||||
|
||||
func (c *keyCache) refresh() {
|
||||
users, err := c.db.GetAllUsers(context.Background())
|
||||
func (c *keyCache) refresh(ctx context.Context) {
|
||||
users, err := c.db.GetAllUsers(ctx)
|
||||
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
|
||||
@@ -600,13 +604,13 @@ func (c *keyCache) Invalidate() {
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *keyCache) IsAllowed(incomingKey ssh.PublicKey) bool {
|
||||
func (c *keyCache) IsAllowed(ctx context.Context, incomingKey ssh.PublicKey) bool {
|
||||
c.mu.RLock()
|
||||
stale := time.Since(c.updated) > c.ttl
|
||||
c.mu.RUnlock()
|
||||
|
||||
if stale {
|
||||
c.refresh()
|
||||
c.refresh(ctx)
|
||||
}
|
||||
|
||||
c.mu.RLock()
|
||||
@@ -652,8 +656,7 @@ func (s *userInvalidatingStore) ImportData(ctx context.Context, data models.Back
|
||||
return err
|
||||
}
|
||||
|
||||
func seedKeysFromEnv(s store.Store) {
|
||||
ctx := context.Background()
|
||||
func seedKeysFromEnv(ctx context.Context, s store.Store) {
|
||||
var keys []string
|
||||
|
||||
if v := os.Getenv("UPTOP_ADMIN_KEY"); v != "" {
|
||||
|
||||
@@ -18,7 +18,9 @@ import (
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
var alertClient = &http.Client{Timeout: 10 * time.Second}
|
||||
const alertHTTPTimeout = 10 * time.Second
|
||||
|
||||
var alertClient = &http.Client{Timeout: alertHTTPTimeout}
|
||||
|
||||
// sanitizeError strips the request URL from transport errors before they are
|
||||
// stored or displayed. *url.Error embeds the full URL, which for several
|
||||
|
||||
@@ -38,14 +38,20 @@ 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: 2 * time.Second}
|
||||
client := http.Client{Timeout: followerTimeout}
|
||||
failures := 0
|
||||
threshold := 3
|
||||
threshold := leaderFailureThreshold
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
case <-time.After(followerRetryInterval):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
@@ -59,7 +65,7 @@ func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) {
|
||||
isLeaderHealthy := false
|
||||
|
||||
if err == nil {
|
||||
isLeaderHealthy = resp.StatusCode == 200
|
||||
isLeaderHealthy = resp.StatusCode == http.StatusOK
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
|
||||
@@ -26,12 +26,18 @@ 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 < 10 {
|
||||
cfg.Interval = 30
|
||||
if cfg.Interval < probeMinInterval {
|
||||
cfg.Interval = probeDefaultInterval
|
||||
}
|
||||
|
||||
apiClient := &http.Client{Timeout: 10 * time.Second}
|
||||
apiClient := &http.Client{Timeout: probeAPITimeout}
|
||||
dial := monitor.SafeDialContext(cfg.AllowPrivateTargets)
|
||||
strictClient := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
|
||||
@@ -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() {
|
||||
records, err := e.db.LoadAlertHealth(context.Background())
|
||||
func (e *Engine) InitAlertHealth(ctx context.Context) {
|
||||
records, err := e.db.LoadAlertHealth(ctx)
|
||||
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(context.Background(), alertID)
|
||||
cfg, err := e.db.GetAlert(e.ctx, 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(context.Background(), 30*time.Second)
|
||||
ctx, cancel := context.WithTimeout(e.ctx, alertSendTimeout)
|
||||
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(alertID int) error {
|
||||
cfg, err := e.db.GetAlert(context.Background(), alertID)
|
||||
func (e *Engine) TestAlert(ctx context.Context, alertID int) error {
|
||||
cfg, err := e.db.GetAlert(ctx, alertID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load alert: %w", err)
|
||||
}
|
||||
@@ -259,7 +259,7 @@ func (e *Engine) TestAlert(alertID int) error {
|
||||
if provider == nil {
|
||||
return fmt.Errorf("no provider for type %q", cfg.Type)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
ctx, cancel := context.WithTimeout(ctx, alertSendTimeout)
|
||||
defer cancel()
|
||||
err = provider.Send(ctx, "🧪 Test Alert", fmt.Sprintf("Test notification from uptop for channel '%s'.", cfg.Name))
|
||||
if err != nil {
|
||||
|
||||
@@ -239,10 +239,24 @@ 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) {
|
||||
|
||||
@@ -14,8 +14,8 @@ type SiteHistory struct {
|
||||
UpChecks int
|
||||
}
|
||||
|
||||
func (e *Engine) InitHistory() {
|
||||
all, err := e.db.LoadAllHistory(context.Background(), maxHistoryLen)
|
||||
func (e *Engine) InitHistory(ctx context.Context) {
|
||||
all, err := e.db.LoadAllHistory(ctx, maxHistoryLen)
|
||||
if err != nil {
|
||||
e.AddLog("Failed to load check history: " + err.Error())
|
||||
return
|
||||
|
||||
@@ -22,6 +22,7 @@ const (
|
||||
maintPruneInterval = 15 * time.Minute
|
||||
defaultMaintRetention = 7 * 24 * time.Hour
|
||||
dbWriteBuffer = 4096
|
||||
alertSendTimeout = 30 * time.Second
|
||||
dbPruneInterval = 10 * time.Minute
|
||||
)
|
||||
|
||||
@@ -63,6 +64,7 @@ type Engine struct {
|
||||
dbWrites chan dbWrite
|
||||
writerWG sync.WaitGroup
|
||||
checkerWG sync.WaitGroup
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
stopOnce sync.Once
|
||||
}
|
||||
@@ -89,6 +91,7 @@ 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{
|
||||
@@ -199,6 +202,8 @@ 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 {
|
||||
@@ -237,8 +242,8 @@ func (e *Engine) Stop() {
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Engine) InitLogs() {
|
||||
entries, err := e.db.LoadLogs(context.Background(), maxLogEntries)
|
||||
func (e *Engine) InitLogs(ctx context.Context) {
|
||||
entries, err := e.db.LoadLogs(ctx, maxLogEntries)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -263,6 +268,7 @@ 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)
|
||||
@@ -277,8 +283,6 @@ 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))
|
||||
@@ -321,6 +325,10 @@ 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 {
|
||||
|
||||
@@ -208,7 +208,7 @@ func TestInitHistory_LoadsFromDB(t *testing.T) {
|
||||
{SiteID: 1, LatencyNs: 3000000, IsUp: false},
|
||||
}
|
||||
e := newTestEngine(ms)
|
||||
e.InitHistory()
|
||||
e.InitHistory(context.Background())
|
||||
|
||||
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()
|
||||
e.InitLogs(context.Background())
|
||||
|
||||
logs := e.GetLogs()
|
||||
if len(logs) != 2 {
|
||||
@@ -445,6 +445,136 @@ 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 {
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const dialTimeout = 10 * time.Second
|
||||
|
||||
var privateRanges []*net.IPNet
|
||||
|
||||
func init() {
|
||||
@@ -60,7 +62,7 @@ func SafeDialContext(allowPrivate bool) func(ctx context.Context, network, addr
|
||||
}
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
dialer := &net.Dialer{Timeout: dialTimeout}
|
||||
for _, ip := range ips {
|
||||
target := net.JoinHostPort(ip.IP.String(), port)
|
||||
conn, err := dialer.DialContext(ctx, network, target)
|
||||
|
||||
@@ -141,16 +141,16 @@ func (e *Engine) GetDisplayStatus(site models.Site) string {
|
||||
return string(site.Status)
|
||||
}
|
||||
|
||||
func (e *Engine) GetStateChanges(siteID int, limit int) []models.StateChange {
|
||||
changes, err := e.db.GetStateChanges(context.Background(), siteID, limit)
|
||||
func (e *Engine) GetStateChanges(ctx context.Context, siteID int, limit int) []models.StateChange {
|
||||
changes, err := e.db.GetStateChanges(ctx, siteID, limit)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func (e *Engine) GetStateChangesSince(siteID int, since time.Time) []models.StateChange {
|
||||
changes, err := e.db.GetStateChangesSince(context.Background(), siteID, since)
|
||||
func (e *Engine) GetStateChangesSince(ctx context.Context, siteID int, since time.Time) []models.StateChange {
|
||||
changes, err := e.db.GetStateChangesSince(ctx, siteID, since)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ import (
|
||||
// guard.
|
||||
const maxVisitors = 10000
|
||||
|
||||
const (
|
||||
visitorCleanupInterval = 5 * time.Minute
|
||||
visitorIdleCutoff = 10 * time.Minute
|
||||
)
|
||||
|
||||
type visitor struct {
|
||||
tokens float64
|
||||
lastSeen time.Time
|
||||
@@ -90,13 +95,13 @@ func (rl *RateLimiter) evictOldest() {
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) cleanup() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
ticker := time.NewTicker(visitorCleanupInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
rl.mu.Lock()
|
||||
cutoff := time.Now().Add(-10 * time.Minute)
|
||||
cutoff := time.Now().Add(-visitorIdleCutoff)
|
||||
for ip, v := range rl.visitors {
|
||||
if v.lastSeen.Before(cutoff) {
|
||||
delete(rl.visitors, ip)
|
||||
|
||||
@@ -45,15 +45,27 @@ 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(60, cfg.TrustedProxies),
|
||||
probeRL: NewRateLimiter(30, cfg.TrustedProxies),
|
||||
backupRL: NewRateLimiter(10, cfg.TrustedProxies),
|
||||
statusRL: NewRateLimiter(120, cfg.TrustedProxies),
|
||||
pushRL: NewRateLimiter(pushRateLimit, cfg.TrustedProxies),
|
||||
probeRL: NewRateLimiter(probeRateLimit, cfg.TrustedProxies),
|
||||
backupRL: NewRateLimiter(backupRateLimit, cfg.TrustedProxies),
|
||||
statusRL: NewRateLimiter(statusRateLimit, cfg.TrustedProxies),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,10 +89,10 @@ func (s *Server) Start() *http.Server {
|
||||
httpSrv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
ReadHeaderTimeout: httpReadHeaderTimeout,
|
||||
ReadTimeout: httpReadTimeout,
|
||||
WriteTimeout: httpWriteTimeout,
|
||||
IdleTimeout: httpIdleTimeout,
|
||||
}
|
||||
go func() {
|
||||
if s.cfg.TLSCert != "" && s.cfg.TLSKey != "" {
|
||||
|
||||
@@ -17,6 +17,10 @@ const (
|
||||
maxLogRows = 200
|
||||
maxStateChangesPerSite = 5000
|
||||
maxMaintenanceExport = 1000
|
||||
maxOpenConns = 25
|
||||
maxIdleConns = 5
|
||||
connMaxLifetime = 5 * time.Minute
|
||||
tokenByteLen = 16
|
||||
)
|
||||
|
||||
type SQLStore struct {
|
||||
@@ -31,9 +35,9 @@ func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(25)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(5 * time.Minute)
|
||||
db.SetMaxOpenConns(maxOpenConns)
|
||||
db.SetMaxIdleConns(maxIdleConns)
|
||||
db.SetConnMaxLifetime(connMaxLifetime)
|
||||
_, isDollar := dialect.(*PostgresDialect)
|
||||
return &SQLStore{db: db, dialect: dialect, dollar: isDollar}, nil
|
||||
}
|
||||
@@ -61,7 +65,7 @@ func (s *SQLStore) q(query string) string {
|
||||
}
|
||||
|
||||
func generateToken() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
b := make([]byte, tokenByteLen)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("crypto/rand failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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
|
||||
)
|
||||
+14
-10
@@ -13,9 +13,9 @@ import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func loadCollapsed(s store.Store) map[int]bool {
|
||||
func loadCollapsed(ctx context.Context, s store.Store) map[int]bool {
|
||||
m := make(map[int]bool)
|
||||
raw, err := s.GetPreference(context.Background(), "collapsed_groups")
|
||||
raw, err := s.GetPreference(ctx, "collapsed_groups")
|
||||
if err != nil || raw == "" {
|
||||
return m
|
||||
}
|
||||
@@ -123,9 +123,10 @@ func (m *Model) refreshLive() {
|
||||
ordered = filterSites(ordered, m.filterText)
|
||||
}
|
||||
m.sites = ordered
|
||||
m.buildMaintSet()
|
||||
m.refreshLogContent()
|
||||
|
||||
if m.currentTab == tabMonitors && m.selectedID != 0 {
|
||||
if m.selectedID != 0 {
|
||||
for i, s := range m.sites {
|
||||
if s.ID == m.selectedID {
|
||||
m.cursor = i
|
||||
@@ -137,7 +138,7 @@ func (m *Model) refreshLive() {
|
||||
}
|
||||
|
||||
func (m *Model) syncSelectedID() {
|
||||
if m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||
if m.cursor < len(m.sites) {
|
||||
m.selectedID = m.sites[m.cursor].ID
|
||||
}
|
||||
}
|
||||
@@ -163,9 +164,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}
|
||||
@@ -192,6 +193,7 @@ 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 {
|
||||
@@ -200,10 +202,10 @@ func (m *Model) loadDetailCmd(siteID int) tea.Cmd {
|
||||
}
|
||||
}
|
||||
return func() tea.Msg {
|
||||
changes := eng.GetStateChanges(siteID, 5)
|
||||
changes := eng.GetStateChanges(ctx, siteID, 5)
|
||||
now := time.Now()
|
||||
allChanges := eng.GetStateChangesSince(siteID, now.Add(-30*24*time.Hour))
|
||||
daily := monitor.ComputeDailyBreakdown(allChanges, currentStatus, 30, now)
|
||||
allChanges := eng.GetStateChangesSince(ctx, siteID, now.Add(-stateHistoryLookback))
|
||||
daily := monitor.ComputeDailyBreakdown(allChanges, currentStatus, stateHistoryDays, now)
|
||||
return detailDataMsg{siteID: siteID, changes: changes, dailyDays: daily}
|
||||
}
|
||||
}
|
||||
@@ -212,8 +214,9 @@ 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(siteID, 100)}
|
||||
return historyDataMsg{siteID: siteID, changes: eng.GetStateChanges(ctx, siteID, stateHistoryLimit)}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,12 +225,13 @@ 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(siteID, since),
|
||||
changes: eng.GetStateChangesSince(ctx, siteID, since),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 >= 400 {
|
||||
if statusCode >= httpErrorThreshold {
|
||||
return ErrCatHTTP
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ func extractDetail(errorReason string, cat ErrorCategory, statusCode int) string
|
||||
}
|
||||
}
|
||||
if detail == "" {
|
||||
detail = limitStr(errorReason, 30)
|
||||
detail = limitStr(errorReason, errorDetailMaxLen)
|
||||
}
|
||||
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, 30)
|
||||
detail = limitStr(errorReason, errorDetailMaxLen)
|
||||
}
|
||||
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:], 30)
|
||||
detail = limitStr(errorReason[idx:], errorDetailMaxLen)
|
||||
}
|
||||
if detail == "" {
|
||||
detail = limitStr(errorReason, 30)
|
||||
detail = limitStr(errorReason, errorDetailMaxLen)
|
||||
}
|
||||
case ErrCatHTTP:
|
||||
if statusCode > 0 {
|
||||
detail = fmt.Sprintf("HTTP %d", statusCode)
|
||||
} else {
|
||||
detail = limitStr(errorReason, 30)
|
||||
detail = limitStr(errorReason, errorDetailMaxLen)
|
||||
}
|
||||
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, 30)
|
||||
detail = limitStr(errorReason, errorDetailMaxLen)
|
||||
}
|
||||
case ErrCatPrivate:
|
||||
detail = "private IP blocked"
|
||||
default:
|
||||
detail = limitStr(errorReason, 30)
|
||||
detail = limitStr(errorReason, errorDetailMaxLen)
|
||||
}
|
||||
|
||||
return detail
|
||||
|
||||
+32
-4
@@ -49,13 +49,13 @@ func siteOrder(s models.Site) int {
|
||||
return 3
|
||||
}
|
||||
switch s.Status {
|
||||
case "DOWN", "SSL EXP":
|
||||
case models.StatusDown, models.StatusSSLExp:
|
||||
return 0
|
||||
case "STALE":
|
||||
case models.StatusStale:
|
||||
return 1
|
||||
case "LATE":
|
||||
case models.StatusLate:
|
||||
return 1
|
||||
case "PENDING":
|
||||
case models.StatusPending:
|
||||
return 3
|
||||
default:
|
||||
return 2
|
||||
@@ -104,6 +104,13 @@ 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("—")
|
||||
@@ -155,6 +162,27 @@ 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")
|
||||
|
||||
@@ -6,6 +6,85 @@ 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
|
||||
@@ -50,3 +129,7 @@ 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)
|
||||
}
|
||||
|
||||
@@ -156,9 +156,8 @@ 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 allSites {
|
||||
for _, s := range m.sites {
|
||||
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
|
||||
hist, _ := m.engine.GetHistory(s.ID)
|
||||
if len(hist.Statuses) > 0 {
|
||||
@@ -209,9 +208,8 @@ 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 allSites {
|
||||
for _, s := range m.sites {
|
||||
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
|
||||
hist, _ := m.engine.GetHistory(s.ID)
|
||||
if len(hist.Statuses) > 0 {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
neturl "net/url"
|
||||
"sort"
|
||||
@@ -224,14 +223,83 @@ func (m Model) viewAlertsTab() string {
|
||||
summary := fmt.Sprintf("%d channels · %d types · %d sent · %d %s",
|
||||
len(m.alerts), len(types), totalSent, totalFail, failLabel)
|
||||
|
||||
return tbl + "\n " + m.st.subtleStyle.Render(summary)
|
||||
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("—")
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) viewAlertDetailPanel() string {
|
||||
if m.cursor >= len(m.alerts) {
|
||||
idx := m.cursor
|
||||
if m.returnState == stateSettings {
|
||||
idx = m.settingsCursor
|
||||
}
|
||||
if idx >= len(m.alerts) {
|
||||
return ""
|
||||
}
|
||||
a := m.alerts[m.cursor]
|
||||
a := m.alerts[idx]
|
||||
h := m.engine.GetAlertHealth(a.ID)
|
||||
|
||||
var b strings.Builder
|
||||
@@ -264,7 +332,7 @@ func (m Model) viewAlertDetailPanel() string {
|
||||
}
|
||||
|
||||
b.WriteString(m.divider() + "\n")
|
||||
b.WriteString(m.st.subtleStyle.Render(" CONFIGURATION") + "\n")
|
||||
b.WriteString(m.st.titleStyle.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.
|
||||
@@ -284,7 +352,7 @@ func (m Model) viewAlertDetailPanel() string {
|
||||
}
|
||||
|
||||
b.WriteString(m.divider() + "\n")
|
||||
b.WriteString(m.st.subtleStyle.Render(" [q/Esc] Back [e] Edit [t] Test"))
|
||||
b.WriteString(m.st.subtleStyle.Render(" [e] Edit [t] Test [q/Esc] Back"))
|
||||
|
||||
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
|
||||
}
|
||||
@@ -537,15 +605,21 @@ func (m *Model) submitAlertForm() tea.Cmd {
|
||||
}
|
||||
|
||||
st := m.store
|
||||
ctx := m.ctx
|
||||
id := m.editID
|
||||
name, aType := d.Name, d.AlertType
|
||||
m.state = stateDashboard
|
||||
if m.returnState == stateSettings {
|
||||
m.state = stateSettings
|
||||
} else {
|
||||
m.state = stateDashboard
|
||||
}
|
||||
m.returnState = 0
|
||||
if id > 0 {
|
||||
return writeCmd("Update alert", func() error {
|
||||
return st.UpdateAlert(context.Background(), id, name, aType, settings)
|
||||
return st.UpdateAlert(ctx, id, name, aType, settings)
|
||||
})
|
||||
}
|
||||
return writeCmd("Add alert", func() error {
|
||||
return st.AddAlert(context.Background(), name, aType, settings)
|
||||
return st.AddAlert(ctx, name, aType, settings)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type logSeverity int
|
||||
@@ -72,6 +73,23 @@ 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
|
||||
|
||||
@@ -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) viewLogsSidebar(width, maxLines int) string {
|
||||
func (m Model) viewLogsStrip(width, maxLines int) string {
|
||||
logs := m.engine.GetLogs()
|
||||
if len(logs) == 0 {
|
||||
return m.st.subtleStyle.Render(" No logs yet")
|
||||
}
|
||||
|
||||
sidebarStyle := lipgloss.NewStyle().Width(width).MaxWidth(width)
|
||||
style := lipgloss.NewStyle().Width(width).MaxWidth(width)
|
||||
|
||||
var all []string
|
||||
for _, entry := range logs {
|
||||
@@ -69,7 +69,7 @@ func (m Model) viewLogsSidebar(width, maxLines int) string {
|
||||
}
|
||||
visible := all[start:end]
|
||||
|
||||
return sidebarStyle.Render(strings.Join(visible, "\n"))
|
||||
return style.Render(strings.Join(visible, "\n"))
|
||||
}
|
||||
|
||||
func (m *Model) scrollLogs(delta int) {
|
||||
|
||||
+16
-110
@@ -1,7 +1,6 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -21,130 +20,37 @@ type maintFormData struct {
|
||||
CustomHours string
|
||||
}
|
||||
|
||||
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) 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 {
|
||||
return m.maintSet[monitorID]
|
||||
}
|
||||
|
||||
func (m *Model) buildMaintSet() {
|
||||
set := make(map[int]bool)
|
||||
now := time.Now()
|
||||
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 || mw.MonitorID == monitorID {
|
||||
return true
|
||||
if mw.MonitorID == 0 {
|
||||
for _, s := range m.sites {
|
||||
set[s.ID] = true
|
||||
}
|
||||
break
|
||||
}
|
||||
set[mw.MonitorID] = true
|
||||
for _, s := range m.sites {
|
||||
if s.ID == monitorID && s.ParentID > 0 && mw.MonitorID == s.ParentID {
|
||||
return true
|
||||
if s.ParentID == mw.MonitorID {
|
||||
set[s.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
m.maintSet = set
|
||||
}
|
||||
|
||||
func (m *Model) initMaintHuhForm() tea.Cmd {
|
||||
@@ -254,9 +160,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
@@ -8,19 +8,19 @@ import (
|
||||
|
||||
func (m Model) viewNodesTab() string {
|
||||
if len(m.nodes) == 0 {
|
||||
return m.emptyState("No probe nodes connected.", "")
|
||||
return m.emptyState("No probe nodes connected.", "Probes auto-register on connection.")
|
||||
}
|
||||
|
||||
var headers []string
|
||||
var widths []int
|
||||
if m.isWide() {
|
||||
headers = []string{"NAME", "REGION", "LAST SEEN", "VERSION", "STATUS"}
|
||||
widths = []int{24, 14, 16, 12, 10}
|
||||
headers = []string{"#", "NAME", "REGION", "LAST SEEN", "VERSION", "STATUS"}
|
||||
widths = []int{4, 24, 14, 16, 12, 10}
|
||||
} else {
|
||||
headers = []string{"NAME", "REGION", "SEEN", "VER", "STATUS"}
|
||||
widths = []int{16, 10, 10, 8, 8}
|
||||
headers = []string{"#", "NAME", "REGION", "SEEN", "VER", "STATUS"}
|
||||
widths = []int{4, 16, 10, 10, 8, 8}
|
||||
}
|
||||
nameW := widths[0]
|
||||
nameW := widths[1]
|
||||
|
||||
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{name, region, lastSeen, version, status})
|
||||
rows = append(rows, []string{fmt.Sprintf("%d", i+1), 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) < 60*time.Second {
|
||||
if time.Since(n.LastSeen) < nodeOnlineThreshold {
|
||||
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 < 60*time.Second {
|
||||
if ago < nodeOnlineThreshold {
|
||||
return m.st.specialStyle.Render("ONLINE")
|
||||
}
|
||||
if ago < 5*time.Minute {
|
||||
if ago < nodeStaleThreshold {
|
||||
return m.st.warnStyle.Render("STALE")
|
||||
}
|
||||
return m.st.dangerStyle.Render("OFFLINE")
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
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 {
|
||||
tabs = append(tabs, m.st.activeTab.Render(name))
|
||||
rendered = m.st.activeTab.Render(name)
|
||||
} else {
|
||||
tabs = append(tabs, m.st.inactiveTab.Render(name))
|
||||
rendered = m.st.inactiveTab.Render(name)
|
||||
}
|
||||
tabs = append(tabs, m.zones.Mark(fmt.Sprintf("section-%d", i), rendered))
|
||||
}
|
||||
header := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
|
||||
|
||||
|
||||
+68
-24
@@ -1,7 +1,6 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -35,10 +34,9 @@ type siteFormData struct {
|
||||
type colKey int
|
||||
|
||||
const (
|
||||
colNum colKey = iota
|
||||
colDot colKey = iota
|
||||
colName
|
||||
colType
|
||||
colStatus
|
||||
colLatency
|
||||
colUptime
|
||||
colHistory
|
||||
@@ -56,17 +54,18 @@ type columnDef struct {
|
||||
}
|
||||
|
||||
var siteColumns = []columnDef{
|
||||
{colNum, "#", "#", 4, 4, 0},
|
||||
{colDot, "", "", 3, 3, 0},
|
||||
{colName, "NAME", "NAME", 0, 0, 0},
|
||||
{colType, "TYPE", "TYPE", 10, 8, mediumBreakpoint},
|
||||
{colStatus, "STATUS", "STATUS", 10, 10, 0},
|
||||
{colType, "TYPE", "TYPE", 10, 8, 0},
|
||||
{colLatency, "LATENCY", "LAT", 10, 7, 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},
|
||||
{colUptime, "UPTIME", "UP%", 8, 8, 0},
|
||||
{colHistory, "HISTORY", "HISTORY", 0, 0, 0},
|
||||
{colSSL, "SSL", "SSL", 7, 5, 0},
|
||||
{colRetries, "RETRIES", "RT", 9, 5, 0},
|
||||
}
|
||||
|
||||
var columnDropOrder = []colKey{colHistory, colRetries, colSSL, colUptime, colType}
|
||||
|
||||
type tableLayout struct {
|
||||
nameW, sparkW int
|
||||
headers []string
|
||||
@@ -77,17 +76,51 @@ type tableLayout struct {
|
||||
func (m Model) computeLayout() tableLayout {
|
||||
wide := m.isWide()
|
||||
|
||||
var active []colKey
|
||||
var headers []string
|
||||
var widths []int
|
||||
var fixed int
|
||||
|
||||
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
|
||||
for _, c := range siteColumns {
|
||||
if c.minTerm > 0 && cw < c.minTerm {
|
||||
if dropped[c.key] {
|
||||
continue
|
||||
}
|
||||
active = append(active, c.key)
|
||||
@@ -107,10 +140,15 @@ func (m Model) computeLayout() tableLayout {
|
||||
}
|
||||
|
||||
sortColMap := map[int]colKey{
|
||||
sortStatus: colStatus,
|
||||
sortStatus: colDot,
|
||||
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 {
|
||||
@@ -123,6 +161,11 @@ 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)
|
||||
@@ -236,11 +279,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{
|
||||
colNum: strconv.Itoa(i + 1),
|
||||
colDot: m.fmtStatusDot(site.Status, site.Paused, inMaint),
|
||||
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),
|
||||
@@ -284,13 +327,13 @@ func (m Model) viewSitesTab() string {
|
||||
spark = m.latencySparkline(hist.Latencies, hist.Statuses, sparkWidth, rowBg)
|
||||
}
|
||||
|
||||
inMaint := m.isMonitorInMaintenance(site.ID)
|
||||
cells := map[colKey]string{
|
||||
colNum: strconv.Itoa(i + 1),
|
||||
colDot: m.fmtStatusDot(site.Status, site.Paused, inMaint),
|
||||
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.fmtUptime(hist.Statuses),
|
||||
colUptime: m.fmtUptimeMaint(hist.Statuses, site.ID),
|
||||
colHistory: spark,
|
||||
colSSL: m.fmtSSL(site),
|
||||
colRetries: m.fmtRetries(site),
|
||||
@@ -604,10 +647,11 @@ 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(context.Background(), cfg) })
|
||||
return writeCmd("Update site", func() error { return st.UpdateSite(ctx, cfg) })
|
||||
}
|
||||
return writeCmd("Add site", func() error { return st.AddSite(context.Background(), cfg) })
|
||||
return writeCmd("Add site", func() error { return st.AddSite(ctx, cfg) })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -114,15 +113,21 @@ 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
|
||||
m.state = stateDashboard
|
||||
if m.returnState == stateSettings {
|
||||
m.state = stateSettings
|
||||
} else {
|
||||
m.state = stateDashboard
|
||||
}
|
||||
m.returnState = 0
|
||||
if id > 0 {
|
||||
return writeCmd("Update user", func() error {
|
||||
return st.UpdateUser(context.Background(), id, username, key, role)
|
||||
return st.UpdateUser(ctx, id, username, key, role)
|
||||
})
|
||||
}
|
||||
return writeCmd("Add user", func() error {
|
||||
return st.AddUser(context.Background(), username, key, role)
|
||||
return st.AddUser(ctx, username, key, role)
|
||||
})
|
||||
}
|
||||
|
||||
+64
-52
@@ -80,14 +80,6 @@ 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 (
|
||||
@@ -100,6 +92,21 @@ 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 (
|
||||
@@ -112,24 +119,25 @@ const (
|
||||
type sessionState int
|
||||
|
||||
const (
|
||||
stateDashboard sessionState = iota
|
||||
stateLogs
|
||||
stateUsers
|
||||
stateDetail
|
||||
stateAlertDetail
|
||||
stateFormSite
|
||||
stateFormAlert
|
||||
stateFormUser
|
||||
stateConfirmDelete
|
||||
stateFormMaint
|
||||
stateHistory
|
||||
stateSLA
|
||||
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
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
state sessionState
|
||||
currentTab int
|
||||
returnState sessionState
|
||||
settingsSection int
|
||||
settingsCursor int
|
||||
settingsOffset int
|
||||
cursor int
|
||||
selectedID int
|
||||
sortColumn int
|
||||
@@ -140,6 +148,7 @@ type Model struct {
|
||||
termHeight int
|
||||
contentWidth int
|
||||
focusedPanel int
|
||||
detailMode int
|
||||
logScrollOffset int
|
||||
editID int
|
||||
editToken string
|
||||
@@ -156,25 +165,27 @@ type Model struct {
|
||||
logTotal int
|
||||
logShown int
|
||||
|
||||
historyViewport viewport.Model
|
||||
historyChanges []models.StateChange
|
||||
historySiteName string
|
||||
historySiteID int
|
||||
|
||||
slaViewport viewport.Model
|
||||
slaReport monitor.SLAReport
|
||||
slaDailyBreakdown []monitor.DayReport
|
||||
slaSiteName string
|
||||
slaSiteID int
|
||||
slaPeriodIdx int
|
||||
slaReport monitor.SLAReport
|
||||
slaDailyBreakdown []monitor.DayReport
|
||||
slaSiteName string
|
||||
slaSiteID int
|
||||
slaPeriodIdx int
|
||||
detailScrollOffset int
|
||||
|
||||
isAdmin bool
|
||||
zones *zone.Manager
|
||||
|
||||
deleteID int
|
||||
deleteName string
|
||||
deleteTab int
|
||||
deleteKind string
|
||||
|
||||
maintDetailID int
|
||||
|
||||
ctx context.Context
|
||||
collapsed map[int]bool
|
||||
store store.Store
|
||||
engine *monitor.Engine
|
||||
@@ -196,31 +207,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(isAdmin bool, s store.Store, eng *monitor.Engine, version string) Model {
|
||||
func InitialModel(ctx context.Context, 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(s)
|
||||
collapsed := loadCollapsed(ctx, s)
|
||||
|
||||
themeName, _ := s.GetPreference(context.Background(), "theme")
|
||||
themeName, _ := s.GetPreference(ctx, "theme")
|
||||
theme := themeByName(themeName)
|
||||
themeIdx := 0
|
||||
for i, t := range themes {
|
||||
@@ -230,25 +241,26 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri
|
||||
}
|
||||
}
|
||||
|
||||
detailPref, _ := s.GetPreference(context.Background(), "detail_open")
|
||||
detailPref, _ := s.GetPreference(ctx, "detail_open")
|
||||
|
||||
return Model{
|
||||
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,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+589
-516
File diff suppressed because it is too large
Load Diff
+159
-21
@@ -118,11 +118,10 @@ 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.state = stateDetail
|
||||
m.detailOpen = true
|
||||
m.termWidth = 120
|
||||
m.termHeight = 40
|
||||
|
||||
// Entering detail dispatches the load Cmd.
|
||||
cmd := m.loadDetailCmd(1)
|
||||
if cmd == nil {
|
||||
t.Fatal("loadDetailCmd returned nil")
|
||||
@@ -136,16 +135,14 @@ 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.viewDetailPanel()
|
||||
_ = m.viewDetailInline(80, 30)
|
||||
}
|
||||
if ms.stateChangeCalls != 1 {
|
||||
t.Errorf("View performed DB IO: store hit %d times (want 1, from the Cmd only)", ms.stateChangeCalls)
|
||||
@@ -202,16 +199,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.state = stateDetail
|
||||
m.detailOpen = true
|
||||
m.termWidth, m.termHeight = 120, 40
|
||||
|
||||
updated, cmd := (&m).handleDetailKey(keyMsg("h"))
|
||||
updated, cmd := (&m).handleDashboardKey(keyMsg("h"))
|
||||
if ms.stateChangeCalls != 0 {
|
||||
t.Fatal("history keypress hit the store synchronously in Update")
|
||||
}
|
||||
got := updated.(*Model)
|
||||
if got.state != stateHistory || got.historySiteID != 7 {
|
||||
t.Fatalf("history view not opened: state=%v siteID=%d", got.state, got.historySiteID)
|
||||
if got.detailMode != detailHistory || got.historySiteID != 7 {
|
||||
t.Fatalf("history mode not set: mode=%v siteID=%d", got.detailMode, got.historySiteID)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected a history load Cmd")
|
||||
@@ -229,7 +226,6 @@ 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 {
|
||||
@@ -241,20 +237,15 @@ 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")
|
||||
@@ -266,7 +257,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.deleteTab = 0
|
||||
m.deleteKind = "site"
|
||||
m.deleteID = 4
|
||||
|
||||
updated, cmd := (&m).handleConfirmDelete(keyMsg("y"))
|
||||
@@ -309,17 +300,81 @@ 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.state = stateDashboard
|
||||
m.detailOpen = false
|
||||
if (&m).detailRefreshCmd() != nil {
|
||||
t.Error("refresh Cmd issued outside the detail view")
|
||||
}
|
||||
|
||||
m.state = stateDetail
|
||||
m.detailOpen = true
|
||||
cmd := (&m).detailRefreshCmd()
|
||||
if cmd == nil {
|
||||
t.Fatal("open detail panel should refresh on the tab-data cadence")
|
||||
@@ -334,3 +389,86 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ func (m Model) uptimeTimeline(days []monitor.DayReport, width int) string {
|
||||
for _, d := range display {
|
||||
ch := "█"
|
||||
switch {
|
||||
case d.UptimePct >= 99.0:
|
||||
case d.UptimePct >= uptimeGoodPct:
|
||||
sb.WriteString(m.st.specialStyle.Render(ch))
|
||||
case d.UptimePct >= 95.0:
|
||||
case d.UptimePct >= uptimeWarnPct:
|
||||
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 < 99.0 {
|
||||
if pct < uptimeGoodPct {
|
||||
pctStyle = m.st.dangerStyle
|
||||
} else if pct < 99.9 {
|
||||
} else if pct < uptimeExcellentPct {
|
||||
pctStyle = m.st.warnStyle
|
||||
}
|
||||
sb.WriteString(" " + pctStyle.Render(fmt.Sprintf("%.2f%%", pct)))
|
||||
|
||||
+139
-164
@@ -47,12 +47,12 @@ func (m Model) View() string {
|
||||
switch m.state {
|
||||
case stateConfirmDelete:
|
||||
kind := "monitor"
|
||||
switch m.deleteTab {
|
||||
case 1:
|
||||
kind = "alert"
|
||||
case 4:
|
||||
switch m.deleteKind {
|
||||
case "maint":
|
||||
kind = "maintenance window"
|
||||
case 5:
|
||||
case "alert":
|
||||
kind = "alert"
|
||||
case "user":
|
||||
kind = "user"
|
||||
}
|
||||
msg := m.st.dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName))
|
||||
@@ -90,14 +90,16 @@ func (m Model) View() string {
|
||||
return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer)
|
||||
}
|
||||
return ""
|
||||
case stateDetail:
|
||||
return m.zones.Scan(m.viewDetailPanel())
|
||||
case stateHistory:
|
||||
return m.viewHistoryPanel()
|
||||
case stateSLA:
|
||||
return m.viewSLAPanel()
|
||||
case stateLogs:
|
||||
return m.viewLogsFullscreen()
|
||||
case stateDetailFullscreen:
|
||||
return m.viewDetailFullscreen()
|
||||
case stateAlertDetail:
|
||||
return m.viewAlertDetailPanel()
|
||||
case stateSettings:
|
||||
return m.viewSettingsOverlay()
|
||||
case stateMaintDetail:
|
||||
return m.viewMaintDetailPanel()
|
||||
default:
|
||||
return m.zones.Scan(m.viewDashboard())
|
||||
}
|
||||
@@ -130,7 +132,7 @@ func (m Model) computeStats() dashboardStats {
|
||||
}
|
||||
}
|
||||
for _, n := range m.nodes {
|
||||
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) > 5*time.Minute {
|
||||
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) > nodeStaleThreshold {
|
||||
s.offlineNodes++
|
||||
}
|
||||
}
|
||||
@@ -143,61 +145,67 @@ 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.renderTabBar(stats)
|
||||
header = m.pulseIndicator() + " " + header
|
||||
header := m.renderStatusLine(stats)
|
||||
|
||||
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 := m.viewMonitorsLayout()
|
||||
|
||||
content = strings.TrimSpace(content)
|
||||
footer := m.renderFooter(stats)
|
||||
@@ -209,74 +217,17 @@ func (m Model) viewDashboard() string {
|
||||
availHeight = 5
|
||||
}
|
||||
|
||||
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)
|
||||
contentHeight := availHeight - lipgloss.Height(header) - 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, tabDivider, paddedContent, footer))
|
||||
return outerPad.Render(lipgloss.JoinVertical(lipgloss.Top, header, paddedContent, footer))
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
func (m Model) renderStatusLine(stats dashboardStats) string {
|
||||
dot := m.st.subtleStyle.Render(" · ")
|
||||
|
||||
upCount := stats.totalMonitors - stats.downCount - stats.lateCount
|
||||
var upStr string
|
||||
@@ -287,59 +238,83 @@ func (m Model) renderFooter(stats dashboardStats) string {
|
||||
} else {
|
||||
upStr = m.st.specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors))
|
||||
}
|
||||
statusParts := []string{upStr}
|
||||
|
||||
parts := []string{m.pulseIndicator() + " " + upStr}
|
||||
|
||||
if stats.lateCount > 0 {
|
||||
statusParts = append(statusParts, m.st.warnStyle.Render(fmt.Sprintf("%d LATE", stats.lateCount)))
|
||||
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)))
|
||||
}
|
||||
if len(m.nodes) > 0 {
|
||||
online := 0
|
||||
for _, n := range m.nodes {
|
||||
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) < 60*time.Second {
|
||||
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) < nodeOnlineThreshold {
|
||||
online++
|
||||
}
|
||||
}
|
||||
probeLabel := "probes"
|
||||
label := "probes"
|
||||
if online == 1 {
|
||||
probeLabel = "probe"
|
||||
label = "probe"
|
||||
}
|
||||
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"
|
||||
parts = append(parts, fmt.Sprintf("%d %s", online, label))
|
||||
}
|
||||
|
||||
left := strings.Join(parts, dot)
|
||||
ver := m.st.subtleStyle.Render("v" + m.version)
|
||||
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
|
||||
|
||||
padW := m.termWidth - chromePadH - lipgloss.Width(left) - lipgloss.Width(ver)
|
||||
if padW < 2 {
|
||||
padW = 2
|
||||
}
|
||||
|
||||
divW := m.termWidth - chromePadH
|
||||
if divW < 40 {
|
||||
divW = 40
|
||||
}
|
||||
return m.st.subtleStyle.Render(strings.Repeat("─", divW)) + "\n" + line
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -2,53 +2,265 @@ 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 int) string {
|
||||
func (m Model) viewDetailInline(width, height 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]
|
||||
hist, _ := m.engine.GetHistory(site.ID)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
// Status + latency + last check + state since
|
||||
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
|
||||
latency := m.fmtLatency(site.Latency)
|
||||
uptime := m.fmtUptime(hist.Statuses)
|
||||
|
||||
line1Parts := []string{status}
|
||||
statusParts := []string{status}
|
||||
if site.Latency > 0 {
|
||||
line1Parts = append(line1Parts, latency)
|
||||
statusParts = append(statusParts, m.fmtLatency(site.Latency))
|
||||
}
|
||||
line1Parts = append(line1Parts, fmt.Sprintf("Uptime %s", uptime))
|
||||
if !site.LastCheck.IsZero() {
|
||||
line1Parts = append(line1Parts, fmt.Sprintf("Checked %s", m.fmtTimeAgo(site.LastCheck)))
|
||||
statusParts = append(statusParts, m.fmtTimeAgo(site.LastCheck))
|
||||
}
|
||||
b.WriteString(" " + strings.Join(line1Parts, m.st.subtleStyle.Render(" · ")) + "\n")
|
||||
if !site.StatusChangedAt.IsZero() {
|
||||
dur := time.Since(site.StatusChangedAt)
|
||||
statusParts = append(statusParts, label.Render("for")+" "+fmtDuration(dur))
|
||||
}
|
||||
b.WriteString(" " + strings.Join(statusParts, dot) + "\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 := width - 12
|
||||
errW := innerW
|
||||
if errW < 20 {
|
||||
errW = 20
|
||||
}
|
||||
errMsg := limitStr(site.LastError, errW)
|
||||
b.WriteString(" " + m.st.subtleStyle.Render("Error") + " " + m.st.dangerStyle.Render(errMsg) + "\n")
|
||||
b.WriteString(" " + label.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\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 {
|
||||
var parts []string
|
||||
limit := 3
|
||||
limit := 5
|
||||
if len(stateChanges) < limit {
|
||||
limit = len(stateChanges)
|
||||
}
|
||||
@@ -57,47 +269,290 @@ func (m Model) viewDetailInline(width int) string {
|
||||
arrow := m.st.subtleStyle.Render("→")
|
||||
from := m.fmtStatusWord(sc.FromStatus)
|
||||
to := m.fmtStatusWord(sc.ToStatus)
|
||||
entry := from + " " + arrow + " " + to + " " + m.st.subtleStyle.Render(ago+" ago")
|
||||
entry := from + " " + arrow + " " + to + " " + label.Render(ago+" ago")
|
||||
if sc.ErrorReason != "" {
|
||||
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, 30))
|
||||
reasonW := innerW - 25
|
||||
if reasonW < 15 {
|
||||
reasonW = 15
|
||||
}
|
||||
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonW))
|
||||
}
|
||||
parts = append(parts, entry)
|
||||
b.WriteString(" " + entry + "\n")
|
||||
}
|
||||
b.WriteString(" " + strings.Join(parts, m.st.subtleStyle.Render(" · ")) + "\n")
|
||||
} else {
|
||||
b.WriteString(" " + label.Render("No state changes") + "\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":
|
||||
return m.st.dangerStyle.Render("DOWN")
|
||||
case "DOWN", "SSL EXP":
|
||||
return m.st.dangerStyle.Render(status)
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type historyStats struct {
|
||||
@@ -105,88 +103,3 @@ 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())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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")
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var slaPeriods = []struct {
|
||||
@@ -21,92 +19,6 @@ 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 {
|
||||
@@ -128,7 +40,7 @@ func fmtPct(pct float64) string {
|
||||
if pct == 100 {
|
||||
return "100.00"
|
||||
}
|
||||
if pct >= 99.99 {
|
||||
if pct >= uptimePrecisionPct {
|
||||
return fmt.Sprintf("%.3f", pct)
|
||||
}
|
||||
return fmt.Sprintf("%.2f", pct)
|
||||
|
||||
Reference in New Issue
Block a user