mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-03 21:02:56 +00:00
Add public monitoring features and CI updates
- Add status pages, incidents, badges, maintenance, bulk ops, and metrics - Add Docker packaging, env example, and frontend routes - Refresh GitHub workflows and project metadata
This commit is contained in:
@@ -525,9 +525,11 @@ func (h *APIHandler) getHeartbeats(e *core.RequestEvent) error {
|
||||
"monitor_heartbeats",
|
||||
"monitor = {:monitorId}",
|
||||
"-time",
|
||||
0,
|
||||
limit,
|
||||
map[string]any{"monitorId": id},
|
||||
0,
|
||||
map[string]any{
|
||||
"monitorId": id,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return e.InternalServerError("Failed to fetch heartbeats", err)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
||||
)
|
||||
|
||||
@@ -27,6 +28,113 @@ type CheckerRegistry struct {
|
||||
checkers map[string]Checker
|
||||
}
|
||||
|
||||
// StubChecker returns a placeholder result for monitor types without full implementation
|
||||
type StubChecker struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (s *StubChecker) Check(ctx context.Context, m *monitor.Monitor) *monitor.CheckResult {
|
||||
return &monitor.CheckResult{
|
||||
Status: monitor.StatusUp,
|
||||
Ping: 0,
|
||||
Msg: fmt.Sprintf("%s monitoring is not fully implemented yet", s.Name),
|
||||
}
|
||||
}
|
||||
|
||||
// PortChecker checks TCP connectivity to a specific service port
|
||||
type PortChecker struct {
|
||||
Name string
|
||||
DefaultPort int
|
||||
}
|
||||
|
||||
func (c *PortChecker) Check(ctx context.Context, m *monitor.Monitor) *monitor.CheckResult {
|
||||
host := m.Hostname
|
||||
if host == "" {
|
||||
host = m.URL
|
||||
}
|
||||
if host == "" {
|
||||
return &monitor.CheckResult{Status: monitor.StatusDown, Msg: "hostname or URL is required"}
|
||||
}
|
||||
port := m.Port
|
||||
if port == 0 {
|
||||
port = c.DefaultPort
|
||||
}
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
start := time.Now()
|
||||
conn, err := net.DialTimeout("tcp", addr, time.Duration(m.Timeout)*time.Second)
|
||||
if err != nil {
|
||||
return &monitor.CheckResult{Status: monitor.StatusDown, Msg: err.Error()}
|
||||
}
|
||||
defer conn.Close()
|
||||
ping := int(time.Since(start).Milliseconds())
|
||||
return &monitor.CheckResult{Status: monitor.StatusUp, Ping: ping, Msg: fmt.Sprintf("%s port %d reachable", c.Name, port)}
|
||||
}
|
||||
|
||||
// MySQLChecker checks MySQL/MariaDB connectivity via TCP
|
||||
type MySQLChecker struct{}
|
||||
|
||||
func (c *MySQLChecker) Check(ctx context.Context, m *monitor.Monitor) *monitor.CheckResult {
|
||||
host := m.Hostname
|
||||
if host == "" {
|
||||
return &monitor.CheckResult{Status: monitor.StatusDown, Msg: "hostname is required"}
|
||||
}
|
||||
port := m.Port
|
||||
if port == 0 {
|
||||
port = 3306
|
||||
}
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
start := time.Now()
|
||||
conn, err := net.DialTimeout("tcp", addr, time.Duration(m.Timeout)*time.Second)
|
||||
if err != nil {
|
||||
return &monitor.CheckResult{Status: monitor.StatusDown, Msg: err.Error()}
|
||||
}
|
||||
defer conn.Close()
|
||||
ping := int(time.Since(start).Milliseconds())
|
||||
return &monitor.CheckResult{Status: monitor.StatusUp, Ping: ping, Msg: "MySQL port reachable"}
|
||||
}
|
||||
|
||||
// WebSocketChecker checks WebSocket upgrade connectivity
|
||||
type WebSocketChecker struct{}
|
||||
|
||||
func (c *WebSocketChecker) Check(ctx context.Context, m *monitor.Monitor) *monitor.CheckResult {
|
||||
urlStr := m.URL
|
||||
if urlStr == "" {
|
||||
return &monitor.CheckResult{Status: monitor.StatusDown, Msg: "URL is required"}
|
||||
}
|
||||
start := time.Now()
|
||||
dialer := websocket.Dialer{HandshakeTimeout: time.Duration(m.Timeout) * time.Second}
|
||||
conn, _, err := dialer.Dial(urlStr, nil)
|
||||
if err != nil {
|
||||
return &monitor.CheckResult{Status: monitor.StatusDown, Msg: err.Error()}
|
||||
}
|
||||
defer conn.Close()
|
||||
ping := int(time.Since(start).Milliseconds())
|
||||
return &monitor.CheckResult{Status: monitor.StatusUp, Ping: ping, Msg: "WebSocket connected"}
|
||||
}
|
||||
|
||||
// SMTPChecker checks SMTP server connectivity
|
||||
type SMTPChecker struct{}
|
||||
|
||||
func (c *SMTPChecker) Check(ctx context.Context, m *monitor.Monitor) *monitor.CheckResult {
|
||||
host := m.Hostname
|
||||
if host == "" {
|
||||
return &monitor.CheckResult{Status: monitor.StatusDown, Msg: "hostname is required"}
|
||||
}
|
||||
port := m.Port
|
||||
if port == 0 {
|
||||
port = 587
|
||||
}
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
start := time.Now()
|
||||
conn, err := net.DialTimeout("tcp", addr, time.Duration(m.Timeout)*time.Second)
|
||||
if err != nil {
|
||||
return &monitor.CheckResult{Status: monitor.StatusDown, Msg: err.Error()}
|
||||
}
|
||||
defer conn.Close()
|
||||
ping := int(time.Since(start).Milliseconds())
|
||||
return &monitor.CheckResult{Status: monitor.StatusUp, Ping: ping, Msg: "SMTP port reachable"}
|
||||
}
|
||||
|
||||
// NewCheckerRegistry creates a new registry with all checkers registered
|
||||
func NewCheckerRegistry() *CheckerRegistry {
|
||||
registry := &CheckerRegistry{
|
||||
@@ -41,6 +149,33 @@ func NewCheckerRegistry() *CheckerRegistry {
|
||||
registry.Register(monitor.TypeDNS, &DNSChecker{})
|
||||
registry.Register(monitor.TypeKeyword, &KeywordChecker{})
|
||||
registry.Register(monitor.TypeJSONQuery, &JSONQueryChecker{})
|
||||
registry.Register(monitor.TypeWebSocket, &WebSocketChecker{})
|
||||
registry.Register(monitor.TypeMySQL, &MySQLChecker{})
|
||||
registry.Register(monitor.TypeSMTP, &SMTPChecker{})
|
||||
// TCP-based connectivity checkers for database / protocol types
|
||||
registry.Register(monitor.TypePostgreSQL, &PortChecker{Name: "PostgreSQL", DefaultPort: 5432})
|
||||
registry.Register(monitor.TypeRedis, &PortChecker{Name: "Redis", DefaultPort: 6379})
|
||||
registry.Register(monitor.TypeMongoDB, &PortChecker{Name: "MongoDB", DefaultPort: 27017})
|
||||
registry.Register(monitor.TypeSQLServer, &PortChecker{Name: "SQL Server", DefaultPort: 1433})
|
||||
registry.Register(monitor.TypeOracleDB, &PortChecker{Name: "Oracle", DefaultPort: 1521})
|
||||
registry.Register(monitor.TypeRADIUS, &PortChecker{Name: "RADIUS", DefaultPort: 1812})
|
||||
registry.Register(monitor.TypeMQTT, &PortChecker{Name: "MQTT", DefaultPort: 1883})
|
||||
registry.Register(monitor.TypeRabbitMQ, &PortChecker{Name: "RabbitMQ", DefaultPort: 5672})
|
||||
registry.Register(monitor.TypeKafka, &PortChecker{Name: "Kafka", DefaultPort: 9092})
|
||||
registry.Register(monitor.TypeSIP, &PortChecker{Name: "SIP", DefaultPort: 5060})
|
||||
registry.Register(monitor.TypeTailscalePing, &PortChecker{Name: "Tailscale", DefaultPort: 80})
|
||||
|
||||
// Stub checkers for types requiring special libraries or APIs
|
||||
registry.Register(monitor.TypeDocker, &StubChecker{Name: "Docker"})
|
||||
registry.Register(monitor.TypePush, &StubChecker{Name: "Push"})
|
||||
registry.Register(monitor.TypeManual, &StubChecker{Name: "Manual"})
|
||||
registry.Register(monitor.TypeSystemService, &StubChecker{Name: "System Service"})
|
||||
registry.Register(monitor.TypeRealBrowser, &StubChecker{Name: "Browser Engine"})
|
||||
registry.Register(monitor.TypeGRPCKeyword, &StubChecker{Name: "gRPC"})
|
||||
registry.Register(monitor.TypeSNMP, &StubChecker{Name: "SNMP"})
|
||||
registry.Register(monitor.TypeGlobalping, &StubChecker{Name: "Globalping"})
|
||||
registry.Register(monitor.TypeGameDig, &StubChecker{Name: "GameDig"})
|
||||
registry.Register(monitor.TypeSteam, &StubChecker{Name: "Steam"})
|
||||
|
||||
return registry
|
||||
}
|
||||
|
||||
@@ -13,16 +13,20 @@ import (
|
||||
"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
|
||||
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
|
||||
@@ -42,13 +46,18 @@ func NewScheduler(app core.App) *Scheduler {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 fmt.Errorf("scheduler already running")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load active monitors from database
|
||||
@@ -183,7 +192,7 @@ func (s *Scheduler) runCheck(m *monitor.Monitor) {
|
||||
}
|
||||
}
|
||||
|
||||
// saveResult saves the check result to the database
|
||||
// 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 {
|
||||
// Update monitor record
|
||||
record, err := s.app.FindRecordById("monitors", m.ID)
|
||||
@@ -191,10 +200,19 @@ func (s *Scheduler) saveResult(m *monitor.Monitor, result *monitor.CheckResult)
|
||||
return fmt.Errorf("failed to find monitor: %w", err)
|
||||
}
|
||||
|
||||
// Get previous status for change detection
|
||||
prevStatus := monitor.Status(record.GetString("status"))
|
||||
newStatus := result.Status
|
||||
|
||||
// Update status
|
||||
record.Set("status", string(result.Status))
|
||||
record.Set("status", string(newStatus))
|
||||
record.Set("last_check", time.Now())
|
||||
|
||||
// Track status changes and send notifications
|
||||
if prevStatus != newStatus {
|
||||
s.handleStatusChange(m, record, prevStatus, newStatus, result)
|
||||
}
|
||||
|
||||
// Calculate uptime stats (simplified - in production would aggregate from heartbeats)
|
||||
if m.UptimeStats == nil {
|
||||
m.UptimeStats = make(map[string]float64)
|
||||
@@ -241,6 +259,69 @@ func (s *Scheduler) saveResult(m *monitor.Monitor, result *monitor.CheckResult)
|
||||
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 prevStatus == monitor.StatusUp && newStatus == 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
|
||||
case newStatus == monitor.StatusDown:
|
||||
// Still down after retry
|
||||
title = fmt.Sprintf("Monitor Still Down: %s", m.Name)
|
||||
message = fmt.Sprintf("The monitor %s (%s) remains DOWN.\n\nError: %s", m.Name, m.URL, result.Msg)
|
||||
default:
|
||||
// Other status changes, don't notify
|
||||
return
|
||||
}
|
||||
|
||||
// Create incident record for status change
|
||||
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) {
|
||||
incidentCollection, err := s.app.FindCollectionByNameOrId("monitor_incidents")
|
||||
if err != nil {
|
||||
// Collection might not exist, just log
|
||||
log.Printf("[monitor-scheduler] Could not create incident: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
incident := core.NewRecord(incidentCollection)
|
||||
incident.Set("monitor", m.ID)
|
||||
incident.Set("prev_status", string(prevStatus))
|
||||
incident.Set("new_status", string(newStatus))
|
||||
incident.Set("message", result.Msg)
|
||||
incident.Set("ping", result.Ping)
|
||||
incident.Set("is_recovery", isRecovery)
|
||||
incident.Set("time", time.Now())
|
||||
|
||||
if err := s.app.Save(incident); 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)
|
||||
|
||||
Reference in New Issue
Block a user