mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-03 21:02:56 +00:00
c7e2c88604
Implement incident tracking for public status pages, improve the monitoring dashboard UI with better grouping and loading states, and refine domain resolution logic. - feat(hub): add incident support to public status pages - feat(hub): implement immediate monitor checks on creation and resume - feat(hub): improve domain status detection using DNS fallback when WHOIS fails - feat(site): redesign monitoring dashboard with categorized cards - feat(site): add incident detail view and management in the dashboard - feat(site): add active incidents section to public status pages - feat(site): add "Add System" functionality to systems table - refactor(site): improve calendar view responsiveness and loading states - style(site): add skeleton components for better UX during data fetching
638 lines
17 KiB
Go
638 lines
17 KiB
Go
package monitors
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/henrygd/beszel/internal/entities/incident"
|
|
"github.com/henrygd/beszel/internal/entities/monitor"
|
|
"github.com/henrygd/beszel/internal/hub/monitors/checks"
|
|
"github.com/pocketbase/dbx"
|
|
"github.com/pocketbase/pocketbase/core"
|
|
"github.com/pocketbase/pocketbase/tools/store"
|
|
)
|
|
|
|
// AlertCallback is a function that sends alerts
|
|
type AlertCallback func(userID, title, message, link, linkText string)
|
|
|
|
// Scheduler manages the periodic execution of monitor checks
|
|
type Scheduler struct {
|
|
app core.App
|
|
registry *checks.CheckerRegistry
|
|
monitors *store.Store[string, *ScheduledMonitor]
|
|
ticker *time.Ticker
|
|
stopChan chan struct{}
|
|
wg sync.WaitGroup
|
|
mu sync.RWMutex
|
|
running bool
|
|
alertCallback AlertCallback
|
|
}
|
|
|
|
// ScheduledMonitor wraps a monitor with scheduling info
|
|
type ScheduledMonitor struct {
|
|
Monitor *monitor.Monitor
|
|
NextCheck time.Time
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// NewScheduler creates a new monitor scheduler
|
|
func NewScheduler(app core.App) *Scheduler {
|
|
return &Scheduler{
|
|
app: app,
|
|
registry: checks.NewCheckerRegistry(),
|
|
monitors: store.New(map[string]*ScheduledMonitor{}),
|
|
stopChan: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// SetAlertCallback sets the callback function for sending alerts
|
|
func (s *Scheduler) SetAlertCallback(callback AlertCallback) {
|
|
s.alertCallback = callback
|
|
}
|
|
|
|
// Start begins the scheduler loop
|
|
func (s *Scheduler) Start() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.running {
|
|
return nil
|
|
}
|
|
|
|
// Load active monitors from database
|
|
if err := s.loadMonitors(); err != nil {
|
|
return fmt.Errorf("failed to load monitors: %w", err)
|
|
}
|
|
|
|
// Start the ticker (minimum 20 second resolution)
|
|
s.ticker = time.NewTicker(20 * time.Second)
|
|
s.running = true
|
|
|
|
s.wg.Add(1)
|
|
go s.run()
|
|
|
|
log.Println("[monitor-scheduler] Started")
|
|
return nil
|
|
}
|
|
|
|
// Stop halts the scheduler
|
|
func (s *Scheduler) Stop() {
|
|
s.mu.Lock()
|
|
if !s.running {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.running = false
|
|
s.ticker.Stop()
|
|
close(s.stopChan)
|
|
s.mu.Unlock()
|
|
|
|
s.wg.Wait()
|
|
log.Println("[monitor-scheduler] Stopped")
|
|
}
|
|
|
|
// run is the main scheduler loop
|
|
func (s *Scheduler) run() {
|
|
defer s.wg.Done()
|
|
|
|
for {
|
|
select {
|
|
case <-s.ticker.C:
|
|
s.checkMonitors()
|
|
case <-s.stopChan:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkMonitors checks all due monitors
|
|
func (s *Scheduler) checkMonitors() {
|
|
now := time.Now()
|
|
|
|
allMonitors := s.monitors.GetAll()
|
|
for _, sm := range allMonitors {
|
|
sm.mu.Lock()
|
|
|
|
// Skip if monitor is paused or not active
|
|
if !sm.Monitor.Active || sm.Monitor.Status == monitor.StatusPaused {
|
|
sm.mu.Unlock()
|
|
continue
|
|
}
|
|
|
|
// Check if it's time to run
|
|
if now.Before(sm.NextCheck) {
|
|
sm.mu.Unlock()
|
|
continue
|
|
}
|
|
|
|
// Schedule the next check
|
|
interval := time.Duration(sm.Monitor.Interval) * time.Second
|
|
if interval < 20*time.Second {
|
|
interval = 20 * time.Second
|
|
}
|
|
sm.NextCheck = now.Add(interval)
|
|
sm.mu.Unlock()
|
|
|
|
// Run check in background
|
|
s.wg.Add(1)
|
|
go func(m *monitor.Monitor) {
|
|
defer s.wg.Done()
|
|
s.runCheck(m)
|
|
}(sm.Monitor)
|
|
}
|
|
}
|
|
|
|
// runCheck executes a single monitor check
|
|
func (s *Scheduler) runCheck(m *monitor.Monitor) {
|
|
// Get the appropriate checker
|
|
checker, ok := s.registry.Get(m.Type)
|
|
if !ok {
|
|
log.Printf("[monitor-scheduler] No checker found for type: %s", m.Type)
|
|
return
|
|
}
|
|
|
|
// Create context with timeout
|
|
timeout := time.Duration(m.Timeout) * time.Second
|
|
if timeout == 0 {
|
|
timeout = 30 * time.Second
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
|
|
// Execute check
|
|
result := checker.Check(ctx, m)
|
|
|
|
// Handle retries
|
|
if result.Status == monitor.StatusDown && m.Retries > 0 {
|
|
retryInterval := time.Duration(m.RetryInterval) * time.Second
|
|
if retryInterval == 0 {
|
|
retryInterval = time.Second
|
|
}
|
|
|
|
for i := 0; i < m.Retries && result.Status == monitor.StatusDown; i++ {
|
|
time.Sleep(retryInterval)
|
|
|
|
ctx, cancel = context.WithTimeout(context.Background(), timeout)
|
|
result = checker.Check(ctx, m)
|
|
cancel()
|
|
}
|
|
}
|
|
|
|
// Save heartbeat and update monitor status
|
|
if err := s.saveResult(m, result); err != nil {
|
|
log.Printf("[monitor-scheduler] Failed to save result: %v", err)
|
|
}
|
|
|
|
// Log result
|
|
if result.Status == monitor.StatusUp {
|
|
log.Printf("[monitor-scheduler] Check UP: %s (ping: %dms)", m.Name, result.Ping)
|
|
} else {
|
|
log.Printf("[monitor-scheduler] Check DOWN: %s - %s", m.Name, result.Msg)
|
|
}
|
|
}
|
|
|
|
// saveResult saves the check result to the database and sends notifications on status change
|
|
func (s *Scheduler) saveResult(m *monitor.Monitor, result *monitor.CheckResult) error {
|
|
record, err := s.app.FindRecordById("monitors", m.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find monitor: %w", err)
|
|
}
|
|
|
|
prevStatus := monitor.Status(record.GetString("status"))
|
|
newStatus := result.Status
|
|
now := time.Now()
|
|
|
|
hbCollection, err := s.app.FindCollectionByNameOrId("monitor_heartbeats")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find heartbeats collection: %w", err)
|
|
}
|
|
|
|
hbRecord := core.NewRecord(hbCollection)
|
|
hbRecord.Set("monitor", m.ID)
|
|
hbRecord.Set("status", string(result.Status))
|
|
hbRecord.Set("ping", result.Ping)
|
|
hbRecord.Set("msg", result.Msg)
|
|
hbRecord.Set("cert_expiry", result.CertExpiry)
|
|
hbRecord.Set("cert_valid", result.CertValid)
|
|
hbRecord.Set("time", now)
|
|
|
|
if err := s.app.Save(hbRecord); err != nil {
|
|
return fmt.Errorf("failed to save heartbeat: %w", err)
|
|
}
|
|
|
|
stats, err := s.calculateUptimeStats(m.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to calculate uptime stats: %w", err)
|
|
}
|
|
stats["last_ping"] = float64(result.Ping)
|
|
|
|
record.Set("status", string(newStatus))
|
|
record.Set("last_check", now)
|
|
record.Set("uptime_stats", stats)
|
|
|
|
if err := s.app.Save(record); err != nil {
|
|
return fmt.Errorf("failed to update monitor: %w", err)
|
|
}
|
|
|
|
m.Status = newStatus
|
|
m.LastCheck = now
|
|
m.UptimeStats = stats
|
|
|
|
if prevStatus != newStatus {
|
|
s.handleStatusChange(m, record, prevStatus, newStatus, result)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleStatusChange sends notifications when monitor status changes
|
|
func (s *Scheduler) handleStatusChange(m *monitor.Monitor, record *core.Record, prevStatus, newStatus monitor.Status, result *monitor.CheckResult) {
|
|
userID := record.GetString("user")
|
|
if userID == "" {
|
|
return
|
|
}
|
|
|
|
var title, message string
|
|
isRecovery := false
|
|
|
|
switch {
|
|
case newStatus == monitor.StatusDown && prevStatus != monitor.StatusDown:
|
|
title = fmt.Sprintf("Monitor Down: %s", m.Name)
|
|
message = fmt.Sprintf("The monitor %s (%s) is now DOWN.\n\nError: %s", m.Name, m.URL, result.Msg)
|
|
case prevStatus == monitor.StatusDown && newStatus == monitor.StatusUp:
|
|
title = fmt.Sprintf("Monitor Recovered: %s", m.Name)
|
|
message = fmt.Sprintf("The monitor %s (%s) is now UP.\n\nResponse time: %dms", m.Name, m.URL, result.Ping)
|
|
isRecovery = true
|
|
default:
|
|
return
|
|
}
|
|
|
|
s.createIncident(m, prevStatus, newStatus, result, isRecovery)
|
|
|
|
// Send notification via AlertManager if available
|
|
if s.alertCallback != nil {
|
|
link := fmt.Sprintf("/monitor/%s", m.ID)
|
|
linkText := "View Monitor"
|
|
s.alertCallback(userID, title, message, link, linkText)
|
|
}
|
|
|
|
log.Printf("[monitor-scheduler] Status change: %s -> %s for %s", prevStatus, newStatus, m.Name)
|
|
}
|
|
|
|
// createIncident creates an incident record for the status change
|
|
func (s *Scheduler) createIncident(m *monitor.Monitor, prevStatus, newStatus monitor.Status, result *monitor.CheckResult, isRecovery bool) {
|
|
if isRecovery {
|
|
records, err := s.app.FindRecordsByFilter(
|
|
"incidents",
|
|
"monitor = {:monitor} && type = {:type} && (status = {:open} || status = {:acknowledged})",
|
|
"-started_at",
|
|
0,
|
|
0,
|
|
dbx.Params{
|
|
"monitor": m.ID,
|
|
"type": incident.TypeMonitorDown,
|
|
"open": incident.StatusOpen,
|
|
"acknowledged": incident.StatusAcknowledged,
|
|
},
|
|
)
|
|
if err != nil {
|
|
log.Printf("[monitor-scheduler] Failed to find open incident: %v", err)
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
for _, record := range records {
|
|
record.Set("status", incident.StatusResolved)
|
|
record.Set("resolved_at", now)
|
|
record.Set("resolution", fmt.Sprintf("Monitor recovered: %s", result.Msg))
|
|
if err := s.app.Save(record); err != nil {
|
|
log.Printf("[monitor-scheduler] Failed to resolve incident: %v", err)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if newStatus != monitor.StatusDown || prevStatus == monitor.StatusDown {
|
|
return
|
|
}
|
|
|
|
existing, err := s.app.FindFirstRecordByFilter(
|
|
"incidents",
|
|
"monitor = {:monitor} && type = {:type} && (status = {:open} || status = {:acknowledged})",
|
|
dbx.Params{
|
|
"monitor": m.ID,
|
|
"type": incident.TypeMonitorDown,
|
|
"open": incident.StatusOpen,
|
|
"acknowledged": incident.StatusAcknowledged,
|
|
},
|
|
)
|
|
if err == nil && existing != nil {
|
|
return
|
|
}
|
|
|
|
incidentCollection, err := s.app.FindCollectionByNameOrId("incidents")
|
|
if err != nil {
|
|
log.Printf("[monitor-scheduler] Could not create incident: %v", err)
|
|
return
|
|
}
|
|
|
|
record := core.NewRecord(incidentCollection)
|
|
record.Set("title", fmt.Sprintf("Monitor Down: %s", m.Name))
|
|
record.Set("description", result.Msg)
|
|
record.Set("type", incident.TypeMonitorDown)
|
|
record.Set("severity", incident.SeverityHigh)
|
|
record.Set("status", incident.StatusOpen)
|
|
record.Set("monitor", m.ID)
|
|
record.Set("started_at", time.Now())
|
|
record.Set("user", m.UserID)
|
|
|
|
if err := s.app.Save(record); err != nil {
|
|
log.Printf("[monitor-scheduler] Failed to save incident: %v", err)
|
|
}
|
|
}
|
|
|
|
// loadMonitors loads active monitors from the database
|
|
func (s *Scheduler) loadMonitors() error {
|
|
records, err := s.app.FindRecordsByFilter("monitors", "active = true", "-created", 0, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to query monitors: %w", err)
|
|
}
|
|
|
|
for _, record := range records {
|
|
m := recordToMonitor(record)
|
|
s.monitors.Set(m.ID, &ScheduledMonitor{
|
|
Monitor: m,
|
|
NextCheck: time.Now(),
|
|
})
|
|
}
|
|
|
|
log.Printf("[monitor-scheduler] Loaded %d monitors", len(records))
|
|
return nil
|
|
}
|
|
|
|
// AddMonitor adds a new monitor to the scheduler
|
|
func (s *Scheduler) AddMonitor(record *core.Record) {
|
|
m := recordToMonitor(record)
|
|
|
|
s.monitors.Set(m.ID, &ScheduledMonitor{
|
|
Monitor: m,
|
|
NextCheck: time.Now(),
|
|
})
|
|
|
|
log.Printf("[monitor-scheduler] Added monitor: %s (%s)", m.Name, m.Type)
|
|
}
|
|
|
|
// UpdateMonitor updates a monitor in the scheduler
|
|
func (s *Scheduler) UpdateMonitor(record *core.Record) {
|
|
m := recordToMonitor(record)
|
|
|
|
// Get existing scheduled monitor
|
|
if sm, ok := s.monitors.GetOk(m.ID); ok {
|
|
sm.mu.Lock()
|
|
wasPaused := sm.Monitor.Status == monitor.StatusPaused || !sm.Monitor.Active
|
|
nowActive := m.Active && m.Status != monitor.StatusPaused
|
|
// If monitor just became active (resumed), reset NextCheck so it's checked immediately
|
|
if wasPaused && nowActive {
|
|
sm.NextCheck = time.Now()
|
|
}
|
|
sm.Monitor = m
|
|
sm.mu.Unlock()
|
|
} else {
|
|
s.monitors.Set(m.ID, &ScheduledMonitor{
|
|
Monitor: m,
|
|
NextCheck: time.Now(),
|
|
})
|
|
}
|
|
|
|
log.Printf("[monitor-scheduler] Updated monitor: %s", m.Name)
|
|
}
|
|
|
|
// RemoveMonitor removes a monitor from the scheduler
|
|
func (s *Scheduler) RemoveMonitor(monitorID string) {
|
|
s.monitors.Remove(monitorID)
|
|
log.Printf("[monitor-scheduler] Removed monitor: %s", monitorID)
|
|
}
|
|
|
|
// RunManualCheck runs a manual check for a monitor
|
|
func (s *Scheduler) RunManualCheck(monitorID string) (*monitor.CheckResult, error) {
|
|
// Get monitor from database
|
|
record, err := s.app.FindRecordById("monitors", monitorID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("monitor not found: %w", err)
|
|
}
|
|
|
|
m := recordToMonitor(record)
|
|
|
|
// Get checker
|
|
checker, ok := s.registry.Get(m.Type)
|
|
if !ok {
|
|
return nil, fmt.Errorf("no checker for type: %s", m.Type)
|
|
}
|
|
|
|
// Run check
|
|
timeout := time.Duration(m.Timeout) * time.Second
|
|
if timeout == 0 {
|
|
timeout = 30 * time.Second
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
|
|
result := checker.Check(ctx, m)
|
|
if err := s.saveResult(m, result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetUptimeStats calculates uptime statistics for a monitor
|
|
func (s *Scheduler) GetUptimeStats(monitorID string, hours int) (*monitor.UptimeStats, error) {
|
|
// Query heartbeats from the last N hours
|
|
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
|
|
|
records, err := s.app.FindRecordsByFilter(
|
|
"monitor_heartbeats",
|
|
"monitor = {:monitorId} && time >= {:since}",
|
|
"-time",
|
|
0,
|
|
0,
|
|
map[string]any{
|
|
"monitorId": monitorID,
|
|
"since": since.Format("2006-01-02 15:04:05"),
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query heartbeats: %w", err)
|
|
}
|
|
|
|
stats := &monitor.UptimeStats{}
|
|
|
|
for _, record := range records {
|
|
stats.Total++
|
|
status := record.GetString("status")
|
|
if status == string(monitor.StatusUp) {
|
|
stats.Up++
|
|
} else if status == string(monitor.StatusDown) {
|
|
stats.Down++
|
|
}
|
|
}
|
|
|
|
if stats.Total > 0 {
|
|
uptime := float64(stats.Up) / float64(stats.Total) * 100
|
|
switch hours {
|
|
case 24:
|
|
stats.Uptime24h = uptime
|
|
case 168: // 7 days
|
|
stats.Uptime7d = uptime
|
|
case 720: // 30 days
|
|
stats.Uptime30d = uptime
|
|
}
|
|
}
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
func (s *Scheduler) calculateUptimeStats(monitorID string) (map[string]float64, error) {
|
|
stats := make(map[string]float64)
|
|
for _, window := range []struct {
|
|
hours int
|
|
key string
|
|
}{
|
|
{24, "uptime_24h"},
|
|
{168, "uptime_7d"},
|
|
{720, "uptime_30d"},
|
|
} {
|
|
windowStats, err := s.GetUptimeStats(monitorID, window.hours)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if windowStats.Total > 0 {
|
|
stats[window.key] = float64(windowStats.Up) / float64(windowStats.Total) * 100
|
|
}
|
|
stats[fmt.Sprintf("checks_%s", window.key)] = float64(windowStats.Total)
|
|
}
|
|
|
|
avgPing, err := s.averagePing(monitorID, 24)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stats["avg_ping_24h"] = avgPing
|
|
return stats, nil
|
|
}
|
|
|
|
func (s *Scheduler) averagePing(monitorID string, hours int) (float64, error) {
|
|
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
|
records, err := s.app.FindRecordsByFilter(
|
|
"monitor_heartbeats",
|
|
"monitor = {:monitorId} && time >= {:since} && status = {:status}",
|
|
"-time",
|
|
0,
|
|
0,
|
|
dbx.Params{
|
|
"monitorId": monitorID,
|
|
"since": since.Format("2006-01-02 15:04:05"),
|
|
"status": string(monitor.StatusUp),
|
|
},
|
|
)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if len(records) == 0 {
|
|
return 0, nil
|
|
}
|
|
total := 0
|
|
for _, record := range records {
|
|
total += record.GetInt("ping")
|
|
}
|
|
return float64(total) / float64(len(records)), nil
|
|
}
|
|
|
|
// recordToMonitor converts a PocketBase record to a Monitor struct
|
|
func recordToMonitor(record *core.Record) *monitor.Monitor {
|
|
m := &monitor.Monitor{
|
|
ID: record.Id,
|
|
Name: record.GetString("name"),
|
|
Type: record.GetString("type"),
|
|
URL: record.GetString("url"),
|
|
Hostname: record.GetString("hostname"),
|
|
Port: record.GetInt("port"),
|
|
Method: record.GetString("method"),
|
|
Headers: record.GetString("headers"),
|
|
Body: record.GetString("body"),
|
|
Interval: record.GetInt("interval"),
|
|
Timeout: record.GetInt("timeout"),
|
|
Retries: record.GetInt("retries"),
|
|
RetryInterval: record.GetInt("retry_interval"),
|
|
MaxRedirects: record.GetInt("max_redirects"),
|
|
Keyword: record.GetString("keyword"),
|
|
JSONQuery: record.GetString("json_query"),
|
|
ExpectedValue: record.GetString("expected_value"),
|
|
InvertKeyword: record.GetBool("invert_keyword"),
|
|
DNSResolveServer: record.GetString("dns_resolve_server"),
|
|
DNSResolverMode: record.GetString("dns_resolver_mode"),
|
|
Status: monitor.Status(record.GetString("status")),
|
|
Active: record.GetBool("active"),
|
|
UserID: record.GetString("user"),
|
|
Description: record.GetString("description"),
|
|
CertExpiryNotification: record.GetBool("cert_expiry_notification"),
|
|
CertExpiryDays: record.GetInt("cert_expiry_days"),
|
|
IgnoreTLSError: record.GetBool("ignore_tls_error"),
|
|
}
|
|
|
|
// Parse JSON fields
|
|
if tagsData := record.Get("tags"); tagsData != nil {
|
|
if tags, ok := tagsData.([]string); ok {
|
|
m.Tags = tags
|
|
}
|
|
}
|
|
|
|
if statsData := record.Get("uptime_stats"); statsData != nil {
|
|
stats := map[string]float64{}
|
|
if raw, err := json.Marshal(statsData); err == nil {
|
|
if err := json.Unmarshal(raw, &stats); err == nil {
|
|
m.UptimeStats = stats
|
|
}
|
|
}
|
|
}
|
|
|
|
if lastCheck := record.Get("last_check"); lastCheck != nil {
|
|
if t, ok := lastCheck.(time.Time); ok {
|
|
m.LastCheck = t
|
|
}
|
|
}
|
|
|
|
return m
|
|
}
|
|
|
|
// CleanupOldHeartbeats removes heartbeats older than retention period
|
|
func (s *Scheduler) CleanupOldHeartbeats(retentionDays int) error {
|
|
cutoff := time.Now().AddDate(0, 0, -retentionDays)
|
|
|
|
records, err := s.app.FindRecordsByFilter(
|
|
"monitor_heartbeats",
|
|
"time < {:cutoff}",
|
|
"",
|
|
0,
|
|
0,
|
|
map[string]any{
|
|
"cutoff": cutoff.Format("2006-01-02 15:04:05"),
|
|
},
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find old heartbeats: %w", err)
|
|
}
|
|
|
|
deleted := 0
|
|
for _, record := range records {
|
|
if err := s.app.Delete(record); err == nil {
|
|
deleted++
|
|
}
|
|
}
|
|
|
|
log.Printf("[monitor-scheduler] Cleaned up %d old heartbeats", deleted)
|
|
return nil
|
|
}
|