Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
1cfa0571c8
|
|||
|
d6ba7d9af8
|
|||
|
14cec4283d
|
|||
|
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)
|
_, unknown := testKey(t)
|
||||||
kc := newKeyCache(&kcMockStore{users: []models.User{{PublicKey: authorized}}})
|
kc := newKeyCache(&kcMockStore{users: []models.User{{PublicKey: authorized}}})
|
||||||
|
|
||||||
if !kc.IsAllowed(known) {
|
if !kc.IsAllowed(context.Background(), known) {
|
||||||
t.Error("known key denied")
|
t.Error("known key denied")
|
||||||
}
|
}
|
||||||
if kc.IsAllowed(unknown) {
|
if kc.IsAllowed(context.Background(), unknown) {
|
||||||
t.Error("unknown key allowed")
|
t.Error("unknown key allowed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ func TestKeyCache_RetainsKeysOnRefreshError(t *testing.T) {
|
|||||||
ms := &kcMockStore{users: []models.User{{PublicKey: authorized}}}
|
ms := &kcMockStore{users: []models.User{{PublicKey: authorized}}}
|
||||||
kc := newKeyCache(ms)
|
kc := newKeyCache(ms)
|
||||||
|
|
||||||
if !kc.IsAllowed(known) {
|
if !kc.IsAllowed(context.Background(), known) {
|
||||||
t.Fatal("known key denied on first refresh")
|
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.updated = time.Now().Add(-time.Hour)
|
||||||
kc.mu.Unlock()
|
kc.mu.Unlock()
|
||||||
|
|
||||||
if !kc.IsAllowed(known) {
|
if !kc.IsAllowed(context.Background(), known) {
|
||||||
t.Error("transient refresh error locked out a previously valid key")
|
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}}}
|
ms := &kcMockStore{users: []models.User{{PublicKey: authorized}}}
|
||||||
kc := newKeyCache(ms)
|
kc := newKeyCache(ms)
|
||||||
|
|
||||||
if !kc.IsAllowed(known) {
|
if !kc.IsAllowed(context.Background(), known) {
|
||||||
t.Fatal("known key denied on first refresh")
|
t.Fatal("known key denied on first refresh")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ func TestKeyCache_FailsClosedAfterInvalidate(t *testing.T) {
|
|||||||
ms.err = errors.New("db down")
|
ms.err = errors.New("db down")
|
||||||
kc.Invalidate()
|
kc.Invalidate()
|
||||||
|
|
||||||
if kc.IsAllowed(known) {
|
if kc.IsAllowed(context.Background(), known) {
|
||||||
t.Error("revoked key still allowed while DB is down — fails open")
|
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)
|
kc := newKeyCache(ms)
|
||||||
s := &userInvalidatingStore{Store: ms, kc: kc}
|
s := &userInvalidatingStore{Store: ms, kc: kc}
|
||||||
|
|
||||||
if !kc.IsAllowed(known) {
|
if !kc.IsAllowed(context.Background(), known) {
|
||||||
t.Fatal("known key denied on first refresh")
|
t.Fatal("known key denied on first refresh")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ func TestUserInvalidatingStore_DeleteDropsKeyCache(t *testing.T) {
|
|||||||
ms.users = nil
|
ms.users = nil
|
||||||
ms.err = errors.New("db down")
|
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")
|
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
|
return cidrs
|
||||||
}
|
}
|
||||||
|
|
||||||
func openStore(dbType, dsn string) store.Store {
|
func openStore(ctx context.Context, dbType, dsn string) store.Store {
|
||||||
var ss *store.SQLStore
|
var ss *store.SQLStore
|
||||||
var err error
|
var err error
|
||||||
if dbType == "postgres" {
|
if dbType == "postgres" {
|
||||||
@@ -201,7 +201,7 @@ func openStore(dbType, dsn string) store.Store {
|
|||||||
} else {
|
} else {
|
||||||
slog.Warn("no UPTOP_ENCRYPTION_KEY set, alert credentials stored unencrypted")
|
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)
|
slog.Error("database init failed", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -223,7 +223,8 @@ func runApply(args []string) {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
s := openStore(*dbType, *dsn)
|
ctx := context.Background()
|
||||||
|
s := openStore(ctx, *dbType, *dsn)
|
||||||
|
|
||||||
f, err := config.LoadFile(*filePath)
|
f, err := config.LoadFile(*filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -231,7 +232,7 @@ func runApply(args []string) {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
changes, err := config.Apply(context.Background(), s, f, config.ApplyOpts{
|
changes, err := config.Apply(ctx, s, f, config.ApplyOpts{
|
||||||
DryRun: *dryRun,
|
DryRun: *dryRun,
|
||||||
Prune: *prune,
|
Prune: *prune,
|
||||||
})
|
})
|
||||||
@@ -250,9 +251,10 @@ func runExport(args []string) {
|
|||||||
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
|
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
|
||||||
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
|
_ = 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 {
|
if err != nil {
|
||||||
slog.Error("export failed", "err", err)
|
slog.Error("export failed", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -281,6 +283,8 @@ func runMigrateSecrets(args []string) {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
var ss *store.SQLStore
|
var ss *store.SQLStore
|
||||||
if *dbType == "postgres" {
|
if *dbType == "postgres" {
|
||||||
ss, err = store.NewPostgresStore(*dsn)
|
ss, err = store.NewPostgresStore(*dsn)
|
||||||
@@ -291,21 +295,21 @@ func runMigrateSecrets(args []string) {
|
|||||||
slog.Error("database connection failed", "err", err)
|
slog.Error("database connection failed", "err", err)
|
||||||
os.Exit(1)
|
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)
|
slog.Error("database init failed", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
ss.SetEncryptor(enc)
|
ss.SetEncryptor(enc)
|
||||||
|
|
||||||
alerts, err := ss.GetAllAlerts(context.Background())
|
alerts, err := ss.GetAllAlerts(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to load alerts", "err", err)
|
slog.Error("failed to load alerts", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
migrated := 0
|
migrated := 0
|
||||||
for _, a := range alerts {
|
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)
|
slog.Error("alert migration failed", "alert", a.Name, "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -392,15 +396,19 @@ func runServe(args []string) {
|
|||||||
|
|
||||||
kc := newKeyCache(ss)
|
kc := newKeyCache(ss)
|
||||||
var s store.Store = &userInvalidatingStore{Store: ss, kc: kc}
|
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)
|
slog.Error("database init failed", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if *demo {
|
if *demo {
|
||||||
seedDemoData(s)
|
seedDemoData(ctx, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
seedKeysFromEnv(s)
|
seedKeysFromEnv(ctx, s)
|
||||||
|
|
||||||
if *importKuma != "" {
|
if *importKuma != "" {
|
||||||
kb, err := importer.LoadKumaFile(*importKuma)
|
kb, err := importer.LoadKumaFile(*importKuma)
|
||||||
@@ -409,7 +417,7 @@ func runServe(args []string) {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
backup := importer.ConvertKuma(kb)
|
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)
|
slog.Error("import failed", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -429,12 +437,9 @@ func runServe(args []string) {
|
|||||||
}
|
}
|
||||||
eng.SetMaintRetention(cfg.MaintRetention)
|
eng.SetMaintRetention(cfg.MaintRetention)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
eng.InitHistory(ctx)
|
||||||
defer cancel()
|
eng.InitLogs(ctx)
|
||||||
|
eng.InitAlertHealth(ctx)
|
||||||
eng.InitHistory()
|
|
||||||
eng.InitLogs()
|
|
||||||
eng.InitAlertHealth()
|
|
||||||
eng.Start(ctx)
|
eng.Start(ctx)
|
||||||
|
|
||||||
localTUI := isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd())
|
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)
|
sshSrv := startSSHServer(*port, s, eng, kc)
|
||||||
|
|
||||||
if localTUI {
|
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 {
|
if _, err := p.Run(); err != nil {
|
||||||
slog.Error("TUI failed", "err", err)
|
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.WithAddress(fmt.Sprintf(":%d", port)),
|
||||||
wish.WithHostKeyPath(envOrDefault("UPTOP_SSH_HOST_KEY", ".ssh/id_ed25519")),
|
wish.WithHostKeyPath(envOrDefault("UPTOP_SSH_HOST_KEY", ".ssh/id_ed25519")),
|
||||||
wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
|
wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
|
||||||
return kc.IsAllowed(key)
|
return kc.IsAllowed(ctx, key)
|
||||||
}),
|
}),
|
||||||
wish.WithMiddleware(
|
wish.WithMiddleware(
|
||||||
bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
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
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func seedDemoData(s store.Store) {
|
func seedDemoData(ctx context.Context, s store.Store) {
|
||||||
ctx := context.Background()
|
|
||||||
existing, _ := s.GetSites(ctx)
|
existing, _ := s.GetSites(ctx)
|
||||||
if len(existing) > 0 {
|
if len(existing) > 0 {
|
||||||
return
|
return
|
||||||
@@ -566,8 +570,8 @@ func newKeyCache(db store.Store) *keyCache {
|
|||||||
return &keyCache{db: db, ttl: 30 * time.Second}
|
return &keyCache{db: db, ttl: 30 * time.Second}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *keyCache) refresh() {
|
func (c *keyCache) refresh(ctx context.Context) {
|
||||||
users, err := c.db.GetAllUsers(context.Background())
|
users, err := c.db.GetAllUsers(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Keep the previous key set: a transient DB error must not lock every
|
// Keep the previous key set: a transient DB error must not lock every
|
||||||
// admin out. Revocation still fails closed because Invalidate clears
|
// admin out. Revocation still fails closed because Invalidate clears
|
||||||
@@ -600,13 +604,13 @@ func (c *keyCache) Invalidate() {
|
|||||||
c.mu.Unlock()
|
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()
|
c.mu.RLock()
|
||||||
stale := time.Since(c.updated) > c.ttl
|
stale := time.Since(c.updated) > c.ttl
|
||||||
c.mu.RUnlock()
|
c.mu.RUnlock()
|
||||||
|
|
||||||
if stale {
|
if stale {
|
||||||
c.refresh()
|
c.refresh(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
@@ -652,8 +656,7 @@ func (s *userInvalidatingStore) ImportData(ctx context.Context, data models.Back
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func seedKeysFromEnv(s store.Store) {
|
func seedKeysFromEnv(ctx context.Context, s store.Store) {
|
||||||
ctx := context.Background()
|
|
||||||
var keys []string
|
var keys []string
|
||||||
|
|
||||||
if v := os.Getenv("UPTOP_ADMIN_KEY"); v != "" {
|
if v := os.Getenv("UPTOP_ADMIN_KEY"); v != "" {
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import (
|
|||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"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
|
// sanitizeError strips the request URL from transport errors before they are
|
||||||
// stored or displayed. *url.Error embeds the full URL, which for several
|
// 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
|
// "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) {
|
func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) {
|
||||||
client := http.Client{Timeout: 2 * time.Second}
|
client := http.Client{Timeout: followerTimeout}
|
||||||
failures := 0
|
failures := 0
|
||||||
threshold := 3
|
threshold := leaderFailureThreshold
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-time.After(5 * time.Second):
|
case <-time.After(followerRetryInterval):
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -59,7 +65,7 @@ func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) {
|
|||||||
isLeaderHealthy := false
|
isLeaderHealthy := false
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
isLeaderHealthy = resp.StatusCode == 200
|
isLeaderHealthy = resp.StatusCode == http.StatusOK
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,12 +26,18 @@ type ProbeConfig struct {
|
|||||||
AllowPrivateTargets bool
|
AllowPrivateTargets bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
probeMinInterval = 10
|
||||||
|
probeDefaultInterval = 30
|
||||||
|
probeAPITimeout = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
func RunProbe(ctx context.Context, cfg ProbeConfig) error {
|
func RunProbe(ctx context.Context, cfg ProbeConfig) error {
|
||||||
if cfg.Interval < 10 {
|
if cfg.Interval < probeMinInterval {
|
||||||
cfg.Interval = 30
|
cfg.Interval = probeDefaultInterval
|
||||||
}
|
}
|
||||||
|
|
||||||
apiClient := &http.Client{Timeout: 10 * time.Second}
|
apiClient := &http.Client{Timeout: probeAPITimeout}
|
||||||
dial := monitor.SafeDialContext(cfg.AllowPrivateTargets)
|
dial := monitor.SafeDialContext(cfg.AllowPrivateTargets)
|
||||||
strictClient := &http.Client{
|
strictClient := &http.Client{
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package monitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/alert"
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AlertHealth struct {
|
||||||
|
LastSendAt time.Time
|
||||||
|
LastSendOK bool
|
||||||
|
LastError string
|
||||||
|
SendCount int
|
||||||
|
FailCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitAlertHealth restores persisted alert send health so the dashboard shows real
|
||||||
|
// "last sent" / health state on startup instead of resetting every channel to "never".
|
||||||
|
func (e *Engine) InitAlertHealth(ctx context.Context) {
|
||||||
|
records, err := e.db.LoadAlertHealth(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.alertHealthMu.Lock()
|
||||||
|
defer e.alertHealthMu.Unlock()
|
||||||
|
for id, r := range records {
|
||||||
|
e.alertHealth[id] = AlertHealth{
|
||||||
|
LastSendAt: r.LastSendAt,
|
||||||
|
LastSendOK: r.LastSendOK,
|
||||||
|
LastError: r.LastError,
|
||||||
|
SendCount: r.SendCount,
|
||||||
|
FailCount: r.FailCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStatusChange folds a check result into the live state. snap is the
|
||||||
|
// stale snapshot the check ran against; the actual mutation is applied onto the
|
||||||
|
// CURRENT live entry via applyState, so a concurrent pause / config edit /
|
||||||
|
// heartbeat is never reverted by this write. Logs and alerts are emitted after
|
||||||
|
// the lock is released, off the critical section.
|
||||||
|
func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int, latency time.Duration, errorReason string) {
|
||||||
|
if !e.IsActive() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
inMaint := e.isInMaintenance(snap.ID)
|
||||||
|
status := models.Status(rawStatus)
|
||||||
|
|
||||||
|
var (
|
||||||
|
prev, next models.Status
|
||||||
|
name, typ string
|
||||||
|
alertID int
|
||||||
|
failCount, maxRetries int
|
||||||
|
confirmedDown bool
|
||||||
|
failedCheck bool
|
||||||
|
downSince time.Time
|
||||||
|
sslWarnFire bool
|
||||||
|
sslDays int
|
||||||
|
skipped bool
|
||||||
|
changed bool
|
||||||
|
)
|
||||||
|
|
||||||
|
_, exists := e.applyState(snap.ID, func(s *models.Site) {
|
||||||
|
// A non-UP result computed from a stale snapshot must not override a
|
||||||
|
// heartbeat (or newer check) that landed while we were evaluating.
|
||||||
|
if status != models.StatusUp && s.LastCheck.After(snap.LastCheck) {
|
||||||
|
skipped = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prev = s.Status
|
||||||
|
name = s.Name
|
||||||
|
typ = s.Type
|
||||||
|
alertID = s.AlertID
|
||||||
|
maxRetries = s.MaxRetries
|
||||||
|
downSince = s.StatusChangedAt
|
||||||
|
|
||||||
|
// Fresh check results (measured by the run against snap).
|
||||||
|
s.StatusCode = code
|
||||||
|
s.Latency = snap.Latency
|
||||||
|
s.LastCheck = snap.LastCheck
|
||||||
|
s.HasSSL = snap.HasSSL
|
||||||
|
s.CertExpiry = snap.CertExpiry
|
||||||
|
s.LastError = errorReason
|
||||||
|
if status == models.StatusUp {
|
||||||
|
s.LastSuccessAt = time.Now()
|
||||||
|
s.LastError = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status + failure-count transition, based on the CURRENT live status.
|
||||||
|
if status == models.StatusUp {
|
||||||
|
s.FailureCount = 0
|
||||||
|
s.Status = models.StatusUp
|
||||||
|
} else {
|
||||||
|
if s.FailureCount <= s.MaxRetries {
|
||||||
|
s.FailureCount++
|
||||||
|
}
|
||||||
|
if s.FailureCount > s.MaxRetries {
|
||||||
|
if s.Status != status {
|
||||||
|
confirmedDown = true
|
||||||
|
}
|
||||||
|
s.Status = status
|
||||||
|
s.FailureCount = s.MaxRetries + 1
|
||||||
|
} else {
|
||||||
|
failedCheck = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
failCount = s.FailureCount
|
||||||
|
|
||||||
|
if s.Status != prev && prev != models.StatusPending {
|
||||||
|
s.StatusChangedAt = time.Now()
|
||||||
|
} else if s.StatusChangedAt.IsZero() && s.Status != models.StatusPending {
|
||||||
|
s.StatusChangedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSL expiry warning (fresh HasSSL/CertExpiry + config threshold).
|
||||||
|
if typ == "http" && s.CheckSSL && s.HasSSL {
|
||||||
|
days := int(time.Until(s.CertExpiry).Hours() / 24)
|
||||||
|
if days <= s.ExpiryThreshold && !s.SentSSLWarning && status != models.StatusSSLExp {
|
||||||
|
sslWarnFire = true
|
||||||
|
sslDays = days
|
||||||
|
s.SentSSLWarning = true
|
||||||
|
} else if days > s.ExpiryThreshold {
|
||||||
|
s.SentSSLWarning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next = s.Status
|
||||||
|
changed = next != prev
|
||||||
|
})
|
||||||
|
|
||||||
|
if !exists || skipped {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e.recordCheck(snap.ID, latency, status == models.StatusUp)
|
||||||
|
|
||||||
|
if confirmedDown {
|
||||||
|
if errorReason != "" {
|
||||||
|
e.AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN: %s", name, errorReason))
|
||||||
|
} else {
|
||||||
|
e.AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN", name))
|
||||||
|
}
|
||||||
|
} else if failedCheck {
|
||||||
|
e.AddLog(fmt.Sprintf("Monitor '%s' failed check %d/%d", name, failCount, maxRetries))
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed && prev != models.StatusPending {
|
||||||
|
e.enqueueWrite(writeStateChange{siteID: snap.ID, fromStatus: string(prev), toStatus: string(next), reason: errorReason})
|
||||||
|
}
|
||||||
|
|
||||||
|
if sslWarnFire {
|
||||||
|
if !inMaint {
|
||||||
|
e.triggerAlert(alertID, "SSL WARNING", fmt.Sprintf("SSL for '%s' expires in %d days", name, sslDays))
|
||||||
|
} else {
|
||||||
|
e.AddLog(fmt.Sprintf("SSL warning for '%s' suppressed (maintenance)", name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if prev == models.StatusUp && next == models.StatusLate {
|
||||||
|
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat overdue", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !prev.IsBroken() && next.IsBroken() && next != models.StatusPending {
|
||||||
|
if inMaint {
|
||||||
|
e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed — maintenance)", name))
|
||||||
|
} else {
|
||||||
|
msg := fmt.Sprintf("Monitor '%s' is DOWN (%s)", name, rawStatus)
|
||||||
|
if errorReason != "" {
|
||||||
|
msg = fmt.Sprintf("Monitor '%s' is DOWN: %s", name, errorReason)
|
||||||
|
}
|
||||||
|
if typ == "push" {
|
||||||
|
msg = fmt.Sprintf("Push Monitor '%s' missed heartbeat.", name)
|
||||||
|
}
|
||||||
|
e.triggerAlert(alertID, "🚨 ALERT", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if prev.IsBroken() && next == models.StatusUp {
|
||||||
|
downDur := ""
|
||||||
|
if !downSince.IsZero() {
|
||||||
|
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(downSince)))
|
||||||
|
}
|
||||||
|
e.AddLog(fmt.Sprintf("Monitor '%s' recovered%s", name, downDur))
|
||||||
|
if !inMaint {
|
||||||
|
e.triggerAlert(alertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP%s", name, downDur))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if prev == models.StatusLate && next == models.StatusUp && !prev.IsBroken() {
|
||||||
|
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat arrived (was late)", name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) triggerAlert(alertID int, title, message string) {
|
||||||
|
if alertID <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
provider := alert.GetProvider(cfg)
|
||||||
|
if provider != nil {
|
||||||
|
go func() {
|
||||||
|
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))
|
||||||
|
e.recordAlertResult(alertID, false, err.Error())
|
||||||
|
} else {
|
||||||
|
e.recordAlertResult(alertID, true, "")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) recordAlertResult(alertID int, ok bool, errMsg string) {
|
||||||
|
e.alertHealthMu.Lock()
|
||||||
|
defer e.alertHealthMu.Unlock()
|
||||||
|
h := e.alertHealth[alertID]
|
||||||
|
h.LastSendAt = time.Now()
|
||||||
|
h.LastSendOK = ok
|
||||||
|
h.SendCount++
|
||||||
|
if ok {
|
||||||
|
h.LastError = ""
|
||||||
|
} else {
|
||||||
|
h.LastError = errMsg
|
||||||
|
h.FailCount++
|
||||||
|
}
|
||||||
|
e.alertHealth[alertID] = h
|
||||||
|
|
||||||
|
// Persist so health survives restarts; DB IO off the alert path.
|
||||||
|
e.enqueueWrite(writeAlertHealth{rec: models.AlertHealthRecord{
|
||||||
|
AlertID: alertID,
|
||||||
|
LastSendAt: h.LastSendAt,
|
||||||
|
LastSendOK: h.LastSendOK,
|
||||||
|
LastError: h.LastError,
|
||||||
|
SendCount: h.SendCount,
|
||||||
|
FailCount: h.FailCount,
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) GetAlertHealth(alertID int) AlertHealth {
|
||||||
|
e.alertHealthMu.RLock()
|
||||||
|
defer e.alertHealthMu.RUnlock()
|
||||||
|
return e.alertHealth[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)
|
||||||
|
}
|
||||||
|
provider := alert.GetProvider(cfg)
|
||||||
|
if provider == nil {
|
||||||
|
return fmt.Errorf("no provider for type %q", cfg.Type)
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
e.recordAlertResult(alertID, false, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.recordAlertResult(alertID, true, "")
|
||||||
|
e.AddLog(fmt.Sprintf("Test alert sent to '%s'", cfg.Name))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
package monitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Group 1: State Machine ---
|
||||||
|
|
||||||
|
func TestHandleStatusChange_PendingToUp(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 3, AlertID: 1},
|
||||||
|
SiteState: models.SiteState{Status: "PENDING"},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
e.handleStatusChange(site, "UP", 200, 10*time.Millisecond, "")
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "UP" {
|
||||||
|
t.Errorf("expected UP, got %s", s.Status)
|
||||||
|
}
|
||||||
|
if s.FailureCount != 0 {
|
||||||
|
t.Errorf("expected FailureCount 0, got %d", s.FailureCount)
|
||||||
|
}
|
||||||
|
waitAsync()
|
||||||
|
if len(ms.getAlertCallsSnapshot()) != 0 {
|
||||||
|
t.Error("expected no alert for PENDING→UP")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStatusChange_UpIncrementFailure(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 3},
|
||||||
|
SiteState: models.SiteState{Status: "UP", FailureCount: 0},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
e.handleStatusChange(site, "DOWN", 500, 0, "test error")
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "UP" {
|
||||||
|
t.Errorf("expected UP (under retry threshold), got %s", s.Status)
|
||||||
|
}
|
||||||
|
if s.FailureCount != 1 {
|
||||||
|
t.Errorf("expected FailureCount 1, got %d", s.FailureCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStatusChange_UpToDown_ExceedsRetries(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "discord", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 2, AlertID: 1},
|
||||||
|
SiteState: models.SiteState{Status: "UP", FailureCount: 2},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
e.handleStatusChange(site, "DOWN", 500, 0, "test error")
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "DOWN" {
|
||||||
|
t.Errorf("expected DOWN, got %s", s.Status)
|
||||||
|
}
|
||||||
|
if s.FailureCount != 3 {
|
||||||
|
t.Errorf("expected FailureCount 3, got %d", s.FailureCount)
|
||||||
|
}
|
||||||
|
waitAsync()
|
||||||
|
calls := ms.getAlertCallsSnapshot()
|
||||||
|
if len(calls) == 0 || calls[0] != 1 {
|
||||||
|
t.Errorf("expected alert call for alertID 1, got %v", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStatusChange_UpToDown_ZeroRetries(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0, AlertID: 1},
|
||||||
|
SiteState: models.SiteState{Status: "UP", FailureCount: 0},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
e.handleStatusChange(site, "DOWN", 0, 0, "test error")
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "DOWN" {
|
||||||
|
t.Errorf("expected DOWN, got %s", s.Status)
|
||||||
|
}
|
||||||
|
waitAsync()
|
||||||
|
if len(ms.getAlertCallsSnapshot()) == 0 {
|
||||||
|
t.Error("expected alert on immediate DOWN")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStatusChange_DownToUp_Recovery(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test", AlertID: 1},
|
||||||
|
SiteState: models.SiteState{Status: "DOWN", FailureCount: 4},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
e.handleStatusChange(site, "UP", 200, 5*time.Millisecond, "")
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "UP" {
|
||||||
|
t.Errorf("expected UP, got %s", s.Status)
|
||||||
|
}
|
||||||
|
if s.FailureCount != 0 {
|
||||||
|
t.Errorf("expected FailureCount 0, got %d", s.FailureCount)
|
||||||
|
}
|
||||||
|
waitAsync()
|
||||||
|
if len(ms.getAlertCallsSnapshot()) == 0 {
|
||||||
|
t.Error("expected recovery alert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStatusChange_DownStaysDown(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 2},
|
||||||
|
SiteState: models.SiteState{Status: "DOWN", FailureCount: 3},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
e.handleStatusChange(site, "DOWN", 0, 0, "test error")
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "DOWN" {
|
||||||
|
t.Errorf("expected DOWN, got %s", s.Status)
|
||||||
|
}
|
||||||
|
waitAsync()
|
||||||
|
if len(ms.getAlertCallsSnapshot()) != 0 {
|
||||||
|
t.Error("expected no re-alert for already DOWN")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStatusChange_SSLExpired(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0, AlertID: 1},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
e.handleStatusChange(site, "SSL EXP", 0, 0, "SSL certificate expired")
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "SSL EXP" {
|
||||||
|
t.Errorf("expected SSL EXP, got %s", s.Status)
|
||||||
|
}
|
||||||
|
waitAsync()
|
||||||
|
if len(ms.getAlertCallsSnapshot()) == 0 {
|
||||||
|
t.Error("expected alert on SSL EXP")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStatusChange_AlertSuppressedMaintenance(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
ms.maintenance[1] = true
|
||||||
|
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0, AlertID: 1},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
e.refreshMaintenanceCache(context.Background())
|
||||||
|
|
||||||
|
e.handleStatusChange(site, "DOWN", 0, 0, "test error")
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "DOWN" {
|
||||||
|
t.Errorf("expected DOWN, got %s", s.Status)
|
||||||
|
}
|
||||||
|
waitAsync()
|
||||||
|
if len(ms.getAlertCallsSnapshot()) != 0 {
|
||||||
|
t.Error("expected no alert during maintenance")
|
||||||
|
}
|
||||||
|
logs := e.GetLogs()
|
||||||
|
found := false
|
||||||
|
for _, l := range logs {
|
||||||
|
if containsStr(l.Message, "suppressed") {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("expected log mentioning suppressed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStatusChange_RecoverySuppressedMaintenance(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
ms.maintenance[1] = true
|
||||||
|
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test", AlertID: 1},
|
||||||
|
SiteState: models.SiteState{Status: "DOWN"},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
e.refreshMaintenanceCache(context.Background())
|
||||||
|
|
||||||
|
e.handleStatusChange(site, "UP", 200, 0, "")
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "UP" {
|
||||||
|
t.Errorf("expected UP, got %s", s.Status)
|
||||||
|
}
|
||||||
|
waitAsync()
|
||||||
|
if len(ms.getAlertCallsSnapshot()) != 0 {
|
||||||
|
t.Error("expected no alert during maintenance recovery")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStatusChange_SSLWarning(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30, AlertID: 1},
|
||||||
|
SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: false, CertExpiry: time.Now().Add(15 * 24 * time.Hour)},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
e.handleStatusChange(site, "UP", 200, 0, "")
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if !s.SentSSLWarning {
|
||||||
|
t.Error("expected SentSSLWarning=true")
|
||||||
|
}
|
||||||
|
waitAsync()
|
||||||
|
if len(ms.getAlertCallsSnapshot()) == 0 {
|
||||||
|
t.Error("expected SSL warning alert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStatusChange_SSLWarningNotRepeated(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30, AlertID: 1},
|
||||||
|
SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: true, CertExpiry: time.Now().Add(15 * 24 * time.Hour)},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
e.handleStatusChange(site, "UP", 200, 0, "")
|
||||||
|
|
||||||
|
waitAsync()
|
||||||
|
if len(ms.getAlertCallsSnapshot()) != 0 {
|
||||||
|
t.Error("expected no repeat SSL warning")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStatusChange_SSLWarningReset(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30},
|
||||||
|
SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: true, CertExpiry: time.Now().Add(60 * 24 * time.Hour)},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
e.handleStatusChange(site, "UP", 200, 0, "")
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.SentSSLWarning {
|
||||||
|
t.Error("expected SentSSLWarning reset to false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStatusChange_SSLWarningSuppressedMaint(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
ms.maintenance[1] = true
|
||||||
|
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30, AlertID: 1},
|
||||||
|
SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: false, CertExpiry: time.Now().Add(15 * 24 * time.Hour)},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
e.refreshMaintenanceCache(context.Background())
|
||||||
|
|
||||||
|
e.handleStatusChange(site, "UP", 200, 0, "")
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if !s.SentSSLWarning {
|
||||||
|
t.Error("expected SentSSLWarning=true even in maintenance")
|
||||||
|
}
|
||||||
|
waitAsync()
|
||||||
|
if len(ms.getAlertCallsSnapshot()) != 0 {
|
||||||
|
t.Error("expected no alert during maintenance")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStatusChange_InactiveEngine(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
e.SetActive(false)
|
||||||
|
|
||||||
|
e.handleStatusChange(site, "DOWN", 0, 0, "test error")
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "UP" {
|
||||||
|
t.Error("expected no state change when inactive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Group 10: liveState merge (lost-update race) ---
|
||||||
|
|
||||||
|
// A pause that lands while a check is in flight must survive the check's
|
||||||
|
// write-back. The old code snapshotted the site, ran the check, then wrote the
|
||||||
|
// whole stale struct back — reverting the pause.
|
||||||
|
func TestHandleStatusChange_PauseDuringCheckSurvives(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
// `site` is the stale snapshot the check ran against (Paused=false).
|
||||||
|
// Meanwhile the user pauses the monitor.
|
||||||
|
e.ToggleSitePause(1)
|
||||||
|
|
||||||
|
// Check completes and folds its result in using the stale snapshot.
|
||||||
|
e.handleStatusChange(site, "DOWN", 500, 0, "boom")
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if !s.Paused {
|
||||||
|
t.Error("pause was reverted by a stale check write-back")
|
||||||
|
}
|
||||||
|
if s.Status != "DOWN" {
|
||||||
|
t.Errorf("expected check result still applied (DOWN), got %s", s.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A config edit that lands while a check is in flight must survive; the check
|
||||||
|
// must not resurrect the old config from its snapshot.
|
||||||
|
func TestHandleStatusChange_ConfigEditDuringCheckSurvives(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test", URL: "http://old.com", Type: "http", MaxRetries: 0, Interval: 30},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
// Config changes mid-check.
|
||||||
|
e.UpdateSiteConfig(models.SiteConfig{ID: 1, Name: "test", URL: "http://new.com", Type: "http", Interval: 60})
|
||||||
|
|
||||||
|
// Stale check (ran against http://old.com) folds its result in.
|
||||||
|
e.handleStatusChange(site, "UP", 200, 5*time.Millisecond, "")
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.URL != "http://new.com" {
|
||||||
|
t.Errorf("config edit reverted: URL=%s", s.URL)
|
||||||
|
}
|
||||||
|
if s.Interval != 60 {
|
||||||
|
t.Errorf("config edit reverted: Interval=%d", s.Interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The classic push false-DOWN: a heartbeat marks the monitor UP while a
|
||||||
|
// staleness evaluation (computed from the older LastCheck) is mid-flight.
|
||||||
|
// The stale DOWN must not overwrite the fresh heartbeat.
|
||||||
|
func TestHandleStatusChange_HeartbeatNotOverwrittenByStaleDown(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
// Snapshot the engine would have taken before evaluating staleness:
|
||||||
|
// LastCheck is old, so checkPush decided "DOWN".
|
||||||
|
snap := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Token: "tok", Interval: 10},
|
||||||
|
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now().Add(-120 * time.Second)},
|
||||||
|
}
|
||||||
|
injectSite(e, snap)
|
||||||
|
|
||||||
|
// A heartbeat lands first, advancing LastCheck and confirming UP.
|
||||||
|
if !e.RecordHeartbeat("tok") {
|
||||||
|
t.Fatal("heartbeat rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now the in-flight stale evaluation tries to write DOWN.
|
||||||
|
e.handleStatusChange(snap, "DOWN", 0, 0, "heartbeat missed")
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "UP" {
|
||||||
|
t.Errorf("stale DOWN overwrote a fresh heartbeat: status=%s", s.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A check result for a site removed mid-check must be dropped, not recreate it.
|
||||||
|
func TestHandleStatusChange_RemovedSiteDropped(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
e.RemoveSite(1)
|
||||||
|
e.handleStatusChange(site, "DOWN", 500, 0, "boom")
|
||||||
|
|
||||||
|
if _, ok := getSite(e, 1); ok {
|
||||||
|
t.Error("removed site was recreated by a late check write-back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Group 12: Phase 3 engine correctness ---
|
||||||
|
|
||||||
|
// PENDING→DOWN must honor MaxRetries instead of alerting on first failure.
|
||||||
|
func TestHandleStatusChange_PendingRetriesBeforeDown(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "new-monitor", MaxRetries: 2},
|
||||||
|
SiteState: models.SiteState{Status: "PENDING"},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
e.handleStatusChange(site, "DOWN", 0, 0, "timeout")
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "PENDING" {
|
||||||
|
t.Errorf("expected PENDING during retry, got %s", s.Status)
|
||||||
|
}
|
||||||
|
if s.FailureCount != 1 {
|
||||||
|
t.Errorf("expected FailureCount 1, got %d", s.FailureCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.handleStatusChange(s, "DOWN", 0, 0, "timeout")
|
||||||
|
s, _ = getSite(e, 1)
|
||||||
|
if s.Status != "PENDING" {
|
||||||
|
t.Errorf("expected PENDING during retry 2, got %s", s.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.handleStatusChange(s, "DOWN", 0, 0, "timeout")
|
||||||
|
s, _ = getSite(e, 1)
|
||||||
|
if s.Status != "DOWN" {
|
||||||
|
t.Errorf("expected DOWN after retries exhausted, got %s", s.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LATE→DOWN must also honor MaxRetries.
|
||||||
|
func TestHandleStatusChange_LateRetriesBeforeDown(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "push-mon", MaxRetries: 1},
|
||||||
|
SiteState: models.SiteState{Status: "LATE"},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
e.handleStatusChange(site, "DOWN", 0, 0, "missed heartbeat")
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "LATE" {
|
||||||
|
t.Errorf("expected LATE during retry, got %s", s.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.handleStatusChange(s, "DOWN", 0, 0, "missed heartbeat")
|
||||||
|
s, _ = getSite(e, 1)
|
||||||
|
if s.Status != "DOWN" {
|
||||||
|
t.Errorf("expected DOWN after retries exhausted, got %s", s.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
package monitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *Engine) RecordHeartbeat(token string) bool {
|
||||||
|
if !e.IsActive() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
e.mu.RLock()
|
||||||
|
targetID, ok := e.tokenIndex[token]
|
||||||
|
e.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
prevStatus models.Status
|
||||||
|
name string
|
||||||
|
alertID int
|
||||||
|
downSince time.Time
|
||||||
|
)
|
||||||
|
_, exists := e.applyState(targetID, func(s *models.Site) {
|
||||||
|
prevStatus = s.Status
|
||||||
|
name = s.Name
|
||||||
|
alertID = s.AlertID
|
||||||
|
downSince = s.StatusChangedAt // captured before mutation = when it went down
|
||||||
|
|
||||||
|
s.LastCheck = time.Now()
|
||||||
|
s.Status = models.StatusUp
|
||||||
|
s.FailureCount = 0
|
||||||
|
s.Latency = 0
|
||||||
|
s.LastError = ""
|
||||||
|
s.LastSuccessAt = time.Now()
|
||||||
|
if prevStatus != models.StatusUp {
|
||||||
|
s.StatusChangedAt = time.Now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch prevStatus {
|
||||||
|
case models.StatusPending:
|
||||||
|
e.AddLog(fmt.Sprintf("Push Monitor '%s' received first heartbeat", name))
|
||||||
|
case models.StatusLate:
|
||||||
|
e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was late)", name))
|
||||||
|
case models.StatusStale:
|
||||||
|
e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was stale)", name))
|
||||||
|
case models.StatusDown:
|
||||||
|
downDur := ""
|
||||||
|
if !downSince.IsZero() {
|
||||||
|
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(downSince)))
|
||||||
|
}
|
||||||
|
e.AddLog(fmt.Sprintf("Push Monitor '%s' recovered%s", name, downDur))
|
||||||
|
go e.triggerAlert(alertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.%s", name, downDur))
|
||||||
|
}
|
||||||
|
|
||||||
|
e.recordCheck(targetID, 0, true)
|
||||||
|
|
||||||
|
if prevStatus != models.StatusUp && prevStatus != models.StatusPending {
|
||||||
|
e.enqueueWrite(writeStateChange{siteID: targetID, fromStatus: string(prevStatus), toStatus: string(models.StatusUp)})
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) getRecheckChan(id int) chan struct{} {
|
||||||
|
e.recheckMu.Lock()
|
||||||
|
defer e.recheckMu.Unlock()
|
||||||
|
ch, ok := e.recheck[id]
|
||||||
|
if !ok {
|
||||||
|
ch = make(chan struct{}, 1)
|
||||||
|
e.recheck[id] = ch
|
||||||
|
}
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) signalRecheck(id int) {
|
||||||
|
ch := e.getRecheckChan(id)
|
||||||
|
select {
|
||||||
|
case ch <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) monitorRoutine(ctx context.Context, id int) {
|
||||||
|
recheckCh := e.getRecheckChan(id)
|
||||||
|
|
||||||
|
// Stagger initial check to avoid thundering herd on startup
|
||||||
|
stagger := time.Duration(rand.IntN(3000)) * time.Millisecond //nolint:gosec // non-security jitter
|
||||||
|
select {
|
||||||
|
case <-time.After(stagger):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e.checkByID(ctx, id)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if !e.IsActive() {
|
||||||
|
select {
|
||||||
|
case <-time.After(pollInterval):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-recheckCh:
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
e.mu.RLock()
|
||||||
|
site, exists := e.liveState[id]
|
||||||
|
e.mu.RUnlock()
|
||||||
|
if !exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if site.Paused {
|
||||||
|
select {
|
||||||
|
case <-time.After(pollInterval):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-recheckCh:
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
interval := site.Interval
|
||||||
|
if interval < minCheckInterval {
|
||||||
|
interval = minCheckInterval
|
||||||
|
}
|
||||||
|
jitter := time.Duration(rand.IntN(interval*100)) * time.Millisecond //nolint:gosec // non-security jitter
|
||||||
|
select {
|
||||||
|
case <-time.After(time.Duration(interval)*time.Second + jitter):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-recheckCh:
|
||||||
|
}
|
||||||
|
e.checkByID(ctx, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) checkByID(ctx context.Context, id int) {
|
||||||
|
if !e.IsActive() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e.mu.RLock()
|
||||||
|
site, exists := e.liveState[id]
|
||||||
|
e.mu.RUnlock()
|
||||||
|
if !exists || site.Paused {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch site.Type {
|
||||||
|
case "push":
|
||||||
|
e.checkPush(ctx, site)
|
||||||
|
case "group":
|
||||||
|
e.checkGroup(ctx, site)
|
||||||
|
default:
|
||||||
|
result := RunCheck(ctx, site.SiteConfig, e.strictClient, e.insecureClient, e.insecureSkipVerify, e.allowPrivateTargets)
|
||||||
|
updatedSite := site
|
||||||
|
updatedSite.HasSSL = result.HasSSL
|
||||||
|
updatedSite.CertExpiry = result.CertExpiry
|
||||||
|
updatedSite.Latency = time.Duration(result.LatencyNs)
|
||||||
|
updatedSite.LastCheck = time.Now()
|
||||||
|
e.handleStatusChange(updatedSite, result.Status, result.StatusCode, time.Duration(result.LatencyNs), result.ErrorReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) checkPush(_ context.Context, site models.Site) {
|
||||||
|
if site.Status == models.StatusPending {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
interval := time.Duration(site.Interval) * time.Second
|
||||||
|
grace := interval / 2
|
||||||
|
if grace < minPushGrace {
|
||||||
|
grace = minPushGrace
|
||||||
|
}
|
||||||
|
|
||||||
|
overdue := site.LastCheck.Add(interval)
|
||||||
|
staleMark := overdue.Add(grace / 2)
|
||||||
|
graceEnd := overdue.Add(grace)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if now.After(graceEnd) {
|
||||||
|
if site.Status != models.StatusDown {
|
||||||
|
e.handleStatusChange(site, string(models.StatusDown), 0, 0, "heartbeat missed")
|
||||||
|
}
|
||||||
|
} else if now.After(staleMark) {
|
||||||
|
if site.Status != models.StatusStale {
|
||||||
|
e.handleStatusChange(site, string(models.StatusStale), 0, 0, "heartbeat stale")
|
||||||
|
}
|
||||||
|
} else if now.After(overdue) {
|
||||||
|
if site.Status != models.StatusLate {
|
||||||
|
e.handleStatusChange(site, string(models.StatusLate), 0, 0, "heartbeat overdue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) checkGroup(_ context.Context, site models.Site) {
|
||||||
|
e.mu.RLock()
|
||||||
|
status := models.StatusUp
|
||||||
|
hasChildren := false
|
||||||
|
for _, child := range e.liveState {
|
||||||
|
if child.ParentID != site.ID || child.Type == "group" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hasChildren = true
|
||||||
|
if child.Paused || e.isInMaintenance(child.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if child.Status == models.StatusDown || child.Status == models.StatusSSLExp {
|
||||||
|
status = models.StatusDown
|
||||||
|
} else if child.Status == models.StatusStale && status != models.StatusDown {
|
||||||
|
status = models.StatusStale
|
||||||
|
} else if child.Status == models.StatusLate && status != models.StatusDown && status != models.StatusStale {
|
||||||
|
status = models.StatusLate
|
||||||
|
} else if child.Status == models.StatusPending && status != models.StatusDown && status != models.StatusStale && status != models.StatusLate {
|
||||||
|
status = models.StatusPending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.mu.RUnlock()
|
||||||
|
|
||||||
|
if !hasChildren {
|
||||||
|
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) {
|
||||||
|
e.enqueueWrite(writeProbeCheck{siteID: siteID, nodeID: nodeID, latencyNs: latencyNs, isUp: isUp})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAggStrategy must be called before Start: the field is read by the probe
|
||||||
|
// aggregation path without synchronization.
|
||||||
|
func (e *Engine) SetAggStrategy(strategy AggregationStrategy) {
|
||||||
|
e.aggStrategy = strategy
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) IngestProbeResult(nodeID string, siteID int, latencyNs int64, isUp bool, errorReason string) {
|
||||||
|
e.mu.RLock()
|
||||||
|
site, exists := e.liveState[siteID]
|
||||||
|
e.mu.RUnlock()
|
||||||
|
if !exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
staleAfter := time.Duration(site.Interval) * time.Second * 3
|
||||||
|
if staleAfter < time.Minute {
|
||||||
|
staleAfter = time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
e.probeResultsMu.Lock()
|
||||||
|
if e.probeResults[siteID] == nil {
|
||||||
|
e.probeResults[siteID] = make(map[string]NodeResult)
|
||||||
|
}
|
||||||
|
e.probeResults[siteID][nodeID] = NodeResult{
|
||||||
|
NodeID: nodeID,
|
||||||
|
IsUp: isUp,
|
||||||
|
LatencyNs: latencyNs,
|
||||||
|
CheckedAt: now,
|
||||||
|
ErrorReason: errorReason,
|
||||||
|
}
|
||||||
|
results := make([]NodeResult, 0, len(e.probeResults[siteID]))
|
||||||
|
for id, r := range e.probeResults[siteID] {
|
||||||
|
if now.Sub(r.CheckedAt) > staleAfter {
|
||||||
|
delete(e.probeResults[siteID], id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, r)
|
||||||
|
}
|
||||||
|
e.probeResultsMu.Unlock()
|
||||||
|
|
||||||
|
aggUp, avgLatency := AggregateStatus(results, e.aggStrategy)
|
||||||
|
|
||||||
|
probeStatus := models.StatusUp
|
||||||
|
if !aggUp {
|
||||||
|
probeStatus = models.StatusDown
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedSite := site
|
||||||
|
updatedSite.Latency = time.Duration(avgLatency)
|
||||||
|
updatedSite.LastCheck = time.Now()
|
||||||
|
e.handleStatusChange(updatedSite, string(probeStatus), 0, time.Duration(avgLatency), errorReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) GetProbeResults(siteID int) map[string]NodeResult {
|
||||||
|
e.probeResultsMu.RLock()
|
||||||
|
defer e.probeResultsMu.RUnlock()
|
||||||
|
src := e.probeResults[siteID]
|
||||||
|
cp := make(map[string]NodeResult, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
cp[k] = v
|
||||||
|
}
|
||||||
|
return cp
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
package monitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Group 2: Heartbeat ---
|
||||||
|
|
||||||
|
func TestRecordHeartbeat_ValidToken(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "push-test", Type: "push", Token: "abc123"},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
if !e.RecordHeartbeat("abc123") {
|
||||||
|
t.Error("expected true for valid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "UP" {
|
||||||
|
t.Errorf("expected UP, got %s", s.Status)
|
||||||
|
}
|
||||||
|
if time.Since(s.LastCheck) > time.Second {
|
||||||
|
t.Error("expected LastCheck to be recent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordHeartbeat_RecoveryFromDown(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "push-test", Type: "push", Token: "abc123", AlertID: 1},
|
||||||
|
SiteState: models.SiteState{Status: "DOWN", FailureCount: 3},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
if !e.RecordHeartbeat("abc123") {
|
||||||
|
t.Error("expected true")
|
||||||
|
}
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "UP" {
|
||||||
|
t.Errorf("expected UP, got %s", s.Status)
|
||||||
|
}
|
||||||
|
if s.FailureCount != 0 {
|
||||||
|
t.Errorf("expected FailureCount 0, got %d", s.FailureCount)
|
||||||
|
}
|
||||||
|
waitAsync()
|
||||||
|
if len(ms.getAlertCallsSnapshot()) == 0 {
|
||||||
|
t.Error("expected recovery alert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordHeartbeat_UnknownToken(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
|
||||||
|
if e.RecordHeartbeat("unknown") {
|
||||||
|
t.Error("expected false for unknown token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordHeartbeat_InactiveEngine(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Type: "push", Token: "abc123"},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
e.SetActive(false)
|
||||||
|
|
||||||
|
if e.RecordHeartbeat("abc123") {
|
||||||
|
t.Error("expected false when inactive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Group 3: Push Deadline ---
|
||||||
|
|
||||||
|
func TestCheckPush_DeadlineMissed(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 10, MaxRetries: 0},
|
||||||
|
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now().Add(-120 * time.Second)},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
e.checkPush(context.Background(), site)
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "DOWN" {
|
||||||
|
t.Errorf("expected DOWN after missed deadline, got %s", s.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckPush_OverdueBecomesLate(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 300},
|
||||||
|
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now().Add(-310 * time.Second)},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
e.checkPush(context.Background(), site)
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "LATE" {
|
||||||
|
t.Errorf("expected LATE when overdue but within grace, got %s", s.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckPush_OverdueBecomesStale(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
// interval=300, grace=150 (300/2), staleMark=overdue+75
|
||||||
|
// at 380s: past staleMark(375) but before graceEnd(450)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 300},
|
||||||
|
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now().Add(-380 * time.Second)},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
e.checkPush(context.Background(), site)
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "STALE" {
|
||||||
|
t.Errorf("expected STALE when past midpoint of grace, got %s", s.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckPush_WithinDeadline(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 60},
|
||||||
|
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now()},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
e.checkPush(context.Background(), site)
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "UP" {
|
||||||
|
t.Errorf("expected UP, got %s", s.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckPush_PendingStaysPending(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 60},
|
||||||
|
SiteState: models.SiteState{Status: "PENDING"},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
e.checkPush(context.Background(), site)
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "PENDING" {
|
||||||
|
t.Errorf("expected PENDING to stay until first heartbeat, got %s", s.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Group 4: Group Checks ---
|
||||||
|
|
||||||
|
func TestCheckGroup_AllChildrenUp(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
group := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
|
||||||
|
SiteState: models.SiteState{Status: "PENDING"},
|
||||||
|
}
|
||||||
|
child1 := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
child2 := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
injectSite(e, group)
|
||||||
|
injectSite(e, child1)
|
||||||
|
injectSite(e, child2)
|
||||||
|
|
||||||
|
e.checkGroup(context.Background(), group)
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "UP" {
|
||||||
|
t.Errorf("expected group UP, got %s", s.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckGroup_OneChildDown(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
group := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
child1 := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
child2 := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1},
|
||||||
|
SiteState: models.SiteState{Status: "DOWN"},
|
||||||
|
}
|
||||||
|
injectSite(e, group)
|
||||||
|
injectSite(e, child1)
|
||||||
|
injectSite(e, child2)
|
||||||
|
|
||||||
|
e.checkGroup(context.Background(), group)
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "DOWN" {
|
||||||
|
t.Errorf("expected group DOWN, got %s", s.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckGroup_PausedChildIgnored(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
group := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
|
||||||
|
}
|
||||||
|
child1 := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
child2 := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1, Paused: true},
|
||||||
|
SiteState: models.SiteState{Status: "DOWN"},
|
||||||
|
}
|
||||||
|
injectSite(e, group)
|
||||||
|
injectSite(e, child1)
|
||||||
|
injectSite(e, child2)
|
||||||
|
|
||||||
|
e.checkGroup(context.Background(), group)
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "UP" {
|
||||||
|
t.Errorf("expected UP (paused child ignored), got %s", s.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckGroup_MaintenanceChildIgnored(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
ms.maintenance[3] = true
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
group := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
|
||||||
|
}
|
||||||
|
child1 := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
child2 := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1},
|
||||||
|
SiteState: models.SiteState{Status: "DOWN"},
|
||||||
|
}
|
||||||
|
injectSite(e, group)
|
||||||
|
injectSite(e, child1)
|
||||||
|
injectSite(e, child2)
|
||||||
|
e.refreshMaintenanceCache(context.Background())
|
||||||
|
|
||||||
|
e.checkGroup(context.Background(), group)
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "UP" {
|
||||||
|
t.Errorf("expected UP (maint child ignored), got %s", s.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckGroup_NoChildren(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
group := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
injectSite(e, group)
|
||||||
|
|
||||||
|
e.checkGroup(context.Background(), group)
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Status != "PENDING" {
|
||||||
|
t.Errorf("expected PENDING for no children, got %s", s.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Groups must not auto-pause when all children are paused — that creates a
|
||||||
|
// one-way trap because monitorRoutine skips paused sites.
|
||||||
|
func TestCheckGroup_AllPausedNoAutoFreeze(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
group := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
child1 := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1, Paused: true},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
child2 := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1, Paused: true},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
injectSite(e, group)
|
||||||
|
injectSite(e, child1)
|
||||||
|
injectSite(e, child2)
|
||||||
|
|
||||||
|
e.checkGroup(context.Background(), group)
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.Paused {
|
||||||
|
t.Error("group must not auto-pause when all children are paused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dead probe results must be expired so they don't poison aggregation.
|
||||||
|
func TestIngestProbeResult_ExpiresStaleProbes(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", Interval: 30},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
e.probeResultsMu.Lock()
|
||||||
|
e.probeResults[1] = map[string]NodeResult{
|
||||||
|
"dead-probe": {
|
||||||
|
NodeID: "dead-probe",
|
||||||
|
IsUp: false,
|
||||||
|
CheckedAt: time.Now().Add(-10 * time.Minute),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
e.probeResultsMu.Unlock()
|
||||||
|
|
||||||
|
e.IngestProbeResult("live-probe", 1, 5000, true, "")
|
||||||
|
|
||||||
|
e.probeResultsMu.RLock()
|
||||||
|
_, deadExists := e.probeResults[1]["dead-probe"]
|
||||||
|
_, liveExists := e.probeResults[1]["live-probe"]
|
||||||
|
e.probeResultsMu.RUnlock()
|
||||||
|
|
||||||
|
if deadExists {
|
||||||
|
t.Error("stale probe result should have been expired")
|
||||||
|
}
|
||||||
|
if !liveExists {
|
||||||
|
t.Error("live probe result should still exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveSite must clean up probeResults.
|
||||||
|
func TestRemoveSite_CleansProbeResults(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http"},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
e.probeResultsMu.Lock()
|
||||||
|
e.probeResults[1] = map[string]NodeResult{
|
||||||
|
"node-a": {NodeID: "node-a", IsUp: true, CheckedAt: time.Now()},
|
||||||
|
}
|
||||||
|
e.probeResultsMu.Unlock()
|
||||||
|
|
||||||
|
e.RemoveSite(1)
|
||||||
|
|
||||||
|
e.probeResultsMu.RLock()
|
||||||
|
defer e.probeResultsMu.RUnlock()
|
||||||
|
if _, exists := e.probeResults[1]; exists {
|
||||||
|
t.Error("probe results should be cleaned up after RemoveSite")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,8 +14,8 @@ type SiteHistory struct {
|
|||||||
UpChecks int
|
UpChecks int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) InitHistory() {
|
func (e *Engine) InitHistory(ctx context.Context) {
|
||||||
all, err := e.db.LoadAllHistory(context.Background(), maxHistoryLen)
|
all, err := e.db.LoadAllHistory(ctx, maxHistoryLen)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.AddLog("Failed to load check history: " + err.Error())
|
e.AddLog("Failed to load check history: " + err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package monitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *Engine) maintenancePruner(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(maintPruneInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
e.pruneMaintenanceWindows(ctx)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
e.pruneMaintenanceWindows(ctx)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) pruneMaintenanceWindows(ctx context.Context) {
|
||||||
|
pruned, err := e.db.PruneExpiredMaintenanceWindows(ctx, e.maintRetention)
|
||||||
|
if err != nil {
|
||||||
|
e.AddLog(fmt.Sprintf("Maintenance prune error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pruned > 0 {
|
||||||
|
e.AddLog(fmt.Sprintf("Pruned %d expired maintenance window(s)", pruned))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) isInMaintenance(monitorID int) bool {
|
||||||
|
e.maintCacheMu.RLock()
|
||||||
|
defer e.maintCacheMu.RUnlock()
|
||||||
|
return e.maintCache[monitorID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) refreshMaintenanceCache(ctx context.Context) {
|
||||||
|
windows, err := e.db.GetActiveMaintenanceWindows(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
directMaint := make(map[int]bool)
|
||||||
|
var globalMaint bool
|
||||||
|
for _, w := range windows {
|
||||||
|
if w.MonitorID == 0 {
|
||||||
|
globalMaint = true
|
||||||
|
} else {
|
||||||
|
directMaint[w.MonitorID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved := make(map[int]bool)
|
||||||
|
e.mu.RLock()
|
||||||
|
for id, site := range e.liveState {
|
||||||
|
if globalMaint || directMaint[id] || (site.ParentID > 0 && directMaint[site.ParentID]) {
|
||||||
|
resolved[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.mu.RUnlock()
|
||||||
|
|
||||||
|
e.maintCacheMu.Lock()
|
||||||
|
e.maintCache = resolved
|
||||||
|
e.maintCacheMu.Unlock()
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package monitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Maintenance cache resolves parent relationships correctly.
|
||||||
|
func TestIsInMaintenance_UsesCache(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
ms.maintenance[10] = true // direct maintenance on group
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
group := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 10, Name: "group", Type: "group"},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
child := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 20, Name: "child", Type: "http", ParentID: 10},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
injectSite(e, group)
|
||||||
|
injectSite(e, child)
|
||||||
|
e.refreshMaintenanceCache(context.Background())
|
||||||
|
|
||||||
|
if !e.isInMaintenance(10) {
|
||||||
|
t.Error("group should be in maintenance (direct)")
|
||||||
|
}
|
||||||
|
if !e.isInMaintenance(20) {
|
||||||
|
t.Error("child should be in maintenance (parent)")
|
||||||
|
}
|
||||||
|
if e.isInMaintenance(99) {
|
||||||
|
t.Error("unknown monitor should not be in maintenance")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global maintenance (monitor_id=0) applies to all monitors.
|
||||||
|
func TestIsInMaintenance_GlobalMaintenance(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
ms.maintenance[0] = true
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http"},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
e.refreshMaintenanceCache(context.Background())
|
||||||
|
|
||||||
|
if !e.isInMaintenance(1) {
|
||||||
|
t.Error("all monitors should be in maintenance during global window")
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-785
@@ -4,14 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand/v2"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/alert"
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||||
)
|
)
|
||||||
@@ -24,17 +22,10 @@ const (
|
|||||||
maintPruneInterval = 15 * time.Minute
|
maintPruneInterval = 15 * time.Minute
|
||||||
defaultMaintRetention = 7 * 24 * time.Hour
|
defaultMaintRetention = 7 * 24 * time.Hour
|
||||||
dbWriteBuffer = 4096
|
dbWriteBuffer = 4096
|
||||||
|
alertSendTimeout = 30 * time.Second
|
||||||
dbPruneInterval = 10 * time.Minute
|
dbPruneInterval = 10 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
type AlertHealth struct {
|
|
||||||
LastSendAt time.Time
|
|
||||||
LastSendOK bool
|
|
||||||
LastError string
|
|
||||||
SendCount int
|
|
||||||
FailCount int
|
|
||||||
}
|
|
||||||
|
|
||||||
type Engine struct {
|
type Engine struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
liveState map[int]models.Site
|
liveState map[int]models.Site
|
||||||
@@ -73,6 +64,7 @@ type Engine struct {
|
|||||||
dbWrites chan dbWrite
|
dbWrites chan dbWrite
|
||||||
writerWG sync.WaitGroup
|
writerWG sync.WaitGroup
|
||||||
checkerWG sync.WaitGroup
|
checkerWG sync.WaitGroup
|
||||||
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
stopOnce sync.Once
|
stopOnce sync.Once
|
||||||
}
|
}
|
||||||
@@ -99,6 +91,7 @@ func newEngine(s store.Store, allowPrivateTargets bool) *Engine {
|
|||||||
allowPrivateTargets: allowPrivateTargets,
|
allowPrivateTargets: allowPrivateTargets,
|
||||||
maintRetention: defaultMaintRetention,
|
maintRetention: defaultMaintRetention,
|
||||||
dbWrites: make(chan dbWrite, dbWriteBuffer),
|
dbWrites: make(chan dbWrite, dbWriteBuffer),
|
||||||
|
ctx: context.Background(),
|
||||||
db: s,
|
db: s,
|
||||||
strictClient: &http.Client{
|
strictClient: &http.Client{
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
@@ -209,6 +202,8 @@ func (e *Engine) dbWriter(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// drainWrites flushes everything still buffered, best-effort, at shutdown.
|
// 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() {
|
func (e *Engine) drainWrites() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -247,8 +242,8 @@ func (e *Engine) Stop() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) InitLogs() {
|
func (e *Engine) InitLogs(ctx context.Context) {
|
||||||
entries, err := e.db.LoadLogs(context.Background(), maxLogEntries)
|
entries, err := e.db.LoadLogs(ctx, maxLogEntries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -260,26 +255,6 @@ func (e *Engine) InitLogs() {
|
|||||||
e.logStore = entries
|
e.logStore = entries
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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())
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.alertHealthMu.Lock()
|
|
||||||
defer e.alertHealthMu.Unlock()
|
|
||||||
for id, r := range records {
|
|
||||||
e.alertHealth[id] = AlertHealth{
|
|
||||||
LastSendAt: r.LastSendAt,
|
|
||||||
LastSendOK: r.LastSendOK,
|
|
||||||
LastError: r.LastError,
|
|
||||||
SendCount: r.SendCount,
|
|
||||||
FailCount: r.FailCount,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) GetLogs() []models.LogEntry {
|
func (e *Engine) GetLogs() []models.LogEntry {
|
||||||
e.logMu.RLock()
|
e.logMu.RLock()
|
||||||
defer e.logMu.RUnlock()
|
defer e.logMu.RUnlock()
|
||||||
@@ -288,128 +263,12 @@ func (e *Engine) GetLogs() []models.LogEntry {
|
|||||||
return logs
|
return logs
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) SetActive(active bool) {
|
|
||||||
e.activeMu.Lock()
|
|
||||||
defer e.activeMu.Unlock()
|
|
||||||
if e.isActive != active {
|
|
||||||
e.isActive = active
|
|
||||||
status := "RESUMED (Active)"
|
|
||||||
if !active {
|
|
||||||
status = "PAUSED (Passive)"
|
|
||||||
}
|
|
||||||
e.AddLog(fmt.Sprintf("Engine %s", status))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) IsActive() bool {
|
|
||||||
e.activeMu.RLock()
|
|
||||||
defer e.activeMu.RUnlock()
|
|
||||||
return e.isActive
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) GetAllSites() []models.Site {
|
|
||||||
e.mu.RLock()
|
|
||||||
defer e.mu.RUnlock()
|
|
||||||
sites := make([]models.Site, 0, len(e.liveState))
|
|
||||||
for _, s := range e.liveState {
|
|
||||||
sites = append(sites, s)
|
|
||||||
}
|
|
||||||
return sites
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) GetLiveState() map[int]models.Site {
|
|
||||||
e.mu.RLock()
|
|
||||||
defer e.mu.RUnlock()
|
|
||||||
cp := make(map[int]models.Site, len(e.liveState))
|
|
||||||
for k, v := range e.liveState {
|
|
||||||
cp[k] = v
|
|
||||||
}
|
|
||||||
return cp
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) RecordHeartbeat(token string) bool {
|
|
||||||
if !e.IsActive() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
e.mu.RLock()
|
|
||||||
targetID, ok := e.tokenIndex[token]
|
|
||||||
e.mu.RUnlock()
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
prevStatus models.Status
|
|
||||||
name string
|
|
||||||
alertID int
|
|
||||||
downSince time.Time
|
|
||||||
)
|
|
||||||
_, exists := e.applyState(targetID, func(s *models.Site) {
|
|
||||||
prevStatus = s.Status
|
|
||||||
name = s.Name
|
|
||||||
alertID = s.AlertID
|
|
||||||
downSince = s.StatusChangedAt // captured before mutation = when it went down
|
|
||||||
|
|
||||||
s.LastCheck = time.Now()
|
|
||||||
s.Status = models.StatusUp
|
|
||||||
s.FailureCount = 0
|
|
||||||
s.Latency = 0
|
|
||||||
s.LastError = ""
|
|
||||||
s.LastSuccessAt = time.Now()
|
|
||||||
if prevStatus != models.StatusUp {
|
|
||||||
s.StatusChangedAt = time.Now()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if !exists {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
switch prevStatus {
|
|
||||||
case models.StatusPending:
|
|
||||||
e.AddLog(fmt.Sprintf("Push Monitor '%s' received first heartbeat", name))
|
|
||||||
case models.StatusLate:
|
|
||||||
e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was late)", name))
|
|
||||||
case models.StatusStale:
|
|
||||||
e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was stale)", name))
|
|
||||||
case models.StatusDown:
|
|
||||||
downDur := ""
|
|
||||||
if !downSince.IsZero() {
|
|
||||||
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(downSince)))
|
|
||||||
}
|
|
||||||
e.AddLog(fmt.Sprintf("Push Monitor '%s' recovered%s", name, downDur))
|
|
||||||
go e.triggerAlert(alertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.%s", name, downDur))
|
|
||||||
}
|
|
||||||
|
|
||||||
e.recordCheck(targetID, 0, true)
|
|
||||||
|
|
||||||
if prevStatus != models.StatusUp && prevStatus != models.StatusPending {
|
|
||||||
e.enqueueWrite(writeStateChange{siteID: targetID, fromStatus: string(prevStatus), toStatus: string(models.StatusUp)})
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) addToTokenIndex(site models.Site) {
|
|
||||||
if site.Type == "push" && site.Token != "" {
|
|
||||||
e.tokenIndex[site.Token] = site.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) removeFromTokenIndex(id int) {
|
|
||||||
for token, sid := range e.tokenIndex {
|
|
||||||
if sid == id {
|
|
||||||
delete(e.tokenIndex, token)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) Start(ctx context.Context) {
|
func (e *Engine) Start(ctx context.Context) {
|
||||||
// e.cancel is invoked by Stop() to drain and halt the writer; gosec can't
|
// e.cancel is invoked by Stop() to drain and halt the writer; gosec can't
|
||||||
// trace the cross-method call, and cancelling the parent reaps this child
|
// trace the cross-method call, and cancelling the parent reaps this child
|
||||||
// regardless, so the leak it warns about can't occur.
|
// regardless, so the leak it warns about can't occur.
|
||||||
ctx, e.cancel = context.WithCancel(ctx) //nolint:gosec // cancel is called in Stop()
|
ctx, e.cancel = context.WithCancel(ctx) //nolint:gosec // cancel is called in Stop()
|
||||||
|
e.ctx = ctx
|
||||||
|
|
||||||
e.writerWG.Add(1)
|
e.writerWG.Add(1)
|
||||||
go e.dbWriter(ctx)
|
go e.dbWriter(ctx)
|
||||||
@@ -424,8 +283,6 @@ func (e *Engine) Start(ctx context.Context) {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
e.refreshMaintenanceCache(ctx)
|
|
||||||
|
|
||||||
configs, err := e.db.GetSites(ctx)
|
configs, err := e.db.GetSites(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.AddLog(fmt.Sprintf("Failed to load sites: %v", err))
|
e.AddLog(fmt.Sprintf("Failed to load sites: %v", err))
|
||||||
@@ -468,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()
|
e.mu.RLock()
|
||||||
var vanished []int
|
var vanished []int
|
||||||
for id := range e.liveState {
|
for id := range e.liveState {
|
||||||
@@ -495,637 +356,3 @@ func (e *Engine) Start(ctx context.Context) {
|
|||||||
e.maintenancePruner(ctx)
|
e.maintenancePruner(ctx)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) maintenancePruner(ctx context.Context) {
|
|
||||||
ticker := time.NewTicker(maintPruneInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
e.pruneMaintenanceWindows(ctx)
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
e.pruneMaintenanceWindows(ctx)
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) pruneMaintenanceWindows(ctx context.Context) {
|
|
||||||
pruned, err := e.db.PruneExpiredMaintenanceWindows(ctx, e.maintRetention)
|
|
||||||
if err != nil {
|
|
||||||
e.AddLog(fmt.Sprintf("Maintenance prune error: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if pruned > 0 {
|
|
||||||
e.AddLog(fmt.Sprintf("Pruned %d expired maintenance window(s)", pruned))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) UpdateSiteConfig(cfg models.SiteConfig) {
|
|
||||||
e.mu.Lock()
|
|
||||||
if existing, ok := e.liveState[cfg.ID]; ok {
|
|
||||||
e.removeFromTokenIndex(cfg.ID)
|
|
||||||
existing.SiteConfig = cfg
|
|
||||||
e.liveState[cfg.ID] = existing
|
|
||||||
e.addToTokenIndex(existing)
|
|
||||||
}
|
|
||||||
e.mu.Unlock()
|
|
||||||
|
|
||||||
e.signalRecheck(cfg.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) getRecheckChan(id int) chan struct{} {
|
|
||||||
e.recheckMu.Lock()
|
|
||||||
defer e.recheckMu.Unlock()
|
|
||||||
ch, ok := e.recheck[id]
|
|
||||||
if !ok {
|
|
||||||
ch = make(chan struct{}, 1)
|
|
||||||
e.recheck[id] = ch
|
|
||||||
}
|
|
||||||
return ch
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) signalRecheck(id int) {
|
|
||||||
ch := e.getRecheckChan(id)
|
|
||||||
select {
|
|
||||||
case ch <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) RemoveSite(id int) {
|
|
||||||
e.mu.Lock()
|
|
||||||
e.removeFromTokenIndex(id)
|
|
||||||
delete(e.liveState, id)
|
|
||||||
e.mu.Unlock()
|
|
||||||
e.removeHistory(id)
|
|
||||||
|
|
||||||
e.probeResultsMu.Lock()
|
|
||||||
delete(e.probeResults, id)
|
|
||||||
e.probeResultsMu.Unlock()
|
|
||||||
|
|
||||||
e.recheckMu.Lock()
|
|
||||||
delete(e.recheck, id)
|
|
||||||
e.recheckMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) ToggleSitePause(id int) bool {
|
|
||||||
var (
|
|
||||||
paused bool
|
|
||||||
name string
|
|
||||||
)
|
|
||||||
_, ok := e.applyState(id, func(s *models.Site) {
|
|
||||||
s.Paused = !s.Paused
|
|
||||||
paused = s.Paused
|
|
||||||
name = s.Name
|
|
||||||
})
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if paused {
|
|
||||||
e.AddLog(fmt.Sprintf("Monitor '%s' paused", name))
|
|
||||||
} else {
|
|
||||||
e.AddLog(fmt.Sprintf("Monitor '%s' resumed", name))
|
|
||||||
}
|
|
||||||
return paused
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) monitorRoutine(ctx context.Context, id int) {
|
|
||||||
recheckCh := e.getRecheckChan(id)
|
|
||||||
|
|
||||||
// Stagger initial check to avoid thundering herd on startup
|
|
||||||
stagger := time.Duration(rand.IntN(3000)) * time.Millisecond //nolint:gosec // non-security jitter
|
|
||||||
select {
|
|
||||||
case <-time.After(stagger):
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
e.checkByID(ctx, id)
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
if !e.IsActive() {
|
|
||||||
select {
|
|
||||||
case <-time.After(pollInterval):
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-recheckCh:
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
e.mu.RLock()
|
|
||||||
site, exists := e.liveState[id]
|
|
||||||
e.mu.RUnlock()
|
|
||||||
if !exists {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if site.Paused {
|
|
||||||
select {
|
|
||||||
case <-time.After(pollInterval):
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-recheckCh:
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
interval := site.Interval
|
|
||||||
if interval < minCheckInterval {
|
|
||||||
interval = minCheckInterval
|
|
||||||
}
|
|
||||||
jitter := time.Duration(rand.IntN(interval*100)) * time.Millisecond //nolint:gosec // non-security jitter
|
|
||||||
select {
|
|
||||||
case <-time.After(time.Duration(interval)*time.Second + jitter):
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-recheckCh:
|
|
||||||
}
|
|
||||||
e.checkByID(ctx, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyState atomically reads, mutates, and writes back the live entry for id.
|
|
||||||
// The mutator runs under the engine write lock and receives a pointer to the
|
|
||||||
// CURRENT live state, so concurrent config edits, pauses, and heartbeats are
|
|
||||||
// never clobbered by a stale snapshot. The mutator must only touch runtime /
|
|
||||||
// check-result fields — config fields (Name/URL/Type/Token/Interval/AlertID/…)
|
|
||||||
// are owned by UpdateSiteConfig and must not be written here. Returns the
|
|
||||||
// post-mutation copy and whether the site still exists.
|
|
||||||
func (e *Engine) applyState(id int, mutate func(s *models.Site)) (models.Site, bool) {
|
|
||||||
e.mu.Lock()
|
|
||||||
defer e.mu.Unlock()
|
|
||||||
cur, ok := e.liveState[id]
|
|
||||||
if !ok {
|
|
||||||
return models.Site{}, false
|
|
||||||
}
|
|
||||||
mutate(&cur)
|
|
||||||
e.liveState[id] = cur
|
|
||||||
return cur, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) checkByID(ctx context.Context, id int) {
|
|
||||||
if !e.IsActive() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
e.mu.RLock()
|
|
||||||
site, exists := e.liveState[id]
|
|
||||||
e.mu.RUnlock()
|
|
||||||
if !exists || site.Paused {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch site.Type {
|
|
||||||
case "push":
|
|
||||||
e.checkPush(ctx, site)
|
|
||||||
case "group":
|
|
||||||
e.checkGroup(ctx, site)
|
|
||||||
default:
|
|
||||||
result := RunCheck(ctx, site.SiteConfig, e.strictClient, e.insecureClient, e.insecureSkipVerify, e.allowPrivateTargets)
|
|
||||||
updatedSite := site
|
|
||||||
updatedSite.HasSSL = result.HasSSL
|
|
||||||
updatedSite.CertExpiry = result.CertExpiry
|
|
||||||
updatedSite.Latency = time.Duration(result.LatencyNs)
|
|
||||||
updatedSite.LastCheck = time.Now()
|
|
||||||
e.handleStatusChange(updatedSite, result.Status, result.StatusCode, time.Duration(result.LatencyNs), result.ErrorReason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) checkPush(_ context.Context, site models.Site) {
|
|
||||||
if site.Status == models.StatusPending {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
interval := time.Duration(site.Interval) * time.Second
|
|
||||||
grace := interval / 2
|
|
||||||
if grace < minPushGrace {
|
|
||||||
grace = minPushGrace
|
|
||||||
}
|
|
||||||
|
|
||||||
overdue := site.LastCheck.Add(interval)
|
|
||||||
staleMark := overdue.Add(grace / 2)
|
|
||||||
graceEnd := overdue.Add(grace)
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
if now.After(graceEnd) {
|
|
||||||
if site.Status != models.StatusDown {
|
|
||||||
e.handleStatusChange(site, string(models.StatusDown), 0, 0, "heartbeat missed")
|
|
||||||
}
|
|
||||||
} else if now.After(staleMark) {
|
|
||||||
if site.Status != models.StatusStale {
|
|
||||||
e.handleStatusChange(site, string(models.StatusStale), 0, 0, "heartbeat stale")
|
|
||||||
}
|
|
||||||
} else if now.After(overdue) {
|
|
||||||
if site.Status != models.StatusLate {
|
|
||||||
e.handleStatusChange(site, string(models.StatusLate), 0, 0, "heartbeat overdue")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleStatusChange folds a check result into the live state. snap is the
|
|
||||||
// stale snapshot the check ran against; the actual mutation is applied onto the
|
|
||||||
// CURRENT live entry via applyState, so a concurrent pause / config edit /
|
|
||||||
// heartbeat is never reverted by this write. Logs and alerts are emitted after
|
|
||||||
// the lock is released, off the critical section.
|
|
||||||
func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int, latency time.Duration, errorReason string) {
|
|
||||||
if !e.IsActive() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
inMaint := e.isInMaintenance(snap.ID)
|
|
||||||
status := models.Status(rawStatus)
|
|
||||||
|
|
||||||
var (
|
|
||||||
prev, next models.Status
|
|
||||||
name, typ string
|
|
||||||
alertID int
|
|
||||||
failCount, maxRetries int
|
|
||||||
confirmedDown bool
|
|
||||||
failedCheck bool
|
|
||||||
downSince time.Time
|
|
||||||
sslWarnFire bool
|
|
||||||
sslDays int
|
|
||||||
skipped bool
|
|
||||||
changed bool
|
|
||||||
)
|
|
||||||
|
|
||||||
_, exists := e.applyState(snap.ID, func(s *models.Site) {
|
|
||||||
// A non-UP result computed from a stale snapshot must not override a
|
|
||||||
// heartbeat (or newer check) that landed while we were evaluating.
|
|
||||||
if status != models.StatusUp && s.LastCheck.After(snap.LastCheck) {
|
|
||||||
skipped = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
prev = s.Status
|
|
||||||
name = s.Name
|
|
||||||
typ = s.Type
|
|
||||||
alertID = s.AlertID
|
|
||||||
maxRetries = s.MaxRetries
|
|
||||||
downSince = s.StatusChangedAt
|
|
||||||
|
|
||||||
// Fresh check results (measured by the run against snap).
|
|
||||||
s.StatusCode = code
|
|
||||||
s.Latency = snap.Latency
|
|
||||||
s.LastCheck = snap.LastCheck
|
|
||||||
s.HasSSL = snap.HasSSL
|
|
||||||
s.CertExpiry = snap.CertExpiry
|
|
||||||
s.LastError = errorReason
|
|
||||||
if status == models.StatusUp {
|
|
||||||
s.LastSuccessAt = time.Now()
|
|
||||||
s.LastError = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status + failure-count transition, based on the CURRENT live status.
|
|
||||||
if status == models.StatusUp {
|
|
||||||
s.FailureCount = 0
|
|
||||||
s.Status = models.StatusUp
|
|
||||||
} else {
|
|
||||||
if s.FailureCount <= s.MaxRetries {
|
|
||||||
s.FailureCount++
|
|
||||||
}
|
|
||||||
if s.FailureCount > s.MaxRetries {
|
|
||||||
if s.Status != status {
|
|
||||||
confirmedDown = true
|
|
||||||
}
|
|
||||||
s.Status = status
|
|
||||||
s.FailureCount = s.MaxRetries + 1
|
|
||||||
} else {
|
|
||||||
failedCheck = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
failCount = s.FailureCount
|
|
||||||
|
|
||||||
if s.Status != prev && prev != models.StatusPending {
|
|
||||||
s.StatusChangedAt = time.Now()
|
|
||||||
} else if s.StatusChangedAt.IsZero() && s.Status != models.StatusPending {
|
|
||||||
s.StatusChangedAt = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSL expiry warning (fresh HasSSL/CertExpiry + config threshold).
|
|
||||||
if typ == "http" && s.CheckSSL && s.HasSSL {
|
|
||||||
days := int(time.Until(s.CertExpiry).Hours() / 24)
|
|
||||||
if days <= s.ExpiryThreshold && !s.SentSSLWarning && status != models.StatusSSLExp {
|
|
||||||
sslWarnFire = true
|
|
||||||
sslDays = days
|
|
||||||
s.SentSSLWarning = true
|
|
||||||
} else if days > s.ExpiryThreshold {
|
|
||||||
s.SentSSLWarning = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
next = s.Status
|
|
||||||
changed = next != prev
|
|
||||||
})
|
|
||||||
|
|
||||||
if !exists || skipped {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
e.recordCheck(snap.ID, latency, status == models.StatusUp)
|
|
||||||
|
|
||||||
if confirmedDown {
|
|
||||||
if errorReason != "" {
|
|
||||||
e.AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN: %s", name, errorReason))
|
|
||||||
} else {
|
|
||||||
e.AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN", name))
|
|
||||||
}
|
|
||||||
} else if failedCheck {
|
|
||||||
e.AddLog(fmt.Sprintf("Monitor '%s' failed check %d/%d", name, failCount, maxRetries))
|
|
||||||
}
|
|
||||||
|
|
||||||
if changed && prev != models.StatusPending {
|
|
||||||
e.enqueueWrite(writeStateChange{siteID: snap.ID, fromStatus: string(prev), toStatus: string(next), reason: errorReason})
|
|
||||||
}
|
|
||||||
|
|
||||||
if sslWarnFire {
|
|
||||||
if !inMaint {
|
|
||||||
e.triggerAlert(alertID, "SSL WARNING", fmt.Sprintf("SSL for '%s' expires in %d days", name, sslDays))
|
|
||||||
} else {
|
|
||||||
e.AddLog(fmt.Sprintf("SSL warning for '%s' suppressed (maintenance)", name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if prev == models.StatusUp && next == models.StatusLate {
|
|
||||||
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat overdue", name))
|
|
||||||
}
|
|
||||||
|
|
||||||
if !prev.IsBroken() && next.IsBroken() && next != models.StatusPending {
|
|
||||||
if inMaint {
|
|
||||||
e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed — maintenance)", name))
|
|
||||||
} else {
|
|
||||||
msg := fmt.Sprintf("Monitor '%s' is DOWN (%s)", name, rawStatus)
|
|
||||||
if errorReason != "" {
|
|
||||||
msg = fmt.Sprintf("Monitor '%s' is DOWN: %s", name, errorReason)
|
|
||||||
}
|
|
||||||
if typ == "push" {
|
|
||||||
msg = fmt.Sprintf("Push Monitor '%s' missed heartbeat.", name)
|
|
||||||
}
|
|
||||||
e.triggerAlert(alertID, "🚨 ALERT", msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if prev.IsBroken() && next == models.StatusUp {
|
|
||||||
downDur := ""
|
|
||||||
if !downSince.IsZero() {
|
|
||||||
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(downSince)))
|
|
||||||
}
|
|
||||||
e.AddLog(fmt.Sprintf("Monitor '%s' recovered%s", name, downDur))
|
|
||||||
if !inMaint {
|
|
||||||
e.triggerAlert(alertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP%s", name, downDur))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if prev == models.StatusLate && next == models.StatusUp && !prev.IsBroken() {
|
|
||||||
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat arrived (was late)", name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) triggerAlert(alertID int, title, message string) {
|
|
||||||
if alertID <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cfg, err := e.db.GetAlert(context.Background(), alertID)
|
|
||||||
if err != nil {
|
|
||||||
e.AddLog(fmt.Sprintf("Failed to load alert config %d: %v", alertID, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
provider := alert.GetProvider(cfg)
|
|
||||||
if provider != nil {
|
|
||||||
go func() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := provider.Send(ctx, title, message); err != nil {
|
|
||||||
e.AddLog(fmt.Sprintf("Alert send failed (%s): %v", cfg.Name, err))
|
|
||||||
e.recordAlertResult(alertID, false, err.Error())
|
|
||||||
} else {
|
|
||||||
e.recordAlertResult(alertID, true, "")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) recordAlertResult(alertID int, ok bool, errMsg string) {
|
|
||||||
e.alertHealthMu.Lock()
|
|
||||||
defer e.alertHealthMu.Unlock()
|
|
||||||
h := e.alertHealth[alertID]
|
|
||||||
h.LastSendAt = time.Now()
|
|
||||||
h.LastSendOK = ok
|
|
||||||
h.SendCount++
|
|
||||||
if ok {
|
|
||||||
h.LastError = ""
|
|
||||||
} else {
|
|
||||||
h.LastError = errMsg
|
|
||||||
h.FailCount++
|
|
||||||
}
|
|
||||||
e.alertHealth[alertID] = h
|
|
||||||
|
|
||||||
// Persist so health survives restarts; DB IO off the alert path.
|
|
||||||
e.enqueueWrite(writeAlertHealth{rec: models.AlertHealthRecord{
|
|
||||||
AlertID: alertID,
|
|
||||||
LastSendAt: h.LastSendAt,
|
|
||||||
LastSendOK: h.LastSendOK,
|
|
||||||
LastError: h.LastError,
|
|
||||||
SendCount: h.SendCount,
|
|
||||||
FailCount: h.FailCount,
|
|
||||||
}})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) GetAlertHealth(alertID int) AlertHealth {
|
|
||||||
e.alertHealthMu.RLock()
|
|
||||||
defer e.alertHealthMu.RUnlock()
|
|
||||||
return e.alertHealth[alertID]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) TestAlert(alertID int) error {
|
|
||||||
cfg, err := e.db.GetAlert(context.Background(), alertID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to load alert: %w", err)
|
|
||||||
}
|
|
||||||
provider := alert.GetProvider(cfg)
|
|
||||||
if provider == nil {
|
|
||||||
return fmt.Errorf("no provider for type %q", cfg.Type)
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
err = provider.Send(ctx, "🧪 Test Alert", fmt.Sprintf("Test notification from uptop for channel '%s'.", cfg.Name))
|
|
||||||
if err != nil {
|
|
||||||
e.recordAlertResult(alertID, false, err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
e.recordAlertResult(alertID, true, "")
|
|
||||||
e.AddLog(fmt.Sprintf("Test alert sent to '%s'", cfg.Name))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) isInMaintenance(monitorID int) bool {
|
|
||||||
e.maintCacheMu.RLock()
|
|
||||||
defer e.maintCacheMu.RUnlock()
|
|
||||||
return e.maintCache[monitorID]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) refreshMaintenanceCache(ctx context.Context) {
|
|
||||||
windows, err := e.db.GetActiveMaintenanceWindows(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
directMaint := make(map[int]bool)
|
|
||||||
var globalMaint bool
|
|
||||||
for _, w := range windows {
|
|
||||||
if w.MonitorID == 0 {
|
|
||||||
globalMaint = true
|
|
||||||
} else {
|
|
||||||
directMaint[w.MonitorID] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resolved := make(map[int]bool)
|
|
||||||
e.mu.RLock()
|
|
||||||
for id, site := range e.liveState {
|
|
||||||
if globalMaint || directMaint[id] || (site.ParentID > 0 && directMaint[site.ParentID]) {
|
|
||||||
resolved[id] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
e.mu.RUnlock()
|
|
||||||
|
|
||||||
e.maintCacheMu.Lock()
|
|
||||||
e.maintCache = resolved
|
|
||||||
e.maintCacheMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) GetDisplayStatus(site models.Site) string {
|
|
||||||
if site.Paused {
|
|
||||||
return "PAUSED"
|
|
||||||
}
|
|
||||||
if e.isInMaintenance(site.ID) {
|
|
||||||
return "MAINT"
|
|
||||||
}
|
|
||||||
return string(site.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) checkGroup(_ context.Context, site models.Site) {
|
|
||||||
e.mu.RLock()
|
|
||||||
status := models.StatusUp
|
|
||||||
hasChildren := false
|
|
||||||
for _, child := range e.liveState {
|
|
||||||
if child.ParentID != site.ID || child.Type == "group" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
hasChildren = true
|
|
||||||
if child.Paused || e.isInMaintenance(child.ID) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if child.Status == models.StatusDown || child.Status == models.StatusSSLExp {
|
|
||||||
status = models.StatusDown
|
|
||||||
} else if child.Status == models.StatusStale && status != models.StatusDown {
|
|
||||||
status = models.StatusStale
|
|
||||||
} else if child.Status == models.StatusLate && status != models.StatusDown && status != models.StatusStale {
|
|
||||||
status = models.StatusLate
|
|
||||||
} else if child.Status == models.StatusPending && status != models.StatusDown && status != models.StatusStale && status != models.StatusLate {
|
|
||||||
status = models.StatusPending
|
|
||||||
}
|
|
||||||
}
|
|
||||||
e.mu.RUnlock()
|
|
||||||
|
|
||||||
if !hasChildren {
|
|
||||||
status = models.StatusPending
|
|
||||||
}
|
|
||||||
|
|
||||||
e.applyState(site.ID, func(s *models.Site) {
|
|
||||||
s.Status = status
|
|
||||||
})
|
|
||||||
e.recordCheck(site.ID, 0, !status.IsBroken())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) EnqueueProbeCheck(siteID int, nodeID string, latencyNs int64, isUp bool) {
|
|
||||||
e.enqueueWrite(writeProbeCheck{siteID: siteID, nodeID: nodeID, latencyNs: latencyNs, isUp: isUp})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetAggStrategy must be called before Start: the field is read by the probe
|
|
||||||
// aggregation path without synchronization.
|
|
||||||
func (e *Engine) SetAggStrategy(strategy AggregationStrategy) {
|
|
||||||
e.aggStrategy = strategy
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) IngestProbeResult(nodeID string, siteID int, latencyNs int64, isUp bool, errorReason string) {
|
|
||||||
e.mu.RLock()
|
|
||||||
site, exists := e.liveState[siteID]
|
|
||||||
e.mu.RUnlock()
|
|
||||||
if !exists {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
staleAfter := time.Duration(site.Interval) * time.Second * 3
|
|
||||||
if staleAfter < time.Minute {
|
|
||||||
staleAfter = time.Minute
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
e.probeResultsMu.Lock()
|
|
||||||
if e.probeResults[siteID] == nil {
|
|
||||||
e.probeResults[siteID] = make(map[string]NodeResult)
|
|
||||||
}
|
|
||||||
e.probeResults[siteID][nodeID] = NodeResult{
|
|
||||||
NodeID: nodeID,
|
|
||||||
IsUp: isUp,
|
|
||||||
LatencyNs: latencyNs,
|
|
||||||
CheckedAt: now,
|
|
||||||
ErrorReason: errorReason,
|
|
||||||
}
|
|
||||||
results := make([]NodeResult, 0, len(e.probeResults[siteID]))
|
|
||||||
for id, r := range e.probeResults[siteID] {
|
|
||||||
if now.Sub(r.CheckedAt) > staleAfter {
|
|
||||||
delete(e.probeResults[siteID], id)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
results = append(results, r)
|
|
||||||
}
|
|
||||||
e.probeResultsMu.Unlock()
|
|
||||||
|
|
||||||
aggUp, avgLatency := AggregateStatus(results, e.aggStrategy)
|
|
||||||
|
|
||||||
probeStatus := models.StatusUp
|
|
||||||
if !aggUp {
|
|
||||||
probeStatus = models.StatusDown
|
|
||||||
}
|
|
||||||
|
|
||||||
updatedSite := site
|
|
||||||
updatedSite.Latency = time.Duration(avgLatency)
|
|
||||||
updatedSite.LastCheck = time.Now()
|
|
||||||
e.handleStatusChange(updatedSite, string(probeStatus), 0, time.Duration(avgLatency), errorReason)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) GetProbeResults(siteID int) map[string]NodeResult {
|
|
||||||
e.probeResultsMu.RLock()
|
|
||||||
defer e.probeResultsMu.RUnlock()
|
|
||||||
src := e.probeResults[siteID]
|
|
||||||
cp := make(map[string]NodeResult, len(src))
|
|
||||||
for k, v := range src {
|
|
||||||
cp[k] = v
|
|
||||||
}
|
|
||||||
return cp
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) GetStateChanges(siteID int, limit int) []models.StateChange {
|
|
||||||
changes, err := e.db.GetStateChanges(context.Background(), siteID, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return changes
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) GetStateChangesSince(siteID int, since time.Time) []models.StateChange {
|
|
||||||
changes, err := e.db.GetStateChangesSince(context.Background(), siteID, since)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return changes
|
|
||||||
}
|
|
||||||
|
|||||||
+111
-1011
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const dialTimeout = 10 * time.Second
|
||||||
|
|
||||||
var privateRanges []*net.IPNet
|
var privateRanges []*net.IPNet
|
||||||
|
|
||||||
func init() {
|
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 {
|
for _, ip := range ips {
|
||||||
target := net.JoinHostPort(ip.IP.String(), port)
|
target := net.JoinHostPort(ip.IP.String(), port)
|
||||||
conn, err := dialer.DialContext(ctx, network, target)
|
conn, err := dialer.DialContext(ctx, network, target)
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package monitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *Engine) SetActive(active bool) {
|
||||||
|
e.activeMu.Lock()
|
||||||
|
defer e.activeMu.Unlock()
|
||||||
|
if e.isActive != active {
|
||||||
|
e.isActive = active
|
||||||
|
status := "RESUMED (Active)"
|
||||||
|
if !active {
|
||||||
|
status = "PAUSED (Passive)"
|
||||||
|
}
|
||||||
|
e.AddLog(fmt.Sprintf("Engine %s", status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) IsActive() bool {
|
||||||
|
e.activeMu.RLock()
|
||||||
|
defer e.activeMu.RUnlock()
|
||||||
|
return e.isActive
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) GetAllSites() []models.Site {
|
||||||
|
e.mu.RLock()
|
||||||
|
defer e.mu.RUnlock()
|
||||||
|
sites := make([]models.Site, 0, len(e.liveState))
|
||||||
|
for _, s := range e.liveState {
|
||||||
|
sites = append(sites, s)
|
||||||
|
}
|
||||||
|
return sites
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) GetLiveState() map[int]models.Site {
|
||||||
|
e.mu.RLock()
|
||||||
|
defer e.mu.RUnlock()
|
||||||
|
cp := make(map[int]models.Site, len(e.liveState))
|
||||||
|
for k, v := range e.liveState {
|
||||||
|
cp[k] = v
|
||||||
|
}
|
||||||
|
return cp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) addToTokenIndex(site models.Site) {
|
||||||
|
if site.Type == "push" && site.Token != "" {
|
||||||
|
e.tokenIndex[site.Token] = site.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) removeFromTokenIndex(id int) {
|
||||||
|
for token, sid := range e.tokenIndex {
|
||||||
|
if sid == id {
|
||||||
|
delete(e.tokenIndex, token)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) UpdateSiteConfig(cfg models.SiteConfig) {
|
||||||
|
e.mu.Lock()
|
||||||
|
if existing, ok := e.liveState[cfg.ID]; ok {
|
||||||
|
e.removeFromTokenIndex(cfg.ID)
|
||||||
|
existing.SiteConfig = cfg
|
||||||
|
e.liveState[cfg.ID] = existing
|
||||||
|
e.addToTokenIndex(existing)
|
||||||
|
}
|
||||||
|
e.mu.Unlock()
|
||||||
|
|
||||||
|
e.signalRecheck(cfg.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) RemoveSite(id int) {
|
||||||
|
e.mu.Lock()
|
||||||
|
e.removeFromTokenIndex(id)
|
||||||
|
delete(e.liveState, id)
|
||||||
|
e.mu.Unlock()
|
||||||
|
e.removeHistory(id)
|
||||||
|
|
||||||
|
e.probeResultsMu.Lock()
|
||||||
|
delete(e.probeResults, id)
|
||||||
|
e.probeResultsMu.Unlock()
|
||||||
|
|
||||||
|
e.recheckMu.Lock()
|
||||||
|
delete(e.recheck, id)
|
||||||
|
e.recheckMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) ToggleSitePause(id int) bool {
|
||||||
|
var (
|
||||||
|
paused bool
|
||||||
|
name string
|
||||||
|
)
|
||||||
|
_, ok := e.applyState(id, func(s *models.Site) {
|
||||||
|
s.Paused = !s.Paused
|
||||||
|
paused = s.Paused
|
||||||
|
name = s.Name
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if paused {
|
||||||
|
e.AddLog(fmt.Sprintf("Monitor '%s' paused", name))
|
||||||
|
} else {
|
||||||
|
e.AddLog(fmt.Sprintf("Monitor '%s' resumed", name))
|
||||||
|
}
|
||||||
|
return paused
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyState atomically reads, mutates, and writes back the live entry for id.
|
||||||
|
// The mutator runs under the engine write lock and receives a pointer to the
|
||||||
|
// CURRENT live state, so concurrent config edits, pauses, and heartbeats are
|
||||||
|
// never clobbered by a stale snapshot. The mutator must only touch runtime /
|
||||||
|
// check-result fields — config fields (Name/URL/Type/Token/Interval/AlertID/…)
|
||||||
|
// are owned by UpdateSiteConfig and must not be written here. Returns the
|
||||||
|
// post-mutation copy and whether the site still exists.
|
||||||
|
func (e *Engine) applyState(id int, mutate func(s *models.Site)) (models.Site, bool) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
cur, ok := e.liveState[id]
|
||||||
|
if !ok {
|
||||||
|
return models.Site{}, false
|
||||||
|
}
|
||||||
|
mutate(&cur)
|
||||||
|
e.liveState[id] = cur
|
||||||
|
return cur, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) GetDisplayStatus(site models.Site) string {
|
||||||
|
if site.Paused {
|
||||||
|
return "PAUSED"
|
||||||
|
}
|
||||||
|
if e.isInMaintenance(site.ID) {
|
||||||
|
return "MAINT"
|
||||||
|
}
|
||||||
|
return string(site.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) GetStateChanges(ctx context.Context, siteID int, limit int) []models.StateChange {
|
||||||
|
changes, err := e.db.GetStateChanges(ctx, siteID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return changes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) GetStateChangesSince(ctx context.Context, siteID int, since time.Time) []models.StateChange {
|
||||||
|
changes, err := e.db.GetStateChangesSince(ctx, siteID, since)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return changes
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package monitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateSiteConfig_PreservesRuntime(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test", URL: "http://old.com"},
|
||||||
|
SiteState: models.SiteState{Status: "DOWN", FailureCount: 3, Latency: 100 * time.Millisecond},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
updated := models.SiteConfig{ID: 1, Name: "test", URL: "http://new.com", Interval: 60}
|
||||||
|
e.UpdateSiteConfig(updated)
|
||||||
|
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if s.URL != "http://new.com" {
|
||||||
|
t.Errorf("expected URL updated, got %s", s.URL)
|
||||||
|
}
|
||||||
|
if s.Status != "DOWN" {
|
||||||
|
t.Errorf("expected Status preserved, got %s", s.Status)
|
||||||
|
}
|
||||||
|
if s.FailureCount != 3 {
|
||||||
|
t.Errorf("expected FailureCount preserved, got %d", s.FailureCount)
|
||||||
|
}
|
||||||
|
if s.Latency != 100*time.Millisecond {
|
||||||
|
t.Errorf("expected Latency preserved, got %v", s.Latency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveSite_CleansUp(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "push", Token: "tok1"},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
e.recordCheck(1, 5*time.Millisecond, true)
|
||||||
|
|
||||||
|
e.RemoveSite(1)
|
||||||
|
|
||||||
|
if _, ok := getSite(e, 1); ok {
|
||||||
|
t.Error("expected site removed from liveState")
|
||||||
|
}
|
||||||
|
if e.RecordHeartbeat("tok1") {
|
||||||
|
t.Error("expected token removed from index")
|
||||||
|
}
|
||||||
|
if _, ok := e.GetHistory(1); ok {
|
||||||
|
t.Error("expected history removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToggleSitePause(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
site := models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "test"},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
}
|
||||||
|
injectSite(e, site)
|
||||||
|
|
||||||
|
paused := e.ToggleSitePause(1)
|
||||||
|
if !paused {
|
||||||
|
t.Error("expected paused=true after first toggle")
|
||||||
|
}
|
||||||
|
s, _ := getSite(e, 1)
|
||||||
|
if !s.Paused {
|
||||||
|
t.Error("expected Paused=true in state")
|
||||||
|
}
|
||||||
|
|
||||||
|
paused = e.ToggleSitePause(1)
|
||||||
|
if paused {
|
||||||
|
t.Error("expected paused=false after second toggle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToggleSitePause_NonexistentSite(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
if e.ToggleSitePause(999) {
|
||||||
|
t.Error("expected false for nonexistent site")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAllSites_ReturnsCopy(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
injectSite(e, models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "s1"},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
})
|
||||||
|
injectSite(e, models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 2, Name: "s2"},
|
||||||
|
SiteState: models.SiteState{Status: "DOWN"},
|
||||||
|
})
|
||||||
|
|
||||||
|
sites := e.GetAllSites()
|
||||||
|
if len(sites) != 2 {
|
||||||
|
t.Fatalf("expected 2 sites, got %d", len(sites))
|
||||||
|
}
|
||||||
|
sites[0].Name = "mutated"
|
||||||
|
|
||||||
|
fresh := e.GetAllSites()
|
||||||
|
for _, s := range fresh {
|
||||||
|
if s.Name == "mutated" {
|
||||||
|
t.Error("GetAllSites returned reference, not copy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLiveState_ReturnsCopy(t *testing.T) {
|
||||||
|
ms := newMockStore()
|
||||||
|
e := newTestEngine(ms)
|
||||||
|
injectSite(e, models.Site{
|
||||||
|
SiteConfig: models.SiteConfig{ID: 1, Name: "s1"},
|
||||||
|
SiteState: models.SiteState{Status: "UP"},
|
||||||
|
})
|
||||||
|
|
||||||
|
state := e.GetLiveState()
|
||||||
|
state[1] = models.Site{SiteConfig: models.SiteConfig{Name: "mutated"}}
|
||||||
|
|
||||||
|
fresh := e.GetLiveState()
|
||||||
|
if fresh[1].Name == "mutated" {
|
||||||
|
t.Error("GetLiveState returned reference, not copy")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,11 @@ import (
|
|||||||
// guard.
|
// guard.
|
||||||
const maxVisitors = 10000
|
const maxVisitors = 10000
|
||||||
|
|
||||||
|
const (
|
||||||
|
visitorCleanupInterval = 5 * time.Minute
|
||||||
|
visitorIdleCutoff = 10 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
type visitor struct {
|
type visitor struct {
|
||||||
tokens float64
|
tokens float64
|
||||||
lastSeen time.Time
|
lastSeen time.Time
|
||||||
@@ -90,13 +95,13 @@ func (rl *RateLimiter) evictOldest() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (rl *RateLimiter) cleanup() {
|
func (rl *RateLimiter) cleanup() {
|
||||||
ticker := time.NewTicker(5 * time.Minute)
|
ticker := time.NewTicker(visitorCleanupInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
rl.mu.Lock()
|
rl.mu.Lock()
|
||||||
cutoff := time.Now().Add(-10 * time.Minute)
|
cutoff := time.Now().Add(-visitorIdleCutoff)
|
||||||
for ip, v := range rl.visitors {
|
for ip, v := range rl.visitors {
|
||||||
if v.lastSeen.Before(cutoff) {
|
if v.lastSeen.Before(cutoff) {
|
||||||
delete(rl.visitors, ip)
|
delete(rl.visitors, ip)
|
||||||
|
|||||||
@@ -45,15 +45,27 @@ type Server struct {
|
|||||||
statusRL *RateLimiter
|
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 {
|
func NewServer(cfg ServerConfig, s store.Store, eng *monitor.Engine) *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
store: s,
|
store: s,
|
||||||
eng: eng,
|
eng: eng,
|
||||||
pushRL: NewRateLimiter(60, cfg.TrustedProxies),
|
pushRL: NewRateLimiter(pushRateLimit, cfg.TrustedProxies),
|
||||||
probeRL: NewRateLimiter(30, cfg.TrustedProxies),
|
probeRL: NewRateLimiter(probeRateLimit, cfg.TrustedProxies),
|
||||||
backupRL: NewRateLimiter(10, cfg.TrustedProxies),
|
backupRL: NewRateLimiter(backupRateLimit, cfg.TrustedProxies),
|
||||||
statusRL: NewRateLimiter(120, cfg.TrustedProxies),
|
statusRL: NewRateLimiter(statusRateLimit, cfg.TrustedProxies),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,10 +89,10 @@ func (s *Server) Start() *http.Server {
|
|||||||
httpSrv := &http.Server{
|
httpSrv := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: httpReadHeaderTimeout,
|
||||||
ReadTimeout: 30 * time.Second,
|
ReadTimeout: httpReadTimeout,
|
||||||
WriteTimeout: 60 * time.Second,
|
WriteTimeout: httpWriteTimeout,
|
||||||
IdleTimeout: 120 * time.Second,
|
IdleTimeout: httpIdleTimeout,
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
if s.cfg.TLSCert != "" && s.cfg.TLSKey != "" {
|
if s.cfg.TLSCert != "" && s.cfg.TLSKey != "" {
|
||||||
|
|||||||
+8
-367
@@ -5,7 +5,6 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -18,6 +17,10 @@ const (
|
|||||||
maxLogRows = 200
|
maxLogRows = 200
|
||||||
maxStateChangesPerSite = 5000
|
maxStateChangesPerSite = 5000
|
||||||
maxMaintenanceExport = 1000
|
maxMaintenanceExport = 1000
|
||||||
|
maxOpenConns = 25
|
||||||
|
maxIdleConns = 5
|
||||||
|
connMaxLifetime = 5 * time.Minute
|
||||||
|
tokenByteLen = 16
|
||||||
)
|
)
|
||||||
|
|
||||||
type SQLStore struct {
|
type SQLStore struct {
|
||||||
@@ -32,9 +35,9 @@ func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
db.SetMaxOpenConns(25)
|
db.SetMaxOpenConns(maxOpenConns)
|
||||||
db.SetMaxIdleConns(5)
|
db.SetMaxIdleConns(maxIdleConns)
|
||||||
db.SetConnMaxLifetime(5 * time.Minute)
|
db.SetConnMaxLifetime(connMaxLifetime)
|
||||||
_, isDollar := dialect.(*PostgresDialect)
|
_, isDollar := dialect.(*PostgresDialect)
|
||||||
return &SQLStore{db: db, dialect: dialect, dollar: isDollar}, nil
|
return &SQLStore{db: db, dialect: dialect, dollar: isDollar}, nil
|
||||||
}
|
}
|
||||||
@@ -62,7 +65,7 @@ func (s *SQLStore) q(query string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func generateToken() (string, error) {
|
func generateToken() (string, error) {
|
||||||
b := make([]byte, 16)
|
b := make([]byte, tokenByteLen)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
return "", fmt.Errorf("crypto/rand failed: %w", err)
|
return "", fmt.Errorf("crypto/rand failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -214,40 +217,6 @@ func (s *SQLStore) GetSiteByName(ctx context.Context, name string) (models.SiteC
|
|||||||
return st, err
|
return st, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLStore) unmarshalSettings(raw string) (map[string]string, error) {
|
|
||||||
decrypted, err := s.decryptSettings(raw)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("decrypt settings: %w", err)
|
|
||||||
}
|
|
||||||
var m map[string]string
|
|
||||||
if err := json.Unmarshal([]byte(decrypted), &m); err != nil {
|
|
||||||
return nil, fmt.Errorf("unmarshal settings: %w", err)
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) marshalSettings(settings map[string]string) (string, error) {
|
|
||||||
jsonBytes, err := json.Marshal(settings)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return s.encryptSettings(string(jsonBytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) GetAlertByName(ctx context.Context, name string) (models.AlertConfig, error) {
|
|
||||||
var a models.AlertConfig
|
|
||||||
var settingsRaw string
|
|
||||||
err := s.db.QueryRowContext(ctx, s.q("SELECT id, name, type, settings FROM alerts WHERE name = ?"), name).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw)
|
|
||||||
if err != nil {
|
|
||||||
return a, err
|
|
||||||
}
|
|
||||||
a.Settings, err = s.unmarshalSettings(settingsRaw)
|
|
||||||
if err != nil {
|
|
||||||
return a, fmt.Errorf("alert %q: %w", name, err)
|
|
||||||
}
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) AddSiteReturningID(ctx context.Context, site models.SiteConfig) (int, error) {
|
func (s *SQLStore) AddSiteReturningID(ctx context.Context, site models.SiteConfig) (int, error) {
|
||||||
token := ""
|
token := ""
|
||||||
if site.Type == "push" {
|
if site.Type == "push" {
|
||||||
@@ -274,89 +243,6 @@ func (s *SQLStore) AddSiteReturningID(ctx context.Context, site models.SiteConfi
|
|||||||
return int(id), err
|
return int(id), err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLStore) AddAlertReturningID(ctx context.Context, name, aType string, settings map[string]string) (int, error) {
|
|
||||||
stored, err := s.marshalSettings(settings)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
if s.dollar {
|
|
||||||
var id int
|
|
||||||
err := s.db.QueryRowContext(ctx, s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?) RETURNING id"), name, aType, stored).Scan(&id)
|
|
||||||
return id, err
|
|
||||||
}
|
|
||||||
result, err := s.db.ExecContext(ctx, s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
id, err := result.LastInsertId()
|
|
||||||
return int(id), err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) GetAllAlerts(ctx context.Context) ([]models.AlertConfig, error) {
|
|
||||||
rows, err := s.db.QueryContext(ctx, "SELECT id, name, type, settings FROM alerts")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var alerts []models.AlertConfig
|
|
||||||
for rows.Next() {
|
|
||||||
var a models.AlertConfig
|
|
||||||
var settingsRaw string
|
|
||||||
if err := rows.Scan(&a.ID, &a.Name, &a.Type, &settingsRaw); err != nil {
|
|
||||||
return alerts, err
|
|
||||||
}
|
|
||||||
a.Settings, err = s.unmarshalSettings(settingsRaw)
|
|
||||||
if err != nil {
|
|
||||||
return alerts, fmt.Errorf("alert %q: %w", a.Name, err)
|
|
||||||
}
|
|
||||||
alerts = append(alerts, a)
|
|
||||||
}
|
|
||||||
return alerts, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) GetAlert(ctx context.Context, id int) (models.AlertConfig, error) {
|
|
||||||
var a models.AlertConfig
|
|
||||||
var settingsRaw string
|
|
||||||
err := s.db.QueryRowContext(ctx, s.q("SELECT id, name, type, settings FROM alerts WHERE id = ?"), id).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw)
|
|
||||||
if err != nil {
|
|
||||||
return a, err
|
|
||||||
}
|
|
||||||
a.Settings, err = s.unmarshalSettings(settingsRaw)
|
|
||||||
if err != nil {
|
|
||||||
return a, fmt.Errorf("alert %d: %w", id, err)
|
|
||||||
}
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) AddAlert(ctx context.Context, name, aType string, settings map[string]string) error {
|
|
||||||
stored, err := s.marshalSettings(settings)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = s.db.ExecContext(ctx, s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) UpdateAlert(ctx context.Context, id int, name, aType string, settings map[string]string) error {
|
|
||||||
stored, err := s.marshalSettings(settings)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = s.db.ExecContext(ctx, s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, stored, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) DeleteAlert(ctx context.Context, id int) error {
|
|
||||||
if _, err := s.db.ExecContext(ctx, s.q("UPDATE sites SET alert_id = 0 WHERE alert_id = ?"), id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := s.db.ExecContext(ctx, s.q("DELETE FROM alerts WHERE id=?"), id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.dialect.ResetSequenceOnEmpty(s.db, "alerts")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) GetAllUsers(ctx context.Context) ([]models.User, error) {
|
func (s *SQLStore) GetAllUsers(ctx context.Context) ([]models.User, error) {
|
||||||
rows, err := s.db.QueryContext(ctx, "SELECT id, username, public_key, role FROM users")
|
rows, err := s.db.QueryContext(ctx, "SELECT id, username, public_key, role FROM users")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -389,85 +275,6 @@ func (s *SQLStore) DeleteUser(ctx context.Context, id int) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLStore) SaveStateChange(ctx context.Context, siteID int, fromStatus, toStatus, errorReason string) error {
|
|
||||||
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO state_changes (site_id, from_status, to_status, error_reason) VALUES (?, ?, ?, ?)"),
|
|
||||||
siteID, fromStatus, toStatus, errorReason)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) GetStateChanges(ctx context.Context, siteID int, limit int) ([]models.StateChange, error) {
|
|
||||||
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, site_id, from_status, to_status, error_reason, changed_at FROM state_changes WHERE site_id = ? ORDER BY changed_at DESC LIMIT ?"), siteID, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var changes []models.StateChange
|
|
||||||
for rows.Next() {
|
|
||||||
var sc models.StateChange
|
|
||||||
if err := rows.Scan(&sc.ID, &sc.SiteID, &sc.FromStatus, &sc.ToStatus, &sc.ErrorReason, &sc.ChangedAt); err != nil {
|
|
||||||
return changes, err
|
|
||||||
}
|
|
||||||
changes = append(changes, sc)
|
|
||||||
}
|
|
||||||
return changes, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) GetStateChangesSince(ctx context.Context, siteID int, since time.Time) ([]models.StateChange, error) {
|
|
||||||
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, site_id, from_status, to_status, error_reason, changed_at FROM state_changes WHERE site_id = ? AND changed_at >= ? ORDER BY changed_at DESC"), siteID, since)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var changes []models.StateChange
|
|
||||||
for rows.Next() {
|
|
||||||
var sc models.StateChange
|
|
||||||
if err := rows.Scan(&sc.ID, &sc.SiteID, &sc.FromStatus, &sc.ToStatus, &sc.ErrorReason, &sc.ChangedAt); err != nil {
|
|
||||||
return changes, err
|
|
||||||
}
|
|
||||||
changes = append(changes, sc)
|
|
||||||
}
|
|
||||||
return changes, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) SaveCheck(ctx context.Context, siteID int, latencyNs int64, isUp bool) error {
|
|
||||||
return s.SaveCheckFromNode(ctx, siteID, "", latencyNs, isUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveCheckFromNode inserts a single check row. Retention is handled out of
|
|
||||||
// band by PruneCheckHistory on a timer, not per-insert, to keep the write hot
|
|
||||||
// path a plain INSERT.
|
|
||||||
func (s *SQLStore) SaveCheckFromNode(ctx context.Context, siteID int, nodeID string, latencyNs int64, isUp bool) error {
|
|
||||||
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO check_history (site_id, node_id, latency_ns, is_up) VALUES (?, ?, ?, ?)"), siteID, nodeID, latencyNs, isUp)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// PruneCheckHistory trims check_history to the newest maxCheckHistory rows per
|
|
||||||
// site, across all sites, in one pass. Intended to run periodically.
|
|
||||||
func (s *SQLStore) PruneCheckHistory(ctx context.Context) error {
|
|
||||||
q := fmt.Sprintf(`DELETE FROM check_history WHERE id IN (
|
|
||||||
SELECT id FROM (
|
|
||||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC, id DESC) AS rn
|
|
||||||
FROM check_history
|
|
||||||
) ranked WHERE rn > %d
|
|
||||||
)`, maxCheckHistory)
|
|
||||||
_, err := s.db.ExecContext(ctx, s.q(q))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// PruneStateChanges trims state_changes to the newest maxStateChangesPerSite
|
|
||||||
// rows per site. Generous so realistic SLA windows are unaffected; bounds the
|
|
||||||
// otherwise unbounded growth of a flapping monitor's history.
|
|
||||||
func (s *SQLStore) PruneStateChanges(ctx context.Context) error {
|
|
||||||
q := fmt.Sprintf(`DELETE FROM state_changes WHERE id IN (
|
|
||||||
SELECT id FROM (
|
|
||||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY changed_at DESC, id DESC) AS rn
|
|
||||||
FROM state_changes
|
|
||||||
) ranked WHERE rn > %d
|
|
||||||
)`, maxStateChangesPerSite)
|
|
||||||
_, err := s.db.ExecContext(ctx, s.q(q))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) RegisterNode(ctx context.Context, node models.ProbeNode) error {
|
func (s *SQLStore) RegisterNode(ctx context.Context, node models.ProbeNode) error {
|
||||||
_, err := s.db.ExecContext(ctx, s.dialect.UpsertNodeSQL(), node.ID, node.Name, node.Region, node.Version)
|
_, err := s.db.ExecContext(ctx, s.dialect.UpsertNodeSQL(), node.ID, node.Name, node.Region, node.Version)
|
||||||
return err
|
return err
|
||||||
@@ -578,172 +385,6 @@ func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]models.LogEntry,
|
|||||||
return entries, rows.Err()
|
return entries, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLStore) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) {
|
|
||||||
result := make(map[int][]models.CheckRecord)
|
|
||||||
rows, err := s.db.QueryContext(ctx, s.q(`
|
|
||||||
SELECT site_id, latency_ns, is_up FROM (
|
|
||||||
SELECT site_id, latency_ns, is_up,
|
|
||||||
ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC) AS rn
|
|
||||||
FROM check_history
|
|
||||||
) sub WHERE rn <= ?`), limit)
|
|
||||||
if err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var r models.CheckRecord
|
|
||||||
if err := rows.Scan(&r.SiteID, &r.LatencyNs, &r.IsUp); err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
result[r.SiteID] = append(result[r.SiteID], r)
|
|
||||||
}
|
|
||||||
for id, records := range result {
|
|
||||||
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
|
|
||||||
records[i], records[j] = records[j], records[i]
|
|
||||||
}
|
|
||||||
result[id] = records
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) scanMaintenanceWindow(rows *sql.Rows) (models.MaintenanceWindow, error) {
|
|
||||||
var mw models.MaintenanceWindow
|
|
||||||
var endTime sql.NullTime
|
|
||||||
if err := rows.Scan(&mw.ID, &mw.MonitorID, &mw.Title, &mw.Description, &mw.Type, &mw.StartTime, &endTime, &mw.CreatedBy, &mw.CreatedAt); err != nil {
|
|
||||||
return mw, err
|
|
||||||
}
|
|
||||||
if endTime.Valid {
|
|
||||||
mw.EndTime = endTime.Time
|
|
||||||
}
|
|
||||||
return mw, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) {
|
|
||||||
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows WHERE start_time <= CURRENT_TIMESTAMP AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP) ORDER BY start_time DESC"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var windows []models.MaintenanceWindow
|
|
||||||
for rows.Next() {
|
|
||||||
mw, err := s.scanMaintenanceWindow(rows)
|
|
||||||
if err != nil {
|
|
||||||
return windows, err
|
|
||||||
}
|
|
||||||
windows = append(windows, mw)
|
|
||||||
}
|
|
||||||
return windows, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) GetAllMaintenanceWindows(ctx context.Context, limit int) ([]models.MaintenanceWindow, error) {
|
|
||||||
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows ORDER BY created_at DESC LIMIT ?"), limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var windows []models.MaintenanceWindow
|
|
||||||
for rows.Next() {
|
|
||||||
mw, err := s.scanMaintenanceWindow(rows)
|
|
||||||
if err != nil {
|
|
||||||
return windows, err
|
|
||||||
}
|
|
||||||
windows = append(windows, mw)
|
|
||||||
}
|
|
||||||
return windows, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) GetOverlappingMaintenanceWindows(ctx context.Context, monitorID int, startTime, endTime time.Time) ([]models.MaintenanceWindow, error) {
|
|
||||||
var timeClause string
|
|
||||||
var args []interface{}
|
|
||||||
|
|
||||||
if endTime.IsZero() {
|
|
||||||
timeClause = "(end_time IS NULL OR end_time > ?)"
|
|
||||||
args = append(args, startTime)
|
|
||||||
} else {
|
|
||||||
timeClause = "(end_time IS NULL OR end_time > ?) AND start_time < ?"
|
|
||||||
args = append(args, startTime, endTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
var scopeClause string
|
|
||||||
if monitorID == 0 {
|
|
||||||
scopeClause = "1=1"
|
|
||||||
} else {
|
|
||||||
scopeClause = "(monitor_id = ? OR monitor_id = 0 OR monitor_id IN (SELECT parent_id FROM sites WHERE id = ? AND parent_id > 0))"
|
|
||||||
args = append(args, monitorID, monitorID)
|
|
||||||
}
|
|
||||||
|
|
||||||
query := fmt.Sprintf(
|
|
||||||
"SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows WHERE %s AND %s ORDER BY start_time",
|
|
||||||
timeClause, scopeClause,
|
|
||||||
)
|
|
||||||
|
|
||||||
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var windows []models.MaintenanceWindow
|
|
||||||
for rows.Next() {
|
|
||||||
mw, err := s.scanMaintenanceWindow(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
windows = append(windows, mw)
|
|
||||||
}
|
|
||||||
return windows, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) AddMaintenanceWindow(ctx context.Context, mw models.MaintenanceWindow) error {
|
|
||||||
if mw.StartTime.IsZero() {
|
|
||||||
mw.StartTime = time.Now()
|
|
||||||
}
|
|
||||||
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO maintenance_windows (monitor_id, title, description, type, start_time, end_time, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"),
|
|
||||||
mw.MonitorID, mw.Title, mw.Description, mw.Type, mw.StartTime, sql.NullTime{Time: mw.EndTime, Valid: !mw.EndTime.IsZero()}, mw.CreatedBy)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) EndMaintenanceWindow(ctx context.Context, id int) error {
|
|
||||||
_, err := s.db.ExecContext(ctx, s.q("UPDATE maintenance_windows SET end_time = CURRENT_TIMESTAMP WHERE id = ?"), id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) DeleteMaintenanceWindow(ctx context.Context, id int) error {
|
|
||||||
_, err := s.db.ExecContext(ctx, s.q("DELETE FROM maintenance_windows WHERE id = ?"), id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.dialect.ResetSequenceOnEmpty(s.db, "maintenance_windows")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) PruneExpiredMaintenanceWindows(ctx context.Context, retention time.Duration) (int64, error) {
|
|
||||||
cutoff := time.Now().Add(-retention)
|
|
||||||
result, err := s.db.ExecContext(ctx,
|
|
||||||
s.q("DELETE FROM maintenance_windows WHERE end_time IS NOT NULL AND end_time < ?"),
|
|
||||||
cutoff,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return result.RowsAffected()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) IsMonitorInMaintenance(ctx context.Context, monitorID int) (bool, error) {
|
|
||||||
var count int
|
|
||||||
err := s.db.QueryRowContext(ctx, s.q(`SELECT COUNT(*) FROM maintenance_windows
|
|
||||||
WHERE type = 'maintenance'
|
|
||||||
AND start_time <= CURRENT_TIMESTAMP
|
|
||||||
AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP)
|
|
||||||
AND (monitor_id = 0 OR monitor_id = ?
|
|
||||||
OR monitor_id IN (SELECT parent_id FROM sites WHERE id = ? AND parent_id > 0))`),
|
|
||||||
monitorID, monitorID).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return count > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) GetPreference(ctx context.Context, key string) (string, error) {
|
func (s *SQLStore) GetPreference(ctx context.Context, key string) (string, error) {
|
||||||
var value string
|
var value string
|
||||||
err := s.db.QueryRowContext(ctx, s.q("SELECT value FROM preferences WHERE key = ?"), key).Scan(&value)
|
err := s.db.QueryRowContext(ctx, s.q("SELECT value FROM preferences WHERE key = ?"), key).Scan(&value)
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *SQLStore) unmarshalSettings(raw string) (map[string]string, error) {
|
||||||
|
decrypted, err := s.decryptSettings(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decrypt settings: %w", err)
|
||||||
|
}
|
||||||
|
var m map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(decrypted), &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal settings: %w", err)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) marshalSettings(settings map[string]string) (string, error) {
|
||||||
|
jsonBytes, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return s.encryptSettings(string(jsonBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) GetAlertByName(ctx context.Context, name string) (models.AlertConfig, error) {
|
||||||
|
var a models.AlertConfig
|
||||||
|
var settingsRaw string
|
||||||
|
err := s.db.QueryRowContext(ctx, s.q("SELECT id, name, type, settings FROM alerts WHERE name = ?"), name).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw)
|
||||||
|
if err != nil {
|
||||||
|
return a, err
|
||||||
|
}
|
||||||
|
a.Settings, err = s.unmarshalSettings(settingsRaw)
|
||||||
|
if err != nil {
|
||||||
|
return a, fmt.Errorf("alert %q: %w", name, err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) AddAlertReturningID(ctx context.Context, name, aType string, settings map[string]string) (int, error) {
|
||||||
|
stored, err := s.marshalSettings(settings)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if s.dollar {
|
||||||
|
var id int
|
||||||
|
err := s.db.QueryRowContext(ctx, s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?) RETURNING id"), name, aType, stored).Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
result, err := s.db.ExecContext(ctx, s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
return int(id), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) GetAllAlerts(ctx context.Context) ([]models.AlertConfig, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, "SELECT id, name, type, settings FROM alerts")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var alerts []models.AlertConfig
|
||||||
|
for rows.Next() {
|
||||||
|
var a models.AlertConfig
|
||||||
|
var settingsRaw string
|
||||||
|
if err := rows.Scan(&a.ID, &a.Name, &a.Type, &settingsRaw); err != nil {
|
||||||
|
return alerts, err
|
||||||
|
}
|
||||||
|
a.Settings, err = s.unmarshalSettings(settingsRaw)
|
||||||
|
if err != nil {
|
||||||
|
return alerts, fmt.Errorf("alert %q: %w", a.Name, err)
|
||||||
|
}
|
||||||
|
alerts = append(alerts, a)
|
||||||
|
}
|
||||||
|
return alerts, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) GetAlert(ctx context.Context, id int) (models.AlertConfig, error) {
|
||||||
|
var a models.AlertConfig
|
||||||
|
var settingsRaw string
|
||||||
|
err := s.db.QueryRowContext(ctx, s.q("SELECT id, name, type, settings FROM alerts WHERE id = ?"), id).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw)
|
||||||
|
if err != nil {
|
||||||
|
return a, err
|
||||||
|
}
|
||||||
|
a.Settings, err = s.unmarshalSettings(settingsRaw)
|
||||||
|
if err != nil {
|
||||||
|
return a, fmt.Errorf("alert %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) AddAlert(ctx context.Context, name, aType string, settings map[string]string) error {
|
||||||
|
stored, err := s.marshalSettings(settings)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.db.ExecContext(ctx, s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) UpdateAlert(ctx context.Context, id int, name, aType string, settings map[string]string) error {
|
||||||
|
stored, err := s.marshalSettings(settings)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.db.ExecContext(ctx, s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, stored, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) DeleteAlert(ctx context.Context, id int) error {
|
||||||
|
if _, err := s.db.ExecContext(ctx, s.q("UPDATE sites SET alert_id = 0 WHERE alert_id = ?"), id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.db.ExecContext(ctx, s.q("DELETE FROM alerts WHERE id=?"), id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.dialect.ResetSequenceOnEmpty(s.db, "alerts")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertCRUD(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
if err := s.AddAlert(context.Background(), "Discord", "discord", map[string]string{"url": "https://example.com/hook"}); err != nil {
|
||||||
|
t.Fatalf("AddAlert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
alerts, err := s.GetAllAlerts(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAllAlerts: %v", err)
|
||||||
|
}
|
||||||
|
if len(alerts) != 1 {
|
||||||
|
t.Fatalf("expected 1 alert, got %d", len(alerts))
|
||||||
|
}
|
||||||
|
if alerts[0].Type != "discord" {
|
||||||
|
t.Errorf("expected type 'discord', got '%s'", alerts[0].Type)
|
||||||
|
}
|
||||||
|
if alerts[0].Settings["url"] != "https://example.com/hook" {
|
||||||
|
t.Errorf("settings url mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
a, err := s.GetAlert(context.Background(), alerts[0].ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAlert: %v", err)
|
||||||
|
}
|
||||||
|
if a.Name != "Discord" {
|
||||||
|
t.Errorf("expected name 'Discord', got '%s'", a.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.UpdateAlert(context.Background(), a.ID, "Slack", "slack", map[string]string{"url": "https://slack.com/hook"}); err != nil {
|
||||||
|
t.Fatalf("UpdateAlert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a, err = s.GetAlert(context.Background(), a.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAlert: %v", err)
|
||||||
|
}
|
||||||
|
if a.Type != "slack" {
|
||||||
|
t.Errorf("expected type 'slack', got '%s'", a.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DeleteAlert(context.Background(), a.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteAlert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
alerts, err = s.GetAllAlerts(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAllAlerts: %v", err)
|
||||||
|
}
|
||||||
|
if len(alerts) != 0 {
|
||||||
|
t.Fatalf("expected 0 alerts after delete, got %d", len(alerts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportData must encrypt alert settings (like AddAlert/UpdateAlert) so a
|
||||||
|
// restore with UPTOP_ENCRYPTION_KEY set never lands secrets in plaintext.
|
||||||
|
func TestImportData_EncryptsAlertSettings(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
enc, err := NewEncryptor(strings.Repeat("ab", 32)) // 64 hex chars = 32 bytes
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewEncryptor: %v", err)
|
||||||
|
}
|
||||||
|
s.SetEncryptor(enc)
|
||||||
|
|
||||||
|
backup := models.Backup{
|
||||||
|
Alerts: []models.AlertConfig{
|
||||||
|
{ID: 1, Name: "tg", Type: "telegram", Settings: map[string]string{"token": "123:SECRET", "chat_id": "42"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := s.ImportData(context.Background(), backup); err != nil {
|
||||||
|
t.Fatalf("ImportData: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw string
|
||||||
|
if err := s.db.QueryRow("SELECT settings FROM alerts WHERE id = 1").Scan(&raw); err != nil {
|
||||||
|
t.Fatalf("query settings: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(raw, encryptedPrefix) {
|
||||||
|
t.Errorf("imported settings not encrypted: %q", raw)
|
||||||
|
}
|
||||||
|
if strings.Contains(raw, "SECRET") {
|
||||||
|
t.Errorf("plaintext secret found in stored column: %q", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
alerts, err := s.GetAllAlerts(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAllAlerts: %v", err)
|
||||||
|
}
|
||||||
|
if len(alerts) != 1 || alerts[0].Settings["token"] != "123:SECRET" {
|
||||||
|
t.Errorf("decrypt round-trip failed: %+v", alerts)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *SQLStore) SaveStateChange(ctx context.Context, siteID int, fromStatus, toStatus, errorReason string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO state_changes (site_id, from_status, to_status, error_reason) VALUES (?, ?, ?, ?)"),
|
||||||
|
siteID, fromStatus, toStatus, errorReason)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) GetStateChanges(ctx context.Context, siteID int, limit int) ([]models.StateChange, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, site_id, from_status, to_status, error_reason, changed_at FROM state_changes WHERE site_id = ? ORDER BY changed_at DESC LIMIT ?"), siteID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var changes []models.StateChange
|
||||||
|
for rows.Next() {
|
||||||
|
var sc models.StateChange
|
||||||
|
if err := rows.Scan(&sc.ID, &sc.SiteID, &sc.FromStatus, &sc.ToStatus, &sc.ErrorReason, &sc.ChangedAt); err != nil {
|
||||||
|
return changes, err
|
||||||
|
}
|
||||||
|
changes = append(changes, sc)
|
||||||
|
}
|
||||||
|
return changes, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) GetStateChangesSince(ctx context.Context, siteID int, since time.Time) ([]models.StateChange, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, site_id, from_status, to_status, error_reason, changed_at FROM state_changes WHERE site_id = ? AND changed_at >= ? ORDER BY changed_at DESC"), siteID, since)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var changes []models.StateChange
|
||||||
|
for rows.Next() {
|
||||||
|
var sc models.StateChange
|
||||||
|
if err := rows.Scan(&sc.ID, &sc.SiteID, &sc.FromStatus, &sc.ToStatus, &sc.ErrorReason, &sc.ChangedAt); err != nil {
|
||||||
|
return changes, err
|
||||||
|
}
|
||||||
|
changes = append(changes, sc)
|
||||||
|
}
|
||||||
|
return changes, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) SaveCheck(ctx context.Context, siteID int, latencyNs int64, isUp bool) error {
|
||||||
|
return s.SaveCheckFromNode(ctx, siteID, "", latencyNs, isUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveCheckFromNode inserts a single check row. Retention is handled out of
|
||||||
|
// band by PruneCheckHistory on a timer, not per-insert, to keep the write hot
|
||||||
|
// path a plain INSERT.
|
||||||
|
func (s *SQLStore) SaveCheckFromNode(ctx context.Context, siteID int, nodeID string, latencyNs int64, isUp bool) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO check_history (site_id, node_id, latency_ns, is_up) VALUES (?, ?, ?, ?)"), siteID, nodeID, latencyNs, isUp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PruneCheckHistory trims check_history to the newest maxCheckHistory rows per
|
||||||
|
// site, across all sites, in one pass. Intended to run periodically.
|
||||||
|
func (s *SQLStore) PruneCheckHistory(ctx context.Context) error {
|
||||||
|
q := fmt.Sprintf(`DELETE FROM check_history WHERE id IN (
|
||||||
|
SELECT id FROM (
|
||||||
|
SELECT id, ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC, id DESC) AS rn
|
||||||
|
FROM check_history
|
||||||
|
) ranked WHERE rn > %d
|
||||||
|
)`, maxCheckHistory)
|
||||||
|
_, err := s.db.ExecContext(ctx, s.q(q))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PruneStateChanges trims state_changes to the newest maxStateChangesPerSite
|
||||||
|
// rows per site. Generous so realistic SLA windows are unaffected; bounds the
|
||||||
|
// otherwise unbounded growth of a flapping monitor's history.
|
||||||
|
func (s *SQLStore) PruneStateChanges(ctx context.Context) error {
|
||||||
|
q := fmt.Sprintf(`DELETE FROM state_changes WHERE id IN (
|
||||||
|
SELECT id FROM (
|
||||||
|
SELECT id, ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY changed_at DESC, id DESC) AS rn
|
||||||
|
FROM state_changes
|
||||||
|
) ranked WHERE rn > %d
|
||||||
|
)`, maxStateChangesPerSite)
|
||||||
|
_, err := s.db.ExecContext(ctx, s.q(q))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) {
|
||||||
|
result := make(map[int][]models.CheckRecord)
|
||||||
|
rows, err := s.db.QueryContext(ctx, s.q(`
|
||||||
|
SELECT site_id, latency_ns, is_up FROM (
|
||||||
|
SELECT site_id, latency_ns, is_up,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC) AS rn
|
||||||
|
FROM check_history
|
||||||
|
) sub WHERE rn <= ?`), limit)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var r models.CheckRecord
|
||||||
|
if err := rows.Scan(&r.SiteID, &r.LatencyNs, &r.IsUp); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
result[r.SiteID] = append(result[r.SiteID], r)
|
||||||
|
}
|
||||||
|
for id, records := range result {
|
||||||
|
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
records[i], records[j] = records[j], records[i]
|
||||||
|
}
|
||||||
|
result[id] = records
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckHistory(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
if err := s.SaveCheck(context.Background(), 1, 5000000, true); err != nil {
|
||||||
|
t.Fatalf("SaveCheck: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.SaveCheck(context.Background(), 1, 10000000, false); err != nil {
|
||||||
|
t.Fatalf("SaveCheck: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.SaveCheck(context.Background(), 2, 3000000, true); err != nil {
|
||||||
|
t.Fatalf("SaveCheck site 2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := s.LoadAllHistory(context.Background(), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadAllHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history[1]) != 2 {
|
||||||
|
t.Fatalf("expected 2 records for site 1, got %d", len(history[1]))
|
||||||
|
}
|
||||||
|
if len(history[2]) != 1 {
|
||||||
|
t.Fatalf("expected 1 record for site 2, got %d", len(history[2]))
|
||||||
|
}
|
||||||
|
|
||||||
|
upCount := 0
|
||||||
|
for _, r := range history[1] {
|
||||||
|
if r.IsUp {
|
||||||
|
upCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if upCount != 1 {
|
||||||
|
t.Errorf("expected 1 up record for site 1, got %d", upCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPruneCheckHistory(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
for i := 0; i < maxCheckHistory+5; i++ {
|
||||||
|
if err := s.SaveCheck(context.Background(), 1, int64(i), true); err != nil {
|
||||||
|
t.Fatalf("SaveCheck site 1: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := s.SaveCheck(context.Background(), 2, int64(i), true); err != nil {
|
||||||
|
t.Fatalf("SaveCheck site 2: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.PruneCheckHistory(context.Background()); err != nil {
|
||||||
|
t.Fatalf("PruneCheckHistory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := s.LoadAllHistory(context.Background(), maxCheckHistory*2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadAllHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history[1]) != maxCheckHistory {
|
||||||
|
t.Errorf("site 1: expected %d rows after prune, got %d", maxCheckHistory, len(history[1]))
|
||||||
|
}
|
||||||
|
if len(history[2]) != 3 {
|
||||||
|
t.Errorf("site 2: expected 3 rows untouched, got %d", len(history[2]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteSiteCascade(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
site := models.SiteConfig{Name: "Cascade Test", URL: "https://example.com", Interval: 30}
|
||||||
|
if err := s.AddSite(context.Background(), site); err != nil {
|
||||||
|
t.Fatalf("AddSite: %v", err)
|
||||||
|
}
|
||||||
|
sites, err := s.GetSites(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSites: %v", err)
|
||||||
|
}
|
||||||
|
siteID := sites[0].ID
|
||||||
|
|
||||||
|
if err := s.SaveCheck(context.Background(), siteID, 1000, true); err != nil {
|
||||||
|
t.Fatalf("SaveCheck: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.SaveStateChange(context.Background(), siteID, "UP", "DOWN", "timeout"); err != nil {
|
||||||
|
t.Fatalf("SaveStateChange: %v", err)
|
||||||
|
}
|
||||||
|
mw := models.MaintenanceWindow{
|
||||||
|
MonitorID: siteID,
|
||||||
|
Title: "Test MW",
|
||||||
|
Type: "maintenance",
|
||||||
|
StartTime: time.Now(),
|
||||||
|
}
|
||||||
|
if err := s.AddMaintenanceWindow(context.Background(), mw); err != nil {
|
||||||
|
t.Fatalf("AddMaintenanceWindow: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DeleteSite(context.Background(), siteID); err != nil {
|
||||||
|
t.Fatalf("DeleteSite: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := s.LoadAllHistory(context.Background(), 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadAllHistory: %v", err)
|
||||||
|
}
|
||||||
|
if len(history[siteID]) != 0 {
|
||||||
|
t.Errorf("expected 0 check_history rows, got %d", len(history[siteID]))
|
||||||
|
}
|
||||||
|
|
||||||
|
changes, err := s.GetStateChanges(context.Background(), siteID, 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetStateChanges: %v", err)
|
||||||
|
}
|
||||||
|
if len(changes) != 0 {
|
||||||
|
t.Errorf("expected 0 state_changes rows, got %d", len(changes))
|
||||||
|
}
|
||||||
|
|
||||||
|
windows, err := s.GetActiveMaintenanceWindows(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetActiveMaintenanceWindows: %v", err)
|
||||||
|
}
|
||||||
|
for _, w := range windows {
|
||||||
|
if w.MonitorID == siteID {
|
||||||
|
t.Errorf("orphaned maintenance window found: id=%d", w.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *SQLStore) scanMaintenanceWindow(rows *sql.Rows) (models.MaintenanceWindow, error) {
|
||||||
|
var mw models.MaintenanceWindow
|
||||||
|
var endTime sql.NullTime
|
||||||
|
if err := rows.Scan(&mw.ID, &mw.MonitorID, &mw.Title, &mw.Description, &mw.Type, &mw.StartTime, &endTime, &mw.CreatedBy, &mw.CreatedAt); err != nil {
|
||||||
|
return mw, err
|
||||||
|
}
|
||||||
|
if endTime.Valid {
|
||||||
|
mw.EndTime = endTime.Time
|
||||||
|
}
|
||||||
|
return mw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows WHERE start_time <= CURRENT_TIMESTAMP AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP) ORDER BY start_time DESC"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var windows []models.MaintenanceWindow
|
||||||
|
for rows.Next() {
|
||||||
|
mw, err := s.scanMaintenanceWindow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return windows, err
|
||||||
|
}
|
||||||
|
windows = append(windows, mw)
|
||||||
|
}
|
||||||
|
return windows, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) GetAllMaintenanceWindows(ctx context.Context, limit int) ([]models.MaintenanceWindow, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows ORDER BY created_at DESC LIMIT ?"), limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var windows []models.MaintenanceWindow
|
||||||
|
for rows.Next() {
|
||||||
|
mw, err := s.scanMaintenanceWindow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return windows, err
|
||||||
|
}
|
||||||
|
windows = append(windows, mw)
|
||||||
|
}
|
||||||
|
return windows, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) GetOverlappingMaintenanceWindows(ctx context.Context, monitorID int, startTime, endTime time.Time) ([]models.MaintenanceWindow, error) {
|
||||||
|
var timeClause string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
if endTime.IsZero() {
|
||||||
|
timeClause = "(end_time IS NULL OR end_time > ?)"
|
||||||
|
args = append(args, startTime)
|
||||||
|
} else {
|
||||||
|
timeClause = "(end_time IS NULL OR end_time > ?) AND start_time < ?"
|
||||||
|
args = append(args, startTime, endTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
var scopeClause string
|
||||||
|
if monitorID == 0 {
|
||||||
|
scopeClause = "1=1"
|
||||||
|
} else {
|
||||||
|
scopeClause = "(monitor_id = ? OR monitor_id = 0 OR monitor_id IN (SELECT parent_id FROM sites WHERE id = ? AND parent_id > 0))"
|
||||||
|
args = append(args, monitorID, monitorID)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(
|
||||||
|
"SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows WHERE %s AND %s ORDER BY start_time",
|
||||||
|
timeClause, scopeClause,
|
||||||
|
)
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var windows []models.MaintenanceWindow
|
||||||
|
for rows.Next() {
|
||||||
|
mw, err := s.scanMaintenanceWindow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
windows = append(windows, mw)
|
||||||
|
}
|
||||||
|
return windows, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) AddMaintenanceWindow(ctx context.Context, mw models.MaintenanceWindow) error {
|
||||||
|
if mw.StartTime.IsZero() {
|
||||||
|
mw.StartTime = time.Now()
|
||||||
|
}
|
||||||
|
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO maintenance_windows (monitor_id, title, description, type, start_time, end_time, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"),
|
||||||
|
mw.MonitorID, mw.Title, mw.Description, mw.Type, mw.StartTime, sql.NullTime{Time: mw.EndTime, Valid: !mw.EndTime.IsZero()}, mw.CreatedBy)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) EndMaintenanceWindow(ctx context.Context, id int) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, s.q("UPDATE maintenance_windows SET end_time = CURRENT_TIMESTAMP WHERE id = ?"), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) DeleteMaintenanceWindow(ctx context.Context, id int) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, s.q("DELETE FROM maintenance_windows WHERE id = ?"), id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.dialect.ResetSequenceOnEmpty(s.db, "maintenance_windows")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) PruneExpiredMaintenanceWindows(ctx context.Context, retention time.Duration) (int64, error) {
|
||||||
|
cutoff := time.Now().Add(-retention)
|
||||||
|
result, err := s.db.ExecContext(ctx,
|
||||||
|
s.q("DELETE FROM maintenance_windows WHERE end_time IS NOT NULL AND end_time < ?"),
|
||||||
|
cutoff,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) IsMonitorInMaintenance(ctx context.Context, monitorID int) (bool, error) {
|
||||||
|
var count int
|
||||||
|
err := s.db.QueryRowContext(ctx, s.q(`SELECT COUNT(*) FROM maintenance_windows
|
||||||
|
WHERE type = 'maintenance'
|
||||||
|
AND start_time <= CURRENT_TIMESTAMP
|
||||||
|
AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP)
|
||||||
|
AND (monitor_id = 0 OR monitor_id = ?
|
||||||
|
OR monitor_id IN (SELECT parent_id FROM sites WHERE id = ? AND parent_id > 0))`),
|
||||||
|
monitorID, monitorID).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPruneExpiredMaintenanceWindows(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Expired 10 days ago — should be pruned with 7d retention.
|
||||||
|
old := models.MaintenanceWindow{
|
||||||
|
MonitorID: 0,
|
||||||
|
Title: "Old Window",
|
||||||
|
Type: "maintenance",
|
||||||
|
StartTime: now.Add(-11 * 24 * time.Hour),
|
||||||
|
EndTime: now.Add(-10 * 24 * time.Hour),
|
||||||
|
}
|
||||||
|
if err := s.AddMaintenanceWindow(context.Background(), old); err != nil {
|
||||||
|
t.Fatalf("AddMaintenanceWindow (old): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired 1 day ago — within 7d retention, should survive.
|
||||||
|
recent := models.MaintenanceWindow{
|
||||||
|
MonitorID: 0,
|
||||||
|
Title: "Recent Window",
|
||||||
|
Type: "maintenance",
|
||||||
|
StartTime: now.Add(-2 * 24 * time.Hour),
|
||||||
|
EndTime: now.Add(-1 * 24 * time.Hour),
|
||||||
|
}
|
||||||
|
if err := s.AddMaintenanceWindow(context.Background(), recent); err != nil {
|
||||||
|
t.Fatalf("AddMaintenanceWindow (recent): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ongoing — no end time, should survive.
|
||||||
|
ongoing := models.MaintenanceWindow{
|
||||||
|
MonitorID: 0,
|
||||||
|
Title: "Ongoing Window",
|
||||||
|
Type: "maintenance",
|
||||||
|
StartTime: now.Add(-1 * time.Hour),
|
||||||
|
}
|
||||||
|
if err := s.AddMaintenanceWindow(context.Background(), ongoing); err != nil {
|
||||||
|
t.Fatalf("AddMaintenanceWindow (ongoing): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pruned, err := s.PruneExpiredMaintenanceWindows(context.Background(), 7*24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PruneExpiredMaintenanceWindows: %v", err)
|
||||||
|
}
|
||||||
|
if pruned != 1 {
|
||||||
|
t.Errorf("expected 1 pruned, got %d", pruned)
|
||||||
|
}
|
||||||
|
|
||||||
|
all, err := s.GetAllMaintenanceWindows(context.Background(), 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAllMaintenanceWindows: %v", err)
|
||||||
|
}
|
||||||
|
if len(all) != 2 {
|
||||||
|
t.Fatalf("expected 2 remaining windows, got %d", len(all))
|
||||||
|
}
|
||||||
|
for _, w := range all {
|
||||||
|
if w.Title == "Old Window" {
|
||||||
|
t.Error("old window should have been pruned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOverlappingMaintenanceWindows(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
site := models.SiteConfig{Name: "web", URL: "https://example.com", Interval: 30}
|
||||||
|
if err := s.AddSite(ctx, site); err != nil {
|
||||||
|
t.Fatalf("AddSite: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
active := models.MaintenanceWindow{
|
||||||
|
MonitorID: 1,
|
||||||
|
Title: "Deploy v2",
|
||||||
|
Type: "maintenance",
|
||||||
|
StartTime: now.Add(-30 * time.Minute),
|
||||||
|
EndTime: now.Add(30 * time.Minute),
|
||||||
|
}
|
||||||
|
if err := s.AddMaintenanceWindow(ctx, active); err != nil {
|
||||||
|
t.Fatalf("AddMaintenanceWindow: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ended := models.MaintenanceWindow{
|
||||||
|
MonitorID: 1,
|
||||||
|
Title: "Old deploy",
|
||||||
|
Type: "maintenance",
|
||||||
|
StartTime: now.Add(-3 * time.Hour),
|
||||||
|
EndTime: now.Add(-2 * time.Hour),
|
||||||
|
}
|
||||||
|
if err := s.AddMaintenanceWindow(ctx, ended); err != nil {
|
||||||
|
t.Fatalf("AddMaintenanceWindow: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("same monitor overlaps", func(t *testing.T) {
|
||||||
|
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, now.Add(1*time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(overlaps) != 1 {
|
||||||
|
t.Fatalf("expected 1 overlap, got %d", len(overlaps))
|
||||||
|
}
|
||||||
|
if overlaps[0].Title != "Deploy v2" {
|
||||||
|
t.Errorf("expected 'Deploy v2', got %q", overlaps[0].Title)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("different monitor no overlap", func(t *testing.T) {
|
||||||
|
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 99, now, now.Add(1*time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(overlaps) != 0 {
|
||||||
|
t.Errorf("expected 0 overlaps, got %d", len(overlaps))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("global window overlaps all", func(t *testing.T) {
|
||||||
|
global := models.MaintenanceWindow{
|
||||||
|
MonitorID: 0,
|
||||||
|
Title: "Global freeze",
|
||||||
|
Type: "maintenance",
|
||||||
|
StartTime: now.Add(-10 * time.Minute),
|
||||||
|
EndTime: now.Add(2 * time.Hour),
|
||||||
|
}
|
||||||
|
if err := s.AddMaintenanceWindow(ctx, global); err != nil {
|
||||||
|
t.Fatalf("AddMaintenanceWindow: %v", err)
|
||||||
|
}
|
||||||
|
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, now.Add(1*time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(overlaps) != 2 {
|
||||||
|
t.Errorf("expected 2 overlaps (specific + global), got %d", len(overlaps))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("indefinite window overlaps", func(t *testing.T) {
|
||||||
|
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, time.Time{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(overlaps) < 1 {
|
||||||
|
t.Error("expected at least 1 overlap for indefinite window")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ended window excluded", func(t *testing.T) {
|
||||||
|
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now.Add(-4*time.Hour), now.Add(-3*time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(overlaps) != 0 {
|
||||||
|
t.Errorf("expected 0 overlaps for past range, got %d", len(overlaps))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,9 +3,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
)
|
)
|
||||||
@@ -74,60 +72,6 @@ func TestSiteCRUD(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertCRUD(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
if err := s.AddAlert(context.Background(), "Discord", "discord", map[string]string{"url": "https://example.com/hook"}); err != nil {
|
|
||||||
t.Fatalf("AddAlert: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
alerts, err := s.GetAllAlerts(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetAllAlerts: %v", err)
|
|
||||||
}
|
|
||||||
if len(alerts) != 1 {
|
|
||||||
t.Fatalf("expected 1 alert, got %d", len(alerts))
|
|
||||||
}
|
|
||||||
if alerts[0].Type != "discord" {
|
|
||||||
t.Errorf("expected type 'discord', got '%s'", alerts[0].Type)
|
|
||||||
}
|
|
||||||
if alerts[0].Settings["url"] != "https://example.com/hook" {
|
|
||||||
t.Errorf("settings url mismatch")
|
|
||||||
}
|
|
||||||
|
|
||||||
a, err := s.GetAlert(context.Background(), alerts[0].ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetAlert: %v", err)
|
|
||||||
}
|
|
||||||
if a.Name != "Discord" {
|
|
||||||
t.Errorf("expected name 'Discord', got '%s'", a.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.UpdateAlert(context.Background(), a.ID, "Slack", "slack", map[string]string{"url": "https://slack.com/hook"}); err != nil {
|
|
||||||
t.Fatalf("UpdateAlert: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
a, err = s.GetAlert(context.Background(), a.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetAlert: %v", err)
|
|
||||||
}
|
|
||||||
if a.Type != "slack" {
|
|
||||||
t.Errorf("expected type 'slack', got '%s'", a.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.DeleteAlert(context.Background(), a.ID); err != nil {
|
|
||||||
t.Fatalf("DeleteAlert: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
alerts, err = s.GetAllAlerts(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetAllAlerts: %v", err)
|
|
||||||
}
|
|
||||||
if len(alerts) != 0 {
|
|
||||||
t.Fatalf("expected 0 alerts after delete, got %d", len(alerts))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserCRUD(t *testing.T) {
|
func TestUserCRUD(t *testing.T) {
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
|
|
||||||
@@ -301,101 +245,6 @@ func TestImportData_NilUsersPreservesExisting(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckHistory(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
if err := s.SaveCheck(context.Background(), 1, 5000000, true); err != nil {
|
|
||||||
t.Fatalf("SaveCheck: %v", err)
|
|
||||||
}
|
|
||||||
if err := s.SaveCheck(context.Background(), 1, 10000000, false); err != nil {
|
|
||||||
t.Fatalf("SaveCheck: %v", err)
|
|
||||||
}
|
|
||||||
if err := s.SaveCheck(context.Background(), 2, 3000000, true); err != nil {
|
|
||||||
t.Fatalf("SaveCheck site 2: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
history, err := s.LoadAllHistory(context.Background(), 10)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadAllHistory: %v", err)
|
|
||||||
}
|
|
||||||
if len(history[1]) != 2 {
|
|
||||||
t.Fatalf("expected 2 records for site 1, got %d", len(history[1]))
|
|
||||||
}
|
|
||||||
if len(history[2]) != 1 {
|
|
||||||
t.Fatalf("expected 1 record for site 2, got %d", len(history[2]))
|
|
||||||
}
|
|
||||||
|
|
||||||
upCount := 0
|
|
||||||
for _, r := range history[1] {
|
|
||||||
if r.IsUp {
|
|
||||||
upCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if upCount != 1 {
|
|
||||||
t.Errorf("expected 1 up record for site 1, got %d", upCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteSiteCascade(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
site := models.SiteConfig{Name: "Cascade Test", URL: "https://example.com", Interval: 30}
|
|
||||||
if err := s.AddSite(context.Background(), site); err != nil {
|
|
||||||
t.Fatalf("AddSite: %v", err)
|
|
||||||
}
|
|
||||||
sites, err := s.GetSites(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSites: %v", err)
|
|
||||||
}
|
|
||||||
siteID := sites[0].ID
|
|
||||||
|
|
||||||
if err := s.SaveCheck(context.Background(), siteID, 1000, true); err != nil {
|
|
||||||
t.Fatalf("SaveCheck: %v", err)
|
|
||||||
}
|
|
||||||
if err := s.SaveStateChange(context.Background(), siteID, "UP", "DOWN", "timeout"); err != nil {
|
|
||||||
t.Fatalf("SaveStateChange: %v", err)
|
|
||||||
}
|
|
||||||
mw := models.MaintenanceWindow{
|
|
||||||
MonitorID: siteID,
|
|
||||||
Title: "Test MW",
|
|
||||||
Type: "maintenance",
|
|
||||||
StartTime: time.Now(),
|
|
||||||
}
|
|
||||||
if err := s.AddMaintenanceWindow(context.Background(), mw); err != nil {
|
|
||||||
t.Fatalf("AddMaintenanceWindow: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.DeleteSite(context.Background(), siteID); err != nil {
|
|
||||||
t.Fatalf("DeleteSite: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
history, err := s.LoadAllHistory(context.Background(), 100)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadAllHistory: %v", err)
|
|
||||||
}
|
|
||||||
if len(history[siteID]) != 0 {
|
|
||||||
t.Errorf("expected 0 check_history rows, got %d", len(history[siteID]))
|
|
||||||
}
|
|
||||||
|
|
||||||
changes, err := s.GetStateChanges(context.Background(), siteID, 100)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetStateChanges: %v", err)
|
|
||||||
}
|
|
||||||
if len(changes) != 0 {
|
|
||||||
t.Errorf("expected 0 state_changes rows, got %d", len(changes))
|
|
||||||
}
|
|
||||||
|
|
||||||
windows, err := s.GetActiveMaintenanceWindows(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetActiveMaintenanceWindows: %v", err)
|
|
||||||
}
|
|
||||||
for _, w := range windows {
|
|
||||||
if w.MonitorID == siteID {
|
|
||||||
t.Errorf("orphaned maintenance window found: id=%d", w.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPruneLogs(t *testing.T) {
|
func TestPruneLogs(t *testing.T) {
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
|
|
||||||
@@ -428,231 +277,3 @@ func TestPruneLogs(t *testing.T) {
|
|||||||
t.Error("oldest log survived prune")
|
t.Error("oldest log survived prune")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPruneCheckHistory(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
for i := 0; i < maxCheckHistory+5; i++ {
|
|
||||||
if err := s.SaveCheck(context.Background(), 1, int64(i), true); err != nil {
|
|
||||||
t.Fatalf("SaveCheck site 1: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
if err := s.SaveCheck(context.Background(), 2, int64(i), true); err != nil {
|
|
||||||
t.Fatalf("SaveCheck site 2: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.PruneCheckHistory(context.Background()); err != nil {
|
|
||||||
t.Fatalf("PruneCheckHistory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
history, err := s.LoadAllHistory(context.Background(), maxCheckHistory*2)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadAllHistory: %v", err)
|
|
||||||
}
|
|
||||||
if len(history[1]) != maxCheckHistory {
|
|
||||||
t.Errorf("site 1: expected %d rows after prune, got %d", maxCheckHistory, len(history[1]))
|
|
||||||
}
|
|
||||||
if len(history[2]) != 3 {
|
|
||||||
t.Errorf("site 2: expected 3 rows untouched, got %d", len(history[2]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPruneExpiredMaintenanceWindows(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
// Expired 10 days ago — should be pruned with 7d retention.
|
|
||||||
old := models.MaintenanceWindow{
|
|
||||||
MonitorID: 0,
|
|
||||||
Title: "Old Window",
|
|
||||||
Type: "maintenance",
|
|
||||||
StartTime: now.Add(-11 * 24 * time.Hour),
|
|
||||||
EndTime: now.Add(-10 * 24 * time.Hour),
|
|
||||||
}
|
|
||||||
if err := s.AddMaintenanceWindow(context.Background(), old); err != nil {
|
|
||||||
t.Fatalf("AddMaintenanceWindow (old): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expired 1 day ago — within 7d retention, should survive.
|
|
||||||
recent := models.MaintenanceWindow{
|
|
||||||
MonitorID: 0,
|
|
||||||
Title: "Recent Window",
|
|
||||||
Type: "maintenance",
|
|
||||||
StartTime: now.Add(-2 * 24 * time.Hour),
|
|
||||||
EndTime: now.Add(-1 * 24 * time.Hour),
|
|
||||||
}
|
|
||||||
if err := s.AddMaintenanceWindow(context.Background(), recent); err != nil {
|
|
||||||
t.Fatalf("AddMaintenanceWindow (recent): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ongoing — no end time, should survive.
|
|
||||||
ongoing := models.MaintenanceWindow{
|
|
||||||
MonitorID: 0,
|
|
||||||
Title: "Ongoing Window",
|
|
||||||
Type: "maintenance",
|
|
||||||
StartTime: now.Add(-1 * time.Hour),
|
|
||||||
}
|
|
||||||
if err := s.AddMaintenanceWindow(context.Background(), ongoing); err != nil {
|
|
||||||
t.Fatalf("AddMaintenanceWindow (ongoing): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pruned, err := s.PruneExpiredMaintenanceWindows(context.Background(), 7*24*time.Hour)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PruneExpiredMaintenanceWindows: %v", err)
|
|
||||||
}
|
|
||||||
if pruned != 1 {
|
|
||||||
t.Errorf("expected 1 pruned, got %d", pruned)
|
|
||||||
}
|
|
||||||
|
|
||||||
all, err := s.GetAllMaintenanceWindows(context.Background(), 100)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetAllMaintenanceWindows: %v", err)
|
|
||||||
}
|
|
||||||
if len(all) != 2 {
|
|
||||||
t.Fatalf("expected 2 remaining windows, got %d", len(all))
|
|
||||||
}
|
|
||||||
for _, w := range all {
|
|
||||||
if w.Title == "Old Window" {
|
|
||||||
t.Error("old window should have been pruned")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetOverlappingMaintenanceWindows(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
site := models.SiteConfig{Name: "web", URL: "https://example.com", Interval: 30}
|
|
||||||
if err := s.AddSite(ctx, site); err != nil {
|
|
||||||
t.Fatalf("AddSite: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
active := models.MaintenanceWindow{
|
|
||||||
MonitorID: 1,
|
|
||||||
Title: "Deploy v2",
|
|
||||||
Type: "maintenance",
|
|
||||||
StartTime: now.Add(-30 * time.Minute),
|
|
||||||
EndTime: now.Add(30 * time.Minute),
|
|
||||||
}
|
|
||||||
if err := s.AddMaintenanceWindow(ctx, active); err != nil {
|
|
||||||
t.Fatalf("AddMaintenanceWindow: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ended := models.MaintenanceWindow{
|
|
||||||
MonitorID: 1,
|
|
||||||
Title: "Old deploy",
|
|
||||||
Type: "maintenance",
|
|
||||||
StartTime: now.Add(-3 * time.Hour),
|
|
||||||
EndTime: now.Add(-2 * time.Hour),
|
|
||||||
}
|
|
||||||
if err := s.AddMaintenanceWindow(ctx, ended); err != nil {
|
|
||||||
t.Fatalf("AddMaintenanceWindow: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("same monitor overlaps", func(t *testing.T) {
|
|
||||||
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, now.Add(1*time.Hour))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if len(overlaps) != 1 {
|
|
||||||
t.Fatalf("expected 1 overlap, got %d", len(overlaps))
|
|
||||||
}
|
|
||||||
if overlaps[0].Title != "Deploy v2" {
|
|
||||||
t.Errorf("expected 'Deploy v2', got %q", overlaps[0].Title)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("different monitor no overlap", func(t *testing.T) {
|
|
||||||
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 99, now, now.Add(1*time.Hour))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if len(overlaps) != 0 {
|
|
||||||
t.Errorf("expected 0 overlaps, got %d", len(overlaps))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("global window overlaps all", func(t *testing.T) {
|
|
||||||
global := models.MaintenanceWindow{
|
|
||||||
MonitorID: 0,
|
|
||||||
Title: "Global freeze",
|
|
||||||
Type: "maintenance",
|
|
||||||
StartTime: now.Add(-10 * time.Minute),
|
|
||||||
EndTime: now.Add(2 * time.Hour),
|
|
||||||
}
|
|
||||||
if err := s.AddMaintenanceWindow(ctx, global); err != nil {
|
|
||||||
t.Fatalf("AddMaintenanceWindow: %v", err)
|
|
||||||
}
|
|
||||||
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, now.Add(1*time.Hour))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if len(overlaps) != 2 {
|
|
||||||
t.Errorf("expected 2 overlaps (specific + global), got %d", len(overlaps))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("indefinite window overlaps", func(t *testing.T) {
|
|
||||||
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, time.Time{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if len(overlaps) < 1 {
|
|
||||||
t.Error("expected at least 1 overlap for indefinite window")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("ended window excluded", func(t *testing.T) {
|
|
||||||
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now.Add(-4*time.Hour), now.Add(-3*time.Hour))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if len(overlaps) != 0 {
|
|
||||||
t.Errorf("expected 0 overlaps for past range, got %d", len(overlaps))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImportData must encrypt alert settings (like AddAlert/UpdateAlert) so a
|
|
||||||
// restore with UPTOP_ENCRYPTION_KEY set never lands secrets in plaintext.
|
|
||||||
func TestImportData_EncryptsAlertSettings(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
enc, err := NewEncryptor(strings.Repeat("ab", 32)) // 64 hex chars = 32 bytes
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewEncryptor: %v", err)
|
|
||||||
}
|
|
||||||
s.SetEncryptor(enc)
|
|
||||||
|
|
||||||
backup := models.Backup{
|
|
||||||
Alerts: []models.AlertConfig{
|
|
||||||
{ID: 1, Name: "tg", Type: "telegram", Settings: map[string]string{"token": "123:SECRET", "chat_id": "42"}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if err := s.ImportData(context.Background(), backup); err != nil {
|
|
||||||
t.Fatalf("ImportData: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var raw string
|
|
||||||
if err := s.db.QueryRow("SELECT settings FROM alerts WHERE id = 1").Scan(&raw); err != nil {
|
|
||||||
t.Fatalf("query settings: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(raw, encryptedPrefix) {
|
|
||||||
t.Errorf("imported settings not encrypted: %q", raw)
|
|
||||||
}
|
|
||||||
if strings.Contains(raw, "SECRET") {
|
|
||||||
t.Errorf("plaintext secret found in stored column: %q", raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
alerts, err := s.GetAllAlerts(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetAllAlerts: %v", err)
|
|
||||||
}
|
|
||||||
if len(alerts) != 1 || alerts[0].Settings["token"] != "123:SECRET" {
|
|
||||||
t.Errorf("decrypt round-trip failed: %+v", alerts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
+29
-10
@@ -13,9 +13,9 @@ import (
|
|||||||
tea "github.com/charmbracelet/bubbletea"
|
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)
|
m := make(map[int]bool)
|
||||||
raw, err := s.GetPreference(context.Background(), "collapsed_groups")
|
raw, err := s.GetPreference(ctx, "collapsed_groups")
|
||||||
if err != nil || raw == "" {
|
if err != nil || raw == "" {
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
@@ -50,6 +50,21 @@ func writeCmd(op string, fn func() error) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) saveBottomPanelPref() tea.Cmd {
|
||||||
|
v := "logs"
|
||||||
|
switch m.bottomPanel {
|
||||||
|
case bottomNone:
|
||||||
|
v = "none"
|
||||||
|
case bottomMaint:
|
||||||
|
v = "maint"
|
||||||
|
}
|
||||||
|
st := m.store
|
||||||
|
ctx := m.ctx
|
||||||
|
return writeCmd("Save bottom panel preference", func() error {
|
||||||
|
return st.SetPreference(ctx, "bottom_panel", v)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool, sortCol int, sortAsc bool) []models.Site {
|
func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool, sortCol int, sortAsc bool) []models.Site {
|
||||||
var groups, ungrouped []models.Site
|
var groups, ungrouped []models.Site
|
||||||
children := make(map[int][]models.Site)
|
children := make(map[int][]models.Site)
|
||||||
@@ -123,9 +138,10 @@ func (m *Model) refreshLive() {
|
|||||||
ordered = filterSites(ordered, m.filterText)
|
ordered = filterSites(ordered, m.filterText)
|
||||||
}
|
}
|
||||||
m.sites = ordered
|
m.sites = ordered
|
||||||
|
m.buildMaintSet()
|
||||||
m.refreshLogContent()
|
m.refreshLogContent()
|
||||||
|
|
||||||
if m.currentTab == tabMonitors && m.selectedID != 0 {
|
if m.selectedID != 0 {
|
||||||
for i, s := range m.sites {
|
for i, s := range m.sites {
|
||||||
if s.ID == m.selectedID {
|
if s.ID == m.selectedID {
|
||||||
m.cursor = i
|
m.cursor = i
|
||||||
@@ -137,7 +153,7 @@ func (m *Model) refreshLive() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) syncSelectedID() {
|
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
|
m.selectedID = m.sites[m.cursor].ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,9 +179,9 @@ func (m *Model) loadTabDataCmd() tea.Cmd {
|
|||||||
m.tabSeq++
|
m.tabSeq++
|
||||||
seq := m.tabSeq
|
seq := m.tabSeq
|
||||||
st := m.store
|
st := m.store
|
||||||
|
ctx := m.ctx
|
||||||
isAdmin := m.isAdmin
|
isAdmin := m.isAdmin
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
ctx := context.Background()
|
|
||||||
alerts, err := st.GetAllAlerts(ctx)
|
alerts, err := st.GetAllAlerts(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return tabDataMsg{seq: seq, err: err}
|
return tabDataMsg{seq: seq, err: err}
|
||||||
@@ -192,6 +208,7 @@ func (m *Model) loadTabDataCmd() tea.Cmd {
|
|||||||
// goroutine. View renders the cached result rather than querying the DB.
|
// goroutine. View renders the cached result rather than querying the DB.
|
||||||
func (m *Model) loadDetailCmd(siteID int) tea.Cmd {
|
func (m *Model) loadDetailCmd(siteID int) tea.Cmd {
|
||||||
eng := m.engine
|
eng := m.engine
|
||||||
|
ctx := m.ctx
|
||||||
var currentStatus models.Status
|
var currentStatus models.Status
|
||||||
for _, s := range m.sites {
|
for _, s := range m.sites {
|
||||||
if s.ID == siteID {
|
if s.ID == siteID {
|
||||||
@@ -200,10 +217,10 @@ func (m *Model) loadDetailCmd(siteID int) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
changes := eng.GetStateChanges(siteID, 5)
|
changes := eng.GetStateChanges(ctx, siteID, 5)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
allChanges := eng.GetStateChangesSince(siteID, now.Add(-30*24*time.Hour))
|
allChanges := eng.GetStateChangesSince(ctx, siteID, now.Add(-stateHistoryLookback))
|
||||||
daily := monitor.ComputeDailyBreakdown(allChanges, currentStatus, 30, now)
|
daily := monitor.ComputeDailyBreakdown(allChanges, currentStatus, stateHistoryDays, now)
|
||||||
return detailDataMsg{siteID: siteID, changes: changes, dailyDays: daily}
|
return detailDataMsg{siteID: siteID, changes: changes, dailyDays: daily}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,8 +229,9 @@ func (m *Model) loadDetailCmd(siteID int) tea.Cmd {
|
|||||||
// the UI goroutine.
|
// the UI goroutine.
|
||||||
func (m *Model) loadHistoryCmd(siteID int) tea.Cmd {
|
func (m *Model) loadHistoryCmd(siteID int) tea.Cmd {
|
||||||
eng := m.engine
|
eng := m.engine
|
||||||
|
ctx := m.ctx
|
||||||
return func() tea.Msg {
|
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 +240,13 @@ func (m *Model) loadHistoryCmd(siteID int) tea.Cmd {
|
|||||||
// can be recognized and dropped.
|
// can be recognized and dropped.
|
||||||
func (m *Model) loadSLACmd(siteID, periodIdx int) tea.Cmd {
|
func (m *Model) loadSLACmd(siteID, periodIdx int) tea.Cmd {
|
||||||
eng := m.engine
|
eng := m.engine
|
||||||
|
ctx := m.ctx
|
||||||
since := time.Now().Add(-slaPeriods[periodIdx].duration)
|
since := time.Now().Add(-slaPeriods[periodIdx].duration)
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
return slaDataMsg{
|
return slaDataMsg{
|
||||||
siteID: siteID,
|
siteID: siteID,
|
||||||
periodIdx: periodIdx,
|
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") {
|
if strings.HasPrefix(lower, "http ") || strings.Contains(lower, "keyword not found") {
|
||||||
return ErrCatHTTP
|
return ErrCatHTTP
|
||||||
}
|
}
|
||||||
if statusCode >= 400 {
|
if statusCode >= httpErrorThreshold {
|
||||||
return ErrCatHTTP
|
return ErrCatHTTP
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@ func extractDetail(errorReason string, cat ErrorCategory, statusCode int) string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if detail == "" {
|
if detail == "" {
|
||||||
detail = limitStr(errorReason, 30)
|
detail = limitStr(errorReason, errorDetailMaxLen)
|
||||||
}
|
}
|
||||||
case ErrCatTCP:
|
case ErrCatTCP:
|
||||||
for _, keyword := range []string{"connection refused", "connection reset", "no route to host", "network unreachable"} {
|
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 == "" {
|
if detail == "" {
|
||||||
detail = limitStr(errorReason, 30)
|
detail = limitStr(errorReason, errorDetailMaxLen)
|
||||||
}
|
}
|
||||||
case ErrCatTLS:
|
case ErrCatTLS:
|
||||||
for _, keyword := range []string{"certificate expired", "certificate has expired", "handshake failure", "unknown authority"} {
|
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:") {
|
if detail == "" && strings.Contains(lower, "x509:") {
|
||||||
idx := strings.Index(lower, "x509:")
|
idx := strings.Index(lower, "x509:")
|
||||||
detail = limitStr(errorReason[idx:], 30)
|
detail = limitStr(errorReason[idx:], errorDetailMaxLen)
|
||||||
}
|
}
|
||||||
if detail == "" {
|
if detail == "" {
|
||||||
detail = limitStr(errorReason, 30)
|
detail = limitStr(errorReason, errorDetailMaxLen)
|
||||||
}
|
}
|
||||||
case ErrCatHTTP:
|
case ErrCatHTTP:
|
||||||
if statusCode > 0 {
|
if statusCode > 0 {
|
||||||
detail = fmt.Sprintf("HTTP %d", statusCode)
|
detail = fmt.Sprintf("HTTP %d", statusCode)
|
||||||
} else {
|
} else {
|
||||||
detail = limitStr(errorReason, 30)
|
detail = limitStr(errorReason, errorDetailMaxLen)
|
||||||
}
|
}
|
||||||
case ErrCatTimeout:
|
case ErrCatTimeout:
|
||||||
if strings.Contains(lower, "i/o timeout") {
|
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") {
|
if strings.Contains(lower, "no icmp response") {
|
||||||
detail = "no response"
|
detail = "no response"
|
||||||
} else {
|
} else {
|
||||||
detail = limitStr(errorReason, 30)
|
detail = limitStr(errorReason, errorDetailMaxLen)
|
||||||
}
|
}
|
||||||
case ErrCatPrivate:
|
case ErrCatPrivate:
|
||||||
detail = "private IP blocked"
|
detail = "private IP blocked"
|
||||||
default:
|
default:
|
||||||
detail = limitStr(errorReason, 30)
|
detail = limitStr(errorReason, errorDetailMaxLen)
|
||||||
}
|
}
|
||||||
|
|
||||||
return detail
|
return detail
|
||||||
|
|||||||
+32
-4
@@ -49,13 +49,13 @@ func siteOrder(s models.Site) int {
|
|||||||
return 3
|
return 3
|
||||||
}
|
}
|
||||||
switch s.Status {
|
switch s.Status {
|
||||||
case "DOWN", "SSL EXP":
|
case models.StatusDown, models.StatusSSLExp:
|
||||||
return 0
|
return 0
|
||||||
case "STALE":
|
case models.StatusStale:
|
||||||
return 1
|
return 1
|
||||||
case "LATE":
|
case models.StatusLate:
|
||||||
return 1
|
return 1
|
||||||
case "PENDING":
|
case models.StatusPending:
|
||||||
return 3
|
return 3
|
||||||
default:
|
default:
|
||||||
return 2
|
return 2
|
||||||
@@ -104,6 +104,13 @@ func (m Model) fmtLatency(d time.Duration) string {
|
|||||||
return m.st.dangerStyle.Render(s)
|
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 {
|
func (m Model) fmtUptime(statuses []bool) string {
|
||||||
if len(statuses) == 0 {
|
if len(statuses) == 0 {
|
||||||
return m.st.subtleStyle.Render("—")
|
return m.st.subtleStyle.Render("—")
|
||||||
@@ -155,6 +162,27 @@ func (m Model) fmtRetries(site models.Site) string {
|
|||||||
return s
|
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 {
|
func (m Model) fmtStatus(status models.Status, paused bool, inMaint bool) string {
|
||||||
if paused {
|
if paused {
|
||||||
return m.st.warnStyle.Render("◇ PAUSED")
|
return m.st.warnStyle.Render("◇ PAUSED")
|
||||||
|
|||||||
@@ -6,6 +6,131 @@ import (
|
|||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type scrollbar struct {
|
||||||
|
pos int
|
||||||
|
total int
|
||||||
|
visible int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) titledPanelH(title, content, footer string, width, height, scrollOffset int, sb scrollbar, 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]
|
||||||
|
|
||||||
|
if sb.total == 0 && len(contentLines) > bodyH {
|
||||||
|
sb = scrollbar{pos: scrollOffset, total: len(contentLines), visible: bodyH}
|
||||||
|
}
|
||||||
|
sbVisible := sb.visible
|
||||||
|
if sbVisible <= 0 {
|
||||||
|
sbVisible = bodyH
|
||||||
|
}
|
||||||
|
showScrollbar := sb.total > 0 && sb.total > sbVisible
|
||||||
|
var thumbStart, thumbEnd int
|
||||||
|
if showScrollbar {
|
||||||
|
thumbSize := bodyH * sbVisible / sb.total
|
||||||
|
if thumbSize < 1 {
|
||||||
|
thumbSize = 1
|
||||||
|
}
|
||||||
|
scrollRange := sb.total - sbVisible
|
||||||
|
if scrollRange < 1 {
|
||||||
|
scrollRange = 1
|
||||||
|
}
|
||||||
|
trackSpace := bodyH - thumbSize
|
||||||
|
thumbStart = sb.pos * trackSpace / scrollRange
|
||||||
|
if thumbStart < 0 {
|
||||||
|
thumbStart = 0
|
||||||
|
}
|
||||||
|
thumbEnd = thumbStart + thumbSize
|
||||||
|
if thumbEnd > bodyH {
|
||||||
|
thumbEnd = bodyH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollTrack := lipgloss.NewStyle().Foreground(m.theme.Border).Render("░")
|
||||||
|
scrollThumb := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("█")
|
||||||
|
|
||||||
|
borderLine := func(line string, idx int) string {
|
||||||
|
rightBorder := bc.Render("│")
|
||||||
|
if showScrollbar && idx >= thumbStart && idx < thumbEnd {
|
||||||
|
rightBorder = scrollThumb
|
||||||
|
} else if showScrollbar {
|
||||||
|
rightBorder = scrollTrack
|
||||||
|
}
|
||||||
|
return bc.Render("│") + line + strings.Repeat(" ", max(0, innerW-lipgloss.Width(line))) + rightBorder
|
||||||
|
}
|
||||||
|
emptyLine := func(idx int) string {
|
||||||
|
return borderLine(strings.Repeat(" ", innerW), idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, top)
|
||||||
|
for i, line := range visible {
|
||||||
|
lines = append(lines, borderLine(line, i))
|
||||||
|
}
|
||||||
|
for i := len(visible); len(lines) < height-1-len(footerLines); i++ {
|
||||||
|
lines = append(lines, emptyLine(i))
|
||||||
|
}
|
||||||
|
for _, line := range footerLines {
|
||||||
|
lines = append(lines, borderLine(line, -1))
|
||||||
|
}
|
||||||
|
lines = append(lines, bottom)
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) titledPanel(title, content string, width int, focused bool) string {
|
func (m Model) titledPanel(title, content string, width int, focused bool) string {
|
||||||
borderColor := m.theme.Border
|
borderColor := m.theme.Border
|
||||||
titleColor := m.theme.Muted
|
titleColor := m.theme.Muted
|
||||||
@@ -50,3 +175,7 @@ func max(a, b int) int {
|
|||||||
}
|
}
|
||||||
return b
|
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 {
|
func (m Model) groupSparkline(groupID int, width int, bg lipgloss.TerminalColor) string {
|
||||||
allSites := m.engine.GetAllSites()
|
|
||||||
var childStatuses [][]bool
|
var childStatuses [][]bool
|
||||||
for _, s := range allSites {
|
for _, s := range m.sites {
|
||||||
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
|
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
|
||||||
hist, _ := m.engine.GetHistory(s.ID)
|
hist, _ := m.engine.GetHistory(s.ID)
|
||||||
if len(hist.Statuses) > 0 {
|
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 {
|
func (m Model) groupUptime(groupID int) string {
|
||||||
allSites := m.engine.GetAllSites()
|
|
||||||
var allStatuses [][]bool
|
var allStatuses [][]bool
|
||||||
for _, s := range allSites {
|
for _, s := range m.sites {
|
||||||
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
|
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
|
||||||
hist, _ := m.engine.GetHistory(s.ID)
|
hist, _ := m.engine.GetHistory(s.ID)
|
||||||
if len(hist.Statuses) > 0 {
|
if len(hist.Statuses) > 0 {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
neturl "net/url"
|
neturl "net/url"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -224,14 +223,83 @@ func (m Model) viewAlertsTab() string {
|
|||||||
summary := fmt.Sprintf("%d channels · %d types · %d sent · %d %s",
|
summary := fmt.Sprintf("%d channels · %d types · %d sent · %d %s",
|
||||||
len(m.alerts), len(types), totalSent, totalFail, failLabel)
|
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 {
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
a := m.alerts[m.cursor]
|
a := m.alerts[idx]
|
||||||
h := m.engine.GetAlertHealth(a.ID)
|
h := m.engine.GetAlertHealth(a.ID)
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
@@ -264,7 +332,7 @@ func (m Model) viewAlertDetailPanel() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString(m.divider() + "\n")
|
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
|
// Render through the same allowlist the backup export uses — this panel
|
||||||
// ends up in screen shares and asciinema recordings. Keys are sorted so
|
// ends up in screen shares and asciinema recordings. Keys are sorted so
|
||||||
// rows don't reshuffle every render.
|
// rows don't reshuffle every render.
|
||||||
@@ -284,7 +352,7 @@ func (m Model) viewAlertDetailPanel() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString(m.divider() + "\n")
|
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())
|
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
|
||||||
}
|
}
|
||||||
@@ -537,15 +605,21 @@ func (m *Model) submitAlertForm() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
st := m.store
|
st := m.store
|
||||||
|
ctx := m.ctx
|
||||||
id := m.editID
|
id := m.editID
|
||||||
name, aType := d.Name, d.AlertType
|
name, aType := d.Name, d.AlertType
|
||||||
|
if m.returnState == stateSettings {
|
||||||
|
m.state = stateSettings
|
||||||
|
} else {
|
||||||
m.state = stateDashboard
|
m.state = stateDashboard
|
||||||
|
}
|
||||||
|
m.returnState = 0
|
||||||
if id > 0 {
|
if id > 0 {
|
||||||
return writeCmd("Update alert", func() error {
|
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 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"
|
"strings"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
type logSeverity int
|
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)
|
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() {
|
func (m *Model) refreshLogContent() {
|
||||||
var rendered []string
|
var rendered []string
|
||||||
total := 0
|
total := 0
|
||||||
|
|||||||
@@ -40,13 +40,13 @@ func (m Model) renderCompactLogLine(entry models.LogEntry, maxW int) string {
|
|||||||
return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg
|
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()
|
logs := m.engine.GetLogs()
|
||||||
if len(logs) == 0 {
|
if len(logs) == 0 {
|
||||||
return m.st.subtleStyle.Render(" No logs yet")
|
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
|
var all []string
|
||||||
for _, entry := range logs {
|
for _, entry := range logs {
|
||||||
@@ -69,7 +69,21 @@ func (m Model) viewLogsSidebar(width, maxLines int) string {
|
|||||||
}
|
}
|
||||||
visible := all[start:end]
|
visible := all[start:end]
|
||||||
|
|
||||||
return sidebarStyle.Render(strings.Join(visible, "\n"))
|
return style.Render(strings.Join(visible, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) filteredLogCount() int {
|
||||||
|
count := 0
|
||||||
|
for _, entry := range m.engine.GetLogs() {
|
||||||
|
if strings.TrimSpace(entry.Message) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.logFilterImportant && !isImportantLog(classifyLog(entry.Message)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) scrollLogs(delta int) {
|
func (m *Model) scrollLogs(delta int) {
|
||||||
|
|||||||
+17
-111
@@ -1,7 +1,6 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -21,130 +20,37 @@ type maintFormData struct {
|
|||||||
CustomHours string
|
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 {
|
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 {
|
for _, mw := range m.maintenanceWindows {
|
||||||
if mw.Type != "maintenance" {
|
if mw.Type != "maintenance" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
now := time.Now()
|
|
||||||
if mw.StartTime.After(now) {
|
if mw.StartTime.After(now) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
|
if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if mw.MonitorID == 0 || mw.MonitorID == monitorID {
|
if mw.MonitorID == 0 {
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, s := range m.sites {
|
for _, s := range m.sites {
|
||||||
if s.ID == monitorID && s.ParentID > 0 && mw.MonitorID == s.ParentID {
|
set[s.ID] = true
|
||||||
return true
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
set[mw.MonitorID] = true
|
||||||
|
for _, s := range m.sites {
|
||||||
|
if s.ParentID == mw.MonitorID {
|
||||||
|
set[s.ID] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
m.maintSet = set
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) viewMaintTab() string {
|
|
||||||
if len(m.maintenanceWindows) == 0 {
|
|
||||||
return m.emptyState("No maintenance windows or incidents.", "[n] Create one")
|
|
||||||
}
|
|
||||||
|
|
||||||
var headers []string
|
|
||||||
var widths []int
|
|
||||||
if m.isWide() {
|
|
||||||
headers = []string{"#", "TITLE", "TYPE", "MONITORS", "STATUS", "STARTED", "ENDS"}
|
|
||||||
widths = []int{4, 24, 14, 22, 12, 16, 16}
|
|
||||||
} else {
|
|
||||||
headers = []string{"#", "TITLE", "TYPE", "MON", "ST", "START", "ENDS"}
|
|
||||||
widths = []int{4, 14, 13, 14, 11, 14, 14}
|
|
||||||
}
|
|
||||||
titleW := widths[1]
|
|
||||||
monW := widths[3]
|
|
||||||
timeW := widths[5]
|
|
||||||
|
|
||||||
tbl := m.renderTable(
|
|
||||||
headers,
|
|
||||||
len(m.maintenanceWindows),
|
|
||||||
func(start, end int) [][]string {
|
|
||||||
var rows [][]string
|
|
||||||
allSites := m.engine.GetAllSites()
|
|
||||||
for i := start; i < end; i++ {
|
|
||||||
mw := m.maintenanceWindows[i]
|
|
||||||
rows = append(rows, []string{
|
|
||||||
strconv.Itoa(i + 1),
|
|
||||||
m.zones.Mark(fmt.Sprintf("maint-%d", i), limitStr(mw.Title, titleW-2)),
|
|
||||||
m.fmtMaintType(mw.Type),
|
|
||||||
fmtMaintMonitorW(mw.MonitorID, allSites, monW-2),
|
|
||||||
m.fmtMaintStatus(mw),
|
|
||||||
m.fmtMaintTime(mw.StartTime, timeW),
|
|
||||||
m.fmtMaintTime(mw.EndTime, timeW),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
},
|
|
||||||
widths,
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
var active, scheduled, ended int
|
|
||||||
for _, mw := range m.maintenanceWindows {
|
|
||||||
if mw.StartTime.After(now) {
|
|
||||||
scheduled++
|
|
||||||
} else if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
|
|
||||||
ended++
|
|
||||||
} else {
|
|
||||||
active++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
summary := fmt.Sprintf("%d active · %d scheduled · %d ended", active, scheduled, ended)
|
|
||||||
|
|
||||||
return tbl + "\n " + m.st.subtleStyle.Render(summary)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) initMaintHuhForm() tea.Cmd {
|
func (m *Model) initMaintHuhForm() tea.Cmd {
|
||||||
@@ -254,9 +160,9 @@ func (m *Model) submitMaintForm() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
st := m.store
|
st := m.store
|
||||||
|
ctx := m.ctx
|
||||||
m.state = stateDashboard
|
m.state = stateDashboard
|
||||||
return writeCmd("Add maintenance window", func() error {
|
return writeCmd("Add maintenance window", func() error {
|
||||||
ctx := context.Background()
|
|
||||||
overlaps, _ := st.GetOverlappingMaintenanceWindows(ctx, mw.MonitorID, mw.StartTime, mw.EndTime)
|
overlaps, _ := st.GetOverlappingMaintenanceWindows(ctx, mw.MonitorID, mw.StartTime, mw.EndTime)
|
||||||
if len(overlaps) > 0 {
|
if len(overlaps) > 0 {
|
||||||
_ = st.SaveLog(ctx, fmt.Sprintf("Overlap: new window %q overlaps with existing %q", mw.Title, overlaps[0].Title))
|
_ = 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 {
|
func (m Model) viewNodesTab() string {
|
||||||
if len(m.nodes) == 0 {
|
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 headers []string
|
||||||
var widths []int
|
var widths []int
|
||||||
if m.isWide() {
|
if m.isWide() {
|
||||||
headers = []string{"NAME", "REGION", "LAST SEEN", "VERSION", "STATUS"}
|
headers = []string{"#", "NAME", "REGION", "LAST SEEN", "VERSION", "STATUS"}
|
||||||
widths = []int{24, 14, 16, 12, 10}
|
widths = []int{4, 24, 14, 16, 12, 10}
|
||||||
} else {
|
} else {
|
||||||
headers = []string{"NAME", "REGION", "SEEN", "VER", "STATUS"}
|
headers = []string{"#", "NAME", "REGION", "SEEN", "VER", "STATUS"}
|
||||||
widths = []int{16, 10, 10, 8, 8}
|
widths = []int{4, 16, 10, 10, 8, 8}
|
||||||
}
|
}
|
||||||
nameW := widths[0]
|
nameW := widths[1]
|
||||||
|
|
||||||
tbl := m.renderTable(
|
tbl := m.renderTable(
|
||||||
headers,
|
headers,
|
||||||
@@ -43,7 +43,7 @@ func (m Model) viewNodesTab() string {
|
|||||||
version = m.st.subtleStyle.Render("—")
|
version = m.st.subtleStyle.Render("—")
|
||||||
}
|
}
|
||||||
status := m.fmtNodeStatus(node.LastSeen)
|
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
|
return rows
|
||||||
},
|
},
|
||||||
@@ -55,7 +55,7 @@ func (m Model) viewNodesTab() string {
|
|||||||
regions := make(map[string]bool)
|
regions := make(map[string]bool)
|
||||||
var leader string
|
var leader string
|
||||||
for _, n := range m.nodes {
|
for _, n := range m.nodes {
|
||||||
if time.Since(n.LastSeen) < 60*time.Second {
|
if time.Since(n.LastSeen) < nodeOnlineThreshold {
|
||||||
online++
|
online++
|
||||||
}
|
}
|
||||||
if n.Region != "" {
|
if n.Region != "" {
|
||||||
@@ -81,10 +81,10 @@ func (m Model) fmtNodeStatus(lastSeen time.Time) string {
|
|||||||
return m.st.subtleStyle.Render("UNKNOWN")
|
return m.st.subtleStyle.Render("UNKNOWN")
|
||||||
}
|
}
|
||||||
ago := time.Since(lastSeen)
|
ago := time.Since(lastSeen)
|
||||||
if ago < 60*time.Second {
|
if ago < nodeOnlineThreshold {
|
||||||
return m.st.specialStyle.Render("ONLINE")
|
return m.st.specialStyle.Render("ONLINE")
|
||||||
}
|
}
|
||||||
if ago < 5*time.Minute {
|
if ago < nodeStaleThreshold {
|
||||||
return m.st.warnStyle.Render("STALE")
|
return m.st.warnStyle.Render("STALE")
|
||||||
}
|
}
|
||||||
return m.st.dangerStyle.Render("OFFLINE")
|
return m.st.dangerStyle.Render("OFFLINE")
|
||||||
|
|||||||
@@ -1,28 +1,26 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) viewSettingsTab() string {
|
func (m Model) viewSettingsTab() string {
|
||||||
maxSections := 2
|
|
||||||
if m.isAdmin {
|
|
||||||
maxSections = 3
|
|
||||||
}
|
|
||||||
|
|
||||||
sections := []string{"Alerts", "Nodes"}
|
sections := []string{"Alerts", "Nodes"}
|
||||||
if m.isAdmin {
|
if m.isAdmin {
|
||||||
sections = append(sections, "Users")
|
sections = append(sections, "Users")
|
||||||
}
|
}
|
||||||
_ = maxSections
|
|
||||||
|
|
||||||
var tabs []string
|
var tabs []string
|
||||||
for i, name := range sections {
|
for i, name := range sections {
|
||||||
|
var rendered string
|
||||||
if i == m.settingsSection {
|
if i == m.settingsSection {
|
||||||
tabs = append(tabs, m.st.activeTab.Render(name))
|
rendered = m.st.activeTab.Render(name)
|
||||||
} else {
|
} 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...)
|
header := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
|
||||||
|
|
||||||
|
|||||||
+68
-24
@@ -1,7 +1,6 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -35,10 +34,9 @@ type siteFormData struct {
|
|||||||
type colKey int
|
type colKey int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
colNum colKey = iota
|
colDot colKey = iota
|
||||||
colName
|
colName
|
||||||
colType
|
colType
|
||||||
colStatus
|
|
||||||
colLatency
|
colLatency
|
||||||
colUptime
|
colUptime
|
||||||
colHistory
|
colHistory
|
||||||
@@ -56,17 +54,18 @@ type columnDef struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var siteColumns = []columnDef{
|
var siteColumns = []columnDef{
|
||||||
{colNum, "#", "#", 4, 4, 0},
|
{colDot, "", "", 3, 3, 0},
|
||||||
{colName, "NAME", "NAME", 0, 0, 0},
|
{colName, "NAME", "NAME", 0, 0, 0},
|
||||||
{colType, "TYPE", "TYPE", 10, 8, mediumBreakpoint},
|
{colType, "TYPE", "TYPE", 10, 8, 0},
|
||||||
{colStatus, "STATUS", "STATUS", 10, 10, 0},
|
|
||||||
{colLatency, "LATENCY", "LAT", 10, 7, 0},
|
{colLatency, "LATENCY", "LAT", 10, 7, 0},
|
||||||
{colUptime, "UPTIME", "UP%", 8, 8, mediumBreakpoint},
|
{colUptime, "UPTIME", "UP%", 8, 8, 0},
|
||||||
{colHistory, "HISTORY", "HISTORY", 0, 0, mediumBreakpoint},
|
{colHistory, "HISTORY", "HISTORY", 0, 0, 0},
|
||||||
{colSSL, "SSL", "SSL", 7, 5, wideBreakpoint},
|
{colSSL, "SSL", "SSL", 7, 5, 0},
|
||||||
{colRetries, "RETRIES", "RT", 9, 5, wideBreakpoint},
|
{colRetries, "RETRIES", "RT", 9, 5, 0},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var columnDropOrder = []colKey{colHistory, colRetries, colSSL, colUptime, colType}
|
||||||
|
|
||||||
type tableLayout struct {
|
type tableLayout struct {
|
||||||
nameW, sparkW int
|
nameW, sparkW int
|
||||||
headers []string
|
headers []string
|
||||||
@@ -77,17 +76,51 @@ type tableLayout struct {
|
|||||||
func (m Model) computeLayout() tableLayout {
|
func (m Model) computeLayout() tableLayout {
|
||||||
wide := m.isWide()
|
wide := m.isWide()
|
||||||
|
|
||||||
var active []colKey
|
|
||||||
var headers []string
|
|
||||||
var widths []int
|
|
||||||
var fixed int
|
|
||||||
|
|
||||||
cw := m.contentWidth
|
cw := m.contentWidth
|
||||||
if cw == 0 {
|
if cw == 0 {
|
||||||
cw = m.termWidth
|
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 {
|
for _, c := range siteColumns {
|
||||||
if c.minTerm > 0 && cw < c.minTerm {
|
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 dropped[c.key] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
active = append(active, c.key)
|
active = append(active, c.key)
|
||||||
@@ -107,10 +140,15 @@ func (m Model) computeLayout() tableLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sortColMap := map[int]colKey{
|
sortColMap := map[int]colKey{
|
||||||
sortStatus: colStatus,
|
sortStatus: colDot,
|
||||||
sortName: colName,
|
sortName: colName,
|
||||||
sortLatency: colLatency,
|
sortLatency: colLatency,
|
||||||
}
|
}
|
||||||
|
sortableKeys := map[colKey]string{
|
||||||
|
colDot: "sort-status",
|
||||||
|
colName: "sort-name",
|
||||||
|
colLatency: "sort-latency",
|
||||||
|
}
|
||||||
if sortedKey, ok := sortColMap[m.sortColumn]; ok {
|
if sortedKey, ok := sortColMap[m.sortColumn]; ok {
|
||||||
arrow := "▼"
|
arrow := "▼"
|
||||||
if m.sortAsc {
|
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)
|
numCols := len(headers)
|
||||||
borderOverhead := 2 + (numCols - 1)
|
borderOverhead := 2 + (numCols - 1)
|
||||||
@@ -236,11 +279,11 @@ func (m Model) viewSitesTab() string {
|
|||||||
if site.Type == "group" {
|
if site.Type == "group" {
|
||||||
groupRows[i-start] = true
|
groupRows[i-start] = true
|
||||||
icon := typeIcon("group", m.collapsed[site.ID])
|
icon := typeIcon("group", m.collapsed[site.ID])
|
||||||
|
inMaint := m.isMonitorInMaintenance(site.ID)
|
||||||
cells := map[colKey]string{
|
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)),
|
colName: m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-4)),
|
||||||
colType: "group",
|
colType: "group",
|
||||||
colStatus: m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
|
|
||||||
colLatency: m.st.subtleStyle.Render("—"),
|
colLatency: m.st.subtleStyle.Render("—"),
|
||||||
colUptime: m.groupUptime(site.ID),
|
colUptime: m.groupUptime(site.ID),
|
||||||
colHistory: m.groupSparkline(site.ID, sparkWidth, rowBg),
|
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)
|
spark = m.latencySparkline(hist.Latencies, hist.Statuses, sparkWidth, rowBg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inMaint := m.isMonitorInMaintenance(site.ID)
|
||||||
cells := map[colKey]string{
|
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),
|
colName: m.zones.Mark(fmt.Sprintf("site-%d", i), name),
|
||||||
colType: typeIcon(site.Type, false) + " " + site.Type,
|
colType: typeIcon(site.Type, false) + " " + site.Type,
|
||||||
colStatus: m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
|
|
||||||
colLatency: m.fmtLatency(site.Latency),
|
colLatency: m.fmtLatency(site.Latency),
|
||||||
colUptime: m.fmtUptime(hist.Statuses),
|
colUptime: m.fmtUptimeMaint(hist.Statuses, site.ID),
|
||||||
colHistory: spark,
|
colHistory: spark,
|
||||||
colSSL: m.fmtSSL(site),
|
colSSL: m.fmtSSL(site),
|
||||||
colRetries: m.fmtRetries(site),
|
colRetries: m.fmtRetries(site),
|
||||||
@@ -604,10 +647,11 @@ func (m *Model) submitSiteForm() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
st := m.store
|
st := m.store
|
||||||
|
ctx := m.ctx
|
||||||
m.state = stateDashboard
|
m.state = stateDashboard
|
||||||
if m.editID > 0 {
|
if m.editID > 0 {
|
||||||
m.engine.UpdateSiteConfig(cfg)
|
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
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
@@ -114,15 +113,21 @@ func (m *Model) initUserHuhForm() tea.Cmd {
|
|||||||
func (m *Model) submitUserForm() tea.Cmd {
|
func (m *Model) submitUserForm() tea.Cmd {
|
||||||
d := m.userFormData
|
d := m.userFormData
|
||||||
st := m.store
|
st := m.store
|
||||||
|
ctx := m.ctx
|
||||||
id := m.editID
|
id := m.editID
|
||||||
username, key, role := d.Username, d.PublicKey, d.Role
|
username, key, role := d.Username, d.PublicKey, d.Role
|
||||||
|
if m.returnState == stateSettings {
|
||||||
|
m.state = stateSettings
|
||||||
|
} else {
|
||||||
m.state = stateDashboard
|
m.state = stateDashboard
|
||||||
|
}
|
||||||
|
m.returnState = 0
|
||||||
if id > 0 {
|
if id > 0 {
|
||||||
return writeCmd("Update user", func() error {
|
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 writeCmd("Add user", func() error {
|
||||||
return st.AddUser(context.Background(), username, key, role)
|
return st.AddUser(ctx, username, key, role)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-32
@@ -80,14 +80,6 @@ const (
|
|||||||
chromeFooter = 2 // footer: "\n" prefix + text line
|
chromeFooter = 2 // footer: "\n" prefix + text line
|
||||||
chromeTable = 3 // renderTable "\n" prefix + top border + header + bottom border (lipgloss collapses two into three rendered lines)
|
chromeTable = 3 // renderTable "\n" prefix + top border + header + bottom border (lipgloss collapses two into three rendered lines)
|
||||||
chromeBase = chromePadV + chromeHeader + chromeGaps + chromeFooter + chromeTable
|
chromeBase = chromePadV + chromeHeader + chromeGaps + chromeFooter + chromeTable
|
||||||
|
|
||||||
detailSparkWidth = 40
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
tabMonitors = 0
|
|
||||||
tabMaint = 1
|
|
||||||
tabSettings = 2
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -100,6 +92,21 @@ const (
|
|||||||
panelMonitors = 0
|
panelMonitors = 0
|
||||||
panelLogs = 1
|
panelLogs = 1
|
||||||
panelDetail = 2
|
panelDetail = 2
|
||||||
|
panelMaint = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
type bottomPanel int
|
||||||
|
|
||||||
|
const (
|
||||||
|
bottomNone bottomPanel = iota
|
||||||
|
bottomLogs
|
||||||
|
bottomMaint
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
detailDefault = 0
|
||||||
|
detailSLA = 1
|
||||||
|
detailHistory = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -112,24 +119,25 @@ const (
|
|||||||
type sessionState int
|
type sessionState int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
stateDashboard sessionState = iota
|
stateDashboard sessionState = 0
|
||||||
stateLogs
|
stateLogs sessionState = 1
|
||||||
stateUsers
|
stateDetailFullscreen sessionState = 2
|
||||||
stateDetail
|
stateAlertDetail sessionState = 3
|
||||||
stateAlertDetail
|
stateFormSite sessionState = 4
|
||||||
stateFormSite
|
stateFormAlert sessionState = 5
|
||||||
stateFormAlert
|
stateFormUser sessionState = 6
|
||||||
stateFormUser
|
stateConfirmDelete sessionState = 7
|
||||||
stateConfirmDelete
|
stateFormMaint sessionState = 8
|
||||||
stateFormMaint
|
stateSettings sessionState = 11
|
||||||
stateHistory
|
stateMaintDetail sessionState = 12
|
||||||
stateSLA
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
state sessionState
|
state sessionState
|
||||||
currentTab int
|
returnState sessionState
|
||||||
settingsSection int
|
settingsSection int
|
||||||
|
settingsCursor int
|
||||||
|
settingsOffset int
|
||||||
cursor int
|
cursor int
|
||||||
selectedID int
|
selectedID int
|
||||||
sortColumn int
|
sortColumn int
|
||||||
@@ -140,6 +148,7 @@ type Model struct {
|
|||||||
termHeight int
|
termHeight int
|
||||||
contentWidth int
|
contentWidth int
|
||||||
focusedPanel int
|
focusedPanel int
|
||||||
|
detailMode int
|
||||||
logScrollOffset int
|
logScrollOffset int
|
||||||
editID int
|
editID int
|
||||||
editToken string
|
editToken string
|
||||||
@@ -156,25 +165,27 @@ type Model struct {
|
|||||||
logTotal int
|
logTotal int
|
||||||
logShown int
|
logShown int
|
||||||
|
|
||||||
historyViewport viewport.Model
|
|
||||||
historyChanges []models.StateChange
|
historyChanges []models.StateChange
|
||||||
historySiteName string
|
historySiteName string
|
||||||
historySiteID int
|
historySiteID int
|
||||||
|
|
||||||
slaViewport viewport.Model
|
|
||||||
slaReport monitor.SLAReport
|
slaReport monitor.SLAReport
|
||||||
slaDailyBreakdown []monitor.DayReport
|
slaDailyBreakdown []monitor.DayReport
|
||||||
slaSiteName string
|
slaSiteName string
|
||||||
slaSiteID int
|
slaSiteID int
|
||||||
slaPeriodIdx int
|
slaPeriodIdx int
|
||||||
|
detailScrollOffset int
|
||||||
|
|
||||||
isAdmin bool
|
isAdmin bool
|
||||||
zones *zone.Manager
|
zones *zone.Manager
|
||||||
|
|
||||||
deleteID int
|
deleteID int
|
||||||
deleteName string
|
deleteName string
|
||||||
deleteTab int
|
deleteKind string
|
||||||
|
|
||||||
|
maintDetailID int
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
collapsed map[int]bool
|
collapsed map[int]bool
|
||||||
store store.Store
|
store store.Store
|
||||||
engine *monitor.Engine
|
engine *monitor.Engine
|
||||||
@@ -196,31 +207,31 @@ type Model struct {
|
|||||||
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
|
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
|
||||||
tabSeq int // seq of the newest issued tab-data load
|
tabSeq int // seq of the newest issued tab-data load
|
||||||
|
|
||||||
|
maintSet map[int]bool
|
||||||
|
bottomPanel bottomPanel
|
||||||
detailOpen bool
|
detailOpen bool
|
||||||
|
maintCursor int
|
||||||
detailChanges []models.StateChange
|
detailChanges []models.StateChange
|
||||||
detailChangesSiteID int
|
detailChangesSiteID int
|
||||||
detailDailyDays []monitor.DayReport
|
detailDailyDays []monitor.DayReport
|
||||||
detailViewport viewport.Model
|
|
||||||
|
|
||||||
filterMode bool
|
filterMode bool
|
||||||
filterText string
|
filterText string
|
||||||
|
|
||||||
sparkTooltipIdx int // clicked sparkline data index, -1 = none
|
|
||||||
|
|
||||||
// demoMode renders a stable status dot instead of the animated pulse so
|
// 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.
|
// screenshots/recordings don't capture the spinner mid-frame. Set via UPTOP_DEMO=1.
|
||||||
demoMode bool
|
demoMode bool
|
||||||
version string
|
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 := viewport.New(100, 20)
|
||||||
vpLogs.SetContent("Waiting for logs...")
|
vpLogs.SetContent("Waiting for logs...")
|
||||||
z := zone.New()
|
z := zone.New()
|
||||||
spring := harmonica.NewSpring(harmonica.FPS(10), 6.0, 0.4)
|
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)
|
theme := themeByName(themeName)
|
||||||
themeIdx := 0
|
themeIdx := 0
|
||||||
for i, t := range themes {
|
for i, t := range themes {
|
||||||
@@ -230,9 +241,20 @@ 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")
|
||||||
|
|
||||||
|
bp := bottomLogs
|
||||||
|
if bpPref, _ := s.GetPreference(ctx, "bottom_panel"); bpPref != "" {
|
||||||
|
switch bpPref {
|
||||||
|
case "none":
|
||||||
|
bp = bottomNone
|
||||||
|
case "maint":
|
||||||
|
bp = bottomMaint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Model{
|
return Model{
|
||||||
|
ctx: ctx,
|
||||||
state: stateDashboard,
|
state: stateDashboard,
|
||||||
logViewport: vpLogs,
|
logViewport: vpLogs,
|
||||||
maxTableRows: 5,
|
maxTableRows: 5,
|
||||||
@@ -245,10 +267,10 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri
|
|||||||
theme: theme,
|
theme: theme,
|
||||||
themeIndex: themeIdx,
|
themeIndex: themeIdx,
|
||||||
st: newStyles(theme),
|
st: newStyles(theme),
|
||||||
|
bottomPanel: bp,
|
||||||
detailOpen: detailPref == "true",
|
detailOpen: detailPref == "true",
|
||||||
demoMode: os.Getenv("UPTOP_DEMO") == "1",
|
demoMode: os.Getenv("UPTOP_DEMO") == "1",
|
||||||
version: version,
|
version: version,
|
||||||
sparkTooltipIdx: -1,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+576
-501
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 := newTestModel(ms)
|
||||||
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "DOWN"}}}
|
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "DOWN"}}}
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
m.state = stateDetail
|
m.detailOpen = true
|
||||||
m.termWidth = 120
|
m.termWidth = 120
|
||||||
m.termHeight = 40
|
m.termHeight = 40
|
||||||
|
|
||||||
// Entering detail dispatches the load Cmd.
|
|
||||||
cmd := m.loadDetailCmd(1)
|
cmd := m.loadDetailCmd(1)
|
||||||
if cmd == nil {
|
if cmd == nil {
|
||||||
t.Fatal("loadDetailCmd returned 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)
|
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)
|
updated, _ := m.Update(dd)
|
||||||
m = updated.(Model)
|
m = updated.(Model)
|
||||||
if m.detailChangesSiteID != 1 || len(m.detailChanges) != 1 {
|
if m.detailChangesSiteID != 1 || len(m.detailChanges) != 1 {
|
||||||
t.Fatalf("detail changes not cached: id=%d n=%d", m.detailChangesSiteID, len(m.detailChanges))
|
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++ {
|
for i := 0; i < 3; i++ {
|
||||||
_ = m.viewDetailPanel()
|
_ = m.viewDetailInline(80, 30)
|
||||||
}
|
}
|
||||||
if ms.stateChangeCalls != 1 {
|
if ms.stateChangeCalls != 1 {
|
||||||
t.Errorf("View performed DB IO: store hit %d times (want 1, from the Cmd only)", ms.stateChangeCalls)
|
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"}}}
|
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
|
||||||
m := newTestModel(ms)
|
m := newTestModel(ms)
|
||||||
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 7, Name: "site"}}}
|
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 7, Name: "site"}}}
|
||||||
m.state = stateDetail
|
m.detailOpen = true
|
||||||
m.termWidth, m.termHeight = 120, 40
|
m.termWidth, m.termHeight = 120, 40
|
||||||
|
|
||||||
updated, cmd := (&m).handleDetailKey(keyMsg("h"))
|
updated, cmd := (&m).handleDashboardKey(keyMsg("h"))
|
||||||
if ms.stateChangeCalls != 0 {
|
if ms.stateChangeCalls != 0 {
|
||||||
t.Fatal("history keypress hit the store synchronously in Update")
|
t.Fatal("history keypress hit the store synchronously in Update")
|
||||||
}
|
}
|
||||||
got := updated.(*Model)
|
got := updated.(*Model)
|
||||||
if got.state != stateHistory || got.historySiteID != 7 {
|
if got.detailMode != detailHistory || got.historySiteID != 7 {
|
||||||
t.Fatalf("history view not opened: state=%v siteID=%d", got.state, got.historySiteID)
|
t.Fatalf("history mode not set: mode=%v siteID=%d", got.detailMode, got.historySiteID)
|
||||||
}
|
}
|
||||||
if cmd == nil {
|
if cmd == nil {
|
||||||
t.Fatal("expected a history load Cmd")
|
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")
|
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
|
m2.historySiteID = 9
|
||||||
stale, _ := m2.Update(historyDataMsg{siteID: 7, changes: nil})
|
stale, _ := m2.Update(historyDataMsg{siteID: 7, changes: nil})
|
||||||
if m3 := stale.(Model); len(m3.historyChanges) != 1 {
|
if m3 := stale.(Model); len(m3.historyChanges) != 1 {
|
||||||
@@ -241,20 +237,15 @@ func TestSLAData_DropsStaleReply(t *testing.T) {
|
|||||||
m := newTestModel(&tuiMockStore{})
|
m := newTestModel(&tuiMockStore{})
|
||||||
m.termWidth, m.termHeight = 120, 40
|
m.termWidth, m.termHeight = 120, 40
|
||||||
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 3}, SiteState: models.SiteState{Status: "UP"}}}
|
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})
|
updated, _ := m.Update(slaDataMsg{siteID: 3, periodIdx: 0})
|
||||||
if mm := updated.(*Model); mm.slaDailyBreakdown != nil {
|
if mm := updated.(*Model); mm.slaDailyBreakdown != nil {
|
||||||
t.Error("stale SLA reply (old period) was applied")
|
t.Error("stale SLA reply (old period) was applied")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matching reply → report computed.
|
|
||||||
updated, _ = updated.(*Model).Update(slaDataMsg{siteID: 3, periodIdx: m.slaPeriodIdx})
|
updated, _ = updated.(*Model).Update(slaDataMsg{siteID: 3, periodIdx: m.slaPeriodIdx})
|
||||||
if mm := updated.(*Model); mm.slaDailyBreakdown == nil {
|
if mm := updated.(*Model); mm.slaDailyBreakdown == nil {
|
||||||
t.Error("matching SLA reply was not applied")
|
t.Error("matching SLA reply was not applied")
|
||||||
@@ -266,7 +257,7 @@ func TestConfirmDelete_WritesOffUIGoroutine(t *testing.T) {
|
|||||||
m := newTestModel(ms)
|
m := newTestModel(ms)
|
||||||
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 4, Name: "s"}}}
|
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 4, Name: "s"}}}
|
||||||
m.state = stateConfirmDelete
|
m.state = stateConfirmDelete
|
||||||
m.deleteTab = 0
|
m.deleteKind = "site"
|
||||||
m.deleteID = 4
|
m.deleteID = 4
|
||||||
|
|
||||||
updated, cmd := (&m).handleConfirmDelete(keyMsg("y"))
|
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) {
|
func TestDetailRefreshCmd_OnlyWhileDetailOpen(t *testing.T) {
|
||||||
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
|
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
|
||||||
m := newTestModel(ms)
|
m := newTestModel(ms)
|
||||||
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 5, Name: "site"}}}
|
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 5, Name: "site"}}}
|
||||||
|
|
||||||
m.state = stateDashboard
|
m.detailOpen = false
|
||||||
if (&m).detailRefreshCmd() != nil {
|
if (&m).detailRefreshCmd() != nil {
|
||||||
t.Error("refresh Cmd issued outside the detail view")
|
t.Error("refresh Cmd issued outside the detail view")
|
||||||
}
|
}
|
||||||
|
|
||||||
m.state = stateDetail
|
m.detailOpen = true
|
||||||
cmd := (&m).detailRefreshCmd()
|
cmd := (&m).detailRefreshCmd()
|
||||||
if cmd == nil {
|
if cmd == nil {
|
||||||
t.Fatal("open detail panel should refresh on the tab-data cadence")
|
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")
|
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 {
|
for _, d := range display {
|
||||||
ch := "█"
|
ch := "█"
|
||||||
switch {
|
switch {
|
||||||
case d.UptimePct >= 99.0:
|
case d.UptimePct >= uptimeGoodPct:
|
||||||
sb.WriteString(m.st.specialStyle.Render(ch))
|
sb.WriteString(m.st.specialStyle.Render(ch))
|
||||||
case d.UptimePct >= 95.0:
|
case d.UptimePct >= uptimeWarnPct:
|
||||||
sb.WriteString(m.st.warnStyle.Render(ch))
|
sb.WriteString(m.st.warnStyle.Render(ch))
|
||||||
case d.UptimePct > 0:
|
case d.UptimePct > 0:
|
||||||
sb.WriteString(m.st.dangerStyle.Render(ch))
|
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
|
pct := days[len(days)-1].UptimePct
|
||||||
pctStyle := m.st.specialStyle
|
pctStyle := m.st.specialStyle
|
||||||
if pct < 99.0 {
|
if pct < uptimeGoodPct {
|
||||||
pctStyle = m.st.dangerStyle
|
pctStyle = m.st.dangerStyle
|
||||||
} else if pct < 99.9 {
|
} else if pct < uptimeExcellentPct {
|
||||||
pctStyle = m.st.warnStyle
|
pctStyle = m.st.warnStyle
|
||||||
}
|
}
|
||||||
sb.WriteString(" " + pctStyle.Render(fmt.Sprintf("%.2f%%", pct)))
|
sb.WriteString(" " + pctStyle.Render(fmt.Sprintf("%.2f%%", pct)))
|
||||||
|
|||||||
+142
-163
@@ -47,12 +47,12 @@ func (m Model) View() string {
|
|||||||
switch m.state {
|
switch m.state {
|
||||||
case stateConfirmDelete:
|
case stateConfirmDelete:
|
||||||
kind := "monitor"
|
kind := "monitor"
|
||||||
switch m.deleteTab {
|
switch m.deleteKind {
|
||||||
case 1:
|
case "maint":
|
||||||
kind = "alert"
|
|
||||||
case 4:
|
|
||||||
kind = "maintenance window"
|
kind = "maintenance window"
|
||||||
case 5:
|
case "alert":
|
||||||
|
kind = "alert"
|
||||||
|
case "user":
|
||||||
kind = "user"
|
kind = "user"
|
||||||
}
|
}
|
||||||
msg := m.st.dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName))
|
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 lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer)
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
case stateDetail:
|
case stateLogs:
|
||||||
return m.zones.Scan(m.viewDetailPanel())
|
return m.viewLogsFullscreen()
|
||||||
case stateHistory:
|
case stateDetailFullscreen:
|
||||||
return m.viewHistoryPanel()
|
return m.viewDetailFullscreen()
|
||||||
case stateSLA:
|
|
||||||
return m.viewSLAPanel()
|
|
||||||
case stateAlertDetail:
|
case stateAlertDetail:
|
||||||
return m.viewAlertDetailPanel()
|
return m.viewAlertDetailPanel()
|
||||||
|
case stateSettings:
|
||||||
|
return m.viewSettingsOverlay()
|
||||||
|
case stateMaintDetail:
|
||||||
|
return m.viewMaintDetailPanel()
|
||||||
default:
|
default:
|
||||||
return m.zones.Scan(m.viewDashboard())
|
return m.zones.Scan(m.viewDashboard())
|
||||||
}
|
}
|
||||||
@@ -130,7 +132,7 @@ func (m Model) computeStats() dashboardStats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, n := range m.nodes {
|
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++
|
s.offlineNodes++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,61 +145,71 @@ func (m Model) computeStats() dashboardStats {
|
|||||||
return s
|
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, scrollbar{pos: m.tableOffset, total: len(m.sites), visible: m.maxTableRows}, 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, scrollbar{}, m.focusedPanel == panelDetail))
|
||||||
|
topParts = append(topParts, detailPanel)
|
||||||
|
}
|
||||||
|
|
||||||
|
top := lipgloss.JoinHorizontal(lipgloss.Top, topParts...)
|
||||||
|
|
||||||
|
switch m.bottomPanel {
|
||||||
|
case bottomLogs:
|
||||||
|
maxLines := logsStripHeight - 2
|
||||||
|
logContent := m.viewLogsStrip(availW-2, maxLines)
|
||||||
|
totalLogs := m.filteredLogCount()
|
||||||
|
logPanel := m.zones.Mark("panel-logs", m.titledPanelH("Logs", logContent, "", availW, logsStripHeight, 0, scrollbar{pos: m.logScrollOffset, total: totalLogs, visible: maxLines}, m.focusedPanel == panelLogs))
|
||||||
|
return top + "\n" + logPanel
|
||||||
|
case bottomMaint:
|
||||||
|
maxLines := logsStripHeight - 2
|
||||||
|
maintContent := m.viewMaintStrip(availW-2, maxLines)
|
||||||
|
totalMaint := len(m.activeMaintWindows())
|
||||||
|
maintPanel := m.zones.Mark("panel-maint", m.titledPanelH("Maint", maintContent, "", availW, logsStripHeight, 0, scrollbar{pos: 0, total: totalMaint, visible: maxLines}, m.focusedPanel == panelMaint))
|
||||||
|
return top + "\n" + maintPanel
|
||||||
|
}
|
||||||
|
return top
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) viewDashboard() string {
|
func (m Model) viewDashboard() string {
|
||||||
stats := m.computeStats()
|
stats := m.computeStats()
|
||||||
|
|
||||||
header := m.renderTabBar(stats)
|
header := m.renderStatusLine(stats)
|
||||||
header = m.pulseIndicator() + " " + header
|
|
||||||
|
|
||||||
var content string
|
content := m.viewMonitorsLayout()
|
||||||
switch m.currentTab {
|
|
||||||
case tabMonitors:
|
|
||||||
showSidebar := m.termWidth >= wideBreakpoint
|
|
||||||
if showSidebar {
|
|
||||||
availW := m.termWidth - chromePadH
|
|
||||||
leftW := availW * 70 / 100
|
|
||||||
rightW := availW - leftW
|
|
||||||
m.contentWidth = leftW - 2
|
|
||||||
monitors := m.viewSitesTab()
|
|
||||||
monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, leftW, m.focusedPanel == panelMonitors))
|
|
||||||
sidebarContent := m.viewLogsSidebar(rightW-2, m.maxTableRows)
|
|
||||||
logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", sidebarContent, rightW, m.focusedPanel == panelLogs))
|
|
||||||
top := lipgloss.JoinHorizontal(lipgloss.Top, monPanel, logPanel)
|
|
||||||
if m.detailOpen {
|
|
||||||
site := ""
|
|
||||||
if m.cursor < len(m.sites) {
|
|
||||||
site = m.sites[m.cursor].Name
|
|
||||||
}
|
|
||||||
detail := m.viewDetailInline(availW - 2)
|
|
||||||
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail))
|
|
||||||
content = top + "\n" + detailPanel
|
|
||||||
} else {
|
|
||||||
content = top
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
m.contentWidth = m.termWidth - 2
|
|
||||||
monitors := m.viewSitesTab()
|
|
||||||
availW := m.termWidth - chromePadH
|
|
||||||
monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, availW, m.focusedPanel == panelMonitors))
|
|
||||||
if m.detailOpen {
|
|
||||||
site := ""
|
|
||||||
if m.cursor < len(m.sites) {
|
|
||||||
site = m.sites[m.cursor].Name
|
|
||||||
}
|
|
||||||
detail := m.viewDetailInline(availW - 2)
|
|
||||||
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail))
|
|
||||||
content = monPanel + "\n" + detailPanel
|
|
||||||
} else {
|
|
||||||
content = monPanel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case tabMaint:
|
|
||||||
m.contentWidth = m.termWidth
|
|
||||||
content = m.viewMaintTab()
|
|
||||||
case tabSettings:
|
|
||||||
m.contentWidth = m.termWidth
|
|
||||||
content = m.viewSettingsTab()
|
|
||||||
}
|
|
||||||
|
|
||||||
content = strings.TrimSpace(content)
|
content = strings.TrimSpace(content)
|
||||||
footer := m.renderFooter(stats)
|
footer := m.renderFooter(stats)
|
||||||
@@ -209,74 +221,17 @@ func (m Model) viewDashboard() string {
|
|||||||
availHeight = 5
|
availHeight = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
divW := m.termWidth - chromePadH
|
contentHeight := availHeight - lipgloss.Height(header) - lipgloss.Height(footer)
|
||||||
if divW < 40 {
|
|
||||||
divW = 40
|
|
||||||
}
|
|
||||||
tabDivider := m.st.subtleStyle.Render(strings.Repeat("─", divW))
|
|
||||||
|
|
||||||
contentHeight := availHeight - lipgloss.Height(header) - 1 - lipgloss.Height(footer)
|
|
||||||
if contentHeight < 1 {
|
if contentHeight < 1 {
|
||||||
contentHeight = 1
|
contentHeight = 1
|
||||||
}
|
}
|
||||||
paddedContent := lipgloss.NewStyle().Height(contentHeight).MaxHeight(contentHeight).Render(content)
|
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 {
|
func (m Model) renderStatusLine(stats dashboardStats) string {
|
||||||
name string
|
dot := m.st.subtleStyle.Render(" · ")
|
||||||
count int
|
|
||||||
warn int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) renderTabBar(stats dashboardStats) string {
|
|
||||||
settingsCount := len(m.alerts) + len(m.nodes)
|
|
||||||
settingsWarn := stats.offlineNodes
|
|
||||||
for _, a := range m.alerts {
|
|
||||||
h := m.engine.GetAlertHealth(a.ID)
|
|
||||||
if !h.LastSendOK && !h.LastSendAt.IsZero() {
|
|
||||||
settingsWarn++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if m.isAdmin {
|
|
||||||
settingsCount += len(m.users)
|
|
||||||
}
|
|
||||||
tabs := []tabEntry{
|
|
||||||
{"Monitors", stats.totalMonitors, stats.downCount + stats.lateCount},
|
|
||||||
{"Maint", len(m.maintenanceWindows), stats.activeMaint},
|
|
||||||
{"Settings", settingsCount, settingsWarn},
|
|
||||||
}
|
|
||||||
|
|
||||||
countStyle := lipgloss.NewStyle().Foreground(m.theme.Muted)
|
|
||||||
|
|
||||||
var renderedTabs []string
|
|
||||||
for i, t := range tabs {
|
|
||||||
label := t.name
|
|
||||||
if t.count > 0 {
|
|
||||||
badge := countStyle.Render(fmt.Sprintf(" %d", t.count))
|
|
||||||
if t.warn > 0 {
|
|
||||||
badge = m.st.dangerStyle.Render(fmt.Sprintf(" %d", t.warn))
|
|
||||||
}
|
|
||||||
label += badge
|
|
||||||
}
|
|
||||||
|
|
||||||
var rendered string
|
|
||||||
if i == m.currentTab {
|
|
||||||
rendered = m.st.activeTab.Render(label)
|
|
||||||
} else {
|
|
||||||
rendered = m.st.inactiveTab.Render(label)
|
|
||||||
}
|
|
||||||
renderedTabs = append(renderedTabs, m.zones.Mark(fmt.Sprintf("tab-%d", i), rendered))
|
|
||||||
}
|
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) renderFooter(stats dashboardStats) string {
|
|
||||||
if m.filterMode {
|
|
||||||
cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│")
|
|
||||||
return "\n" + m.st.titleStyle.Render("/") + " " + m.filterText + cursor + " " + m.st.subtleStyle.Render("[Enter]Apply [Esc]Clear")
|
|
||||||
}
|
|
||||||
|
|
||||||
upCount := stats.totalMonitors - stats.downCount - stats.lateCount
|
upCount := stats.totalMonitors - stats.downCount - stats.lateCount
|
||||||
var upStr string
|
var upStr string
|
||||||
@@ -287,59 +242,83 @@ func (m Model) renderFooter(stats dashboardStats) string {
|
|||||||
} else {
|
} else {
|
||||||
upStr = m.st.specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors))
|
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 {
|
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 {
|
if len(m.nodes) > 0 {
|
||||||
online := 0
|
online := 0
|
||||||
for _, n := range m.nodes {
|
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++
|
online++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
probeLabel := "probes"
|
label := "probes"
|
||||||
if online == 1 {
|
if online == 1 {
|
||||||
probeLabel = "probe"
|
label = "probe"
|
||||||
}
|
}
|
||||||
statusParts = append(statusParts, fmt.Sprintf("%d %s", online, probeLabel))
|
parts = append(parts, fmt.Sprintf("%d %s", online, label))
|
||||||
}
|
|
||||||
statusLine := strings.Join(statusParts, m.st.subtleStyle.Render(" · "))
|
|
||||||
|
|
||||||
var keys string
|
|
||||||
switch m.currentTab {
|
|
||||||
case tabMonitors:
|
|
||||||
if m.focusedPanel == panelLogs {
|
|
||||||
keys = "[↑/↓]Scroll [l/Esc]Back [T]Theme [q]Quit"
|
|
||||||
} else if m.detailOpen {
|
|
||||||
keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [l]Logs [↑/↓]Select [T]Theme [q]Quit"
|
|
||||||
} else {
|
|
||||||
keys = "[/]Filter [i]Info [Enter]Detail [</>]Sort [r]Reverse [n]New [e]Edit [d]Del [l]Logs [T]Theme [Tab]Switch [q]Quit"
|
|
||||||
}
|
|
||||||
case tabMaint:
|
|
||||||
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
|
|
||||||
case tabSettings:
|
|
||||||
switch m.settingsSection {
|
|
||||||
case sectionAlerts:
|
|
||||||
keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [←/→]Section [T]Theme [Tab]Switch [q]Quit"
|
|
||||||
case sectionUsers:
|
|
||||||
keys = "[n]Add [d]Revoke [←/→]Section [T]Theme [Tab]Switch [q]Quit"
|
|
||||||
default:
|
|
||||||
keys = "[←/→]Section [T]Theme [Tab]Switch [q]Quit"
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
keys = "[T]Theme [Tab]Switch [q]Quit"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
left := strings.Join(parts, dot)
|
||||||
ver := m.st.subtleStyle.Render("v" + m.version)
|
ver := m.st.subtleStyle.Render("v" + m.version)
|
||||||
line := statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
|
|
||||||
if m.filterText != "" && m.currentTab == tabMonitors {
|
padW := m.termWidth - chromePadH - lipgloss.Width(left) - lipgloss.Width(ver)
|
||||||
line = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
|
if padW < 2 {
|
||||||
|
padW = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
divW := m.termWidth - chromePadH
|
return left + strings.Repeat(" ", padW) + ver
|
||||||
if divW < 40 {
|
|
||||||
divW = 40
|
|
||||||
}
|
}
|
||||||
return m.st.subtleStyle.Render(strings.Repeat("─", divW)) + "\n" + line
|
|
||||||
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"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) {
|
if m.cursor >= len(m.sites) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
switch m.detailMode {
|
||||||
|
case detailSLA:
|
||||||
|
return m.viewSLASidebar(width, height)
|
||||||
|
case detailHistory:
|
||||||
|
return m.viewHistorySidebar(width, height)
|
||||||
|
default:
|
||||||
site := m.sites[m.cursor]
|
site := m.sites[m.cursor]
|
||||||
hist, _ := m.engine.GetHistory(site.ID)
|
hist, _ := m.engine.GetHistory(site.ID)
|
||||||
|
return m.buildDetailContent(site, hist, width, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) viewDetailFullscreen() string {
|
||||||
|
if m.cursor >= len(m.sites) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
availW := m.termWidth - chromePadH
|
||||||
|
site := m.sites[m.cursor]
|
||||||
|
|
||||||
|
var title string
|
||||||
|
switch m.detailMode {
|
||||||
|
case detailSLA:
|
||||||
|
title = "SLA · " + site.Name
|
||||||
|
case detailHistory:
|
||||||
|
title = "History · " + site.Name
|
||||||
|
default:
|
||||||
|
title = site.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
if site.ParentID > 0 {
|
||||||
|
for _, s := range m.sites {
|
||||||
|
if s.ID == site.ParentID {
|
||||||
|
title = s.Name + " > " + title
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
innerW := availW - 2
|
||||||
|
|
||||||
|
var content string
|
||||||
|
switch m.detailMode {
|
||||||
|
case detailSLA:
|
||||||
|
content = m.viewSLASidebar(innerW, 0)
|
||||||
|
case detailHistory:
|
||||||
|
content = m.viewHistorySidebar(innerW, 0)
|
||||||
|
default:
|
||||||
|
hist, _ := m.engine.GetHistory(site.ID)
|
||||||
|
content = m.buildDetailContent(site, hist, innerW, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
footer := m.detailFooter(innerW)
|
||||||
|
|
||||||
|
panelH := m.termHeight - chromePadV
|
||||||
|
if panelH < 10 {
|
||||||
|
panelH = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
return lipgloss.NewStyle().Padding(1, 2).Render(
|
||||||
|
m.titledPanelH(title, content, footer, availW, panelH, m.detailScrollOffset, scrollbar{}, 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
|
var b strings.Builder
|
||||||
|
|
||||||
|
// Status + latency + last check + state since
|
||||||
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
|
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
|
||||||
latency := m.fmtLatency(site.Latency)
|
statusParts := []string{status}
|
||||||
uptime := m.fmtUptime(hist.Statuses)
|
|
||||||
|
|
||||||
line1Parts := []string{status}
|
|
||||||
if site.Latency > 0 {
|
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() {
|
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 ||
|
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
|
||||||
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
|
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
|
||||||
errW := width - 12
|
errW := innerW
|
||||||
if errW < 20 {
|
if errW < 20 {
|
||||||
errW = 20
|
errW = 20
|
||||||
}
|
}
|
||||||
errMsg := limitStr(site.LastError, errW)
|
b.WriteString(" " + label.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n")
|
||||||
b.WriteString(" " + m.st.subtleStyle.Render("Error") + " " + m.st.dangerStyle.Render(errMsg) + "\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connection chain
|
||||||
|
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp) && site.LastError != "" {
|
||||||
|
chain := connectionChain(site.LastError, site.Type, site.StatusCode, strings.HasPrefix(site.URL, "https"))
|
||||||
|
if len(chain) > 0 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
for _, step := range chain {
|
||||||
|
var icon string
|
||||||
|
switch step.Status {
|
||||||
|
case stepPassed:
|
||||||
|
icon = m.st.specialStyle.Render("✓")
|
||||||
|
case stepFailed:
|
||||||
|
icon = m.st.dangerStyle.Render("✗")
|
||||||
|
case stepSkipped:
|
||||||
|
icon = m.st.subtleStyle.Render("·")
|
||||||
|
}
|
||||||
|
line := fmt.Sprintf(" %s %-16s", icon, step.Name)
|
||||||
|
if step.Detail != "" {
|
||||||
|
switch step.Status {
|
||||||
|
case stepFailed:
|
||||||
|
line += " " + m.st.dangerStyle.Render(step.Detail)
|
||||||
|
case stepSkipped:
|
||||||
|
line += " " + m.st.subtleStyle.Render(step.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString(line + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// Probe results
|
||||||
|
probeResults := m.engine.GetProbeResults(site.ID)
|
||||||
|
if len(probeResults) > 0 {
|
||||||
|
nodeIDs := make([]string, 0, len(probeResults))
|
||||||
|
for id := range probeResults {
|
||||||
|
nodeIDs = append(nodeIDs, id)
|
||||||
|
}
|
||||||
|
sort.Strings(nodeIDs)
|
||||||
|
for _, nodeID := range nodeIDs {
|
||||||
|
result := probeResults[nodeID]
|
||||||
|
probeStatus := m.st.specialStyle.Render("UP")
|
||||||
|
if !result.IsUp {
|
||||||
|
probeStatus = m.st.dangerStyle.Render("DN")
|
||||||
|
}
|
||||||
|
latency := time.Duration(result.LatencyNs).Milliseconds()
|
||||||
|
ago := time.Since(result.CheckedAt).Truncate(time.Second)
|
||||||
|
line := fmt.Sprintf(" %-14s %s %dms %s ago", nodeID, probeStatus, latency, ago)
|
||||||
|
if !result.IsUp && result.ErrorReason != "" {
|
||||||
|
line += " " + m.st.dangerStyle.Render(result.ErrorReason)
|
||||||
|
}
|
||||||
|
b.WriteString(line + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latency chart
|
||||||
|
if len(hist.Latencies) > 0 {
|
||||||
|
chart := m.latencyChart(hist.Latencies, hist.Statuses, innerW, 3)
|
||||||
|
if chart != "" {
|
||||||
|
b.WriteString(chart + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 30d uptime timeline
|
||||||
|
if len(m.detailDailyDays) > 0 && m.detailChangesSiteID == site.ID {
|
||||||
|
b.WriteString(" " + label.Render("30d") + " " + m.uptimeTimeline(m.detailDailyDays, innerW) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sparkline + min/avg/max
|
||||||
|
if site.Type != "push" && len(hist.Latencies) > 0 {
|
||||||
|
b.WriteString(" " + m.latencySparkline(hist.Latencies, hist.Statuses, innerW, nil) + "\n")
|
||||||
|
var minL, maxL, total time.Duration
|
||||||
|
count := 0
|
||||||
|
for i, l := range hist.Latencies {
|
||||||
|
if i < len(hist.Statuses) && !hist.Statuses[i] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
minL, maxL = l, l
|
||||||
|
} else if l < minL {
|
||||||
|
minL = l
|
||||||
|
} else if l > maxL {
|
||||||
|
maxL = l
|
||||||
|
}
|
||||||
|
total += l
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
avg := total / time.Duration(count)
|
||||||
|
fmt.Fprintf(&b, " %s %dms %s %dms %s %dms\n",
|
||||||
|
label.Render("Min"), minL.Milliseconds(),
|
||||||
|
label.Render("Avg"), avg.Milliseconds(),
|
||||||
|
label.Render("Max"), maxL.Milliseconds())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latency histogram
|
||||||
|
if site.Type != "push" && len(hist.Latencies) > 5 {
|
||||||
|
histContent := m.latencyHistogram(hist.Latencies, hist.Statuses, innerW)
|
||||||
|
if histContent != "" {
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(histContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// State changes
|
||||||
var stateChanges []models.StateChange
|
var stateChanges []models.StateChange
|
||||||
if m.detailChangesSiteID == site.ID {
|
if m.detailChangesSiteID == site.ID {
|
||||||
stateChanges = m.detailChanges
|
stateChanges = m.detailChanges
|
||||||
}
|
}
|
||||||
if len(stateChanges) > 0 {
|
if len(stateChanges) > 0 {
|
||||||
var parts []string
|
limit := 5
|
||||||
limit := 3
|
|
||||||
if len(stateChanges) < limit {
|
if len(stateChanges) < limit {
|
||||||
limit = len(stateChanges)
|
limit = len(stateChanges)
|
||||||
}
|
}
|
||||||
@@ -57,47 +269,290 @@ func (m Model) viewDetailInline(width int) string {
|
|||||||
arrow := m.st.subtleStyle.Render("→")
|
arrow := m.st.subtleStyle.Render("→")
|
||||||
from := m.fmtStatusWord(sc.FromStatus)
|
from := m.fmtStatusWord(sc.FromStatus)
|
||||||
to := m.fmtStatusWord(sc.ToStatus)
|
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 != "" {
|
if sc.ErrorReason != "" {
|
||||||
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, 30))
|
reasonW := innerW - 25
|
||||||
|
if reasonW < 15 {
|
||||||
|
reasonW = 15
|
||||||
}
|
}
|
||||||
parts = append(parts, entry)
|
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonW))
|
||||||
}
|
}
|
||||||
b.WriteString(" " + strings.Join(parts, m.st.subtleStyle.Render(" · ")) + "\n")
|
b.WriteString(" " + entry + "\n")
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
if len(hist.Latencies) > 0 {
|
b.WriteString(" " + label.Render("No state changes") + "\n")
|
||||||
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())
|
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 {
|
func (m Model) fmtStatusWord(status string) string {
|
||||||
switch status {
|
switch status {
|
||||||
case "DOWN":
|
case "DOWN", "SSL EXP":
|
||||||
return m.st.dangerStyle.Render("DOWN")
|
return m.st.dangerStyle.Render(status)
|
||||||
case "UP":
|
case "UP":
|
||||||
return m.st.specialStyle.Render("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:
|
default:
|
||||||
return m.st.subtleStyle.Render(status)
|
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
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type historyStats struct {
|
type historyStats struct {
|
||||||
@@ -105,88 +103,3 @@ func (m Model) stateChangeSparkline(changes []models.StateChange, width int) str
|
|||||||
}
|
}
|
||||||
return sb.String()
|
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"
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var slaPeriods = []struct {
|
var slaPeriods = []struct {
|
||||||
@@ -21,92 +19,6 @@ var slaPeriods = []struct {
|
|||||||
{"90d", "4", 90 * 24 * time.Hour, 90},
|
{"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 {
|
func (m Model) uptimeBar(pct float64, width int) string {
|
||||||
filled := int(math.Round(pct / 100 * float64(width)))
|
filled := int(math.Round(pct / 100 * float64(width)))
|
||||||
if filled > width {
|
if filled > width {
|
||||||
@@ -128,7 +40,7 @@ func fmtPct(pct float64) string {
|
|||||||
if pct == 100 {
|
if pct == 100 {
|
||||||
return "100.00"
|
return "100.00"
|
||||||
}
|
}
|
||||||
if pct >= 99.99 {
|
if pct >= uptimePrecisionPct {
|
||||||
return fmt.Sprintf("%.3f", pct)
|
return fmt.Sprintf("%.3f", pct)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%.2f", pct)
|
return fmt.Sprintf("%.2f", pct)
|
||||||
|
|||||||
Reference in New Issue
Block a user