This commit is contained in:
Tomas Dvorak
2026-04-29 11:32:39 +02:00
parent 193839bd27
commit 67254f89a9
20 changed files with 1792 additions and 1094 deletions
+58 -12
View File
@@ -6,6 +6,7 @@ import (
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
@@ -408,11 +409,25 @@ func (h *APIHandler) manualCheck(e *core.RequestEvent) error {
return e.InternalServerError("Check failed", err)
}
return e.JSON(http.StatusOK, map[string]interface{}{
response := map[string]interface{}{
"status": result.Status,
"ping": result.Ping,
"msg": result.Msg,
})
}
if hbs, err := h.app.FindRecordsByFilter(
"monitor_heartbeats",
"monitor = {:monitorId}",
"-time",
1,
0,
dbx.Params{"monitorId": id},
); err == nil && len(hbs) > 0 {
response["heartbeat_id"] = hbs[0].Id
response["time"] = hbs[0].GetDateTime("time").String()
}
return e.JSON(http.StatusOK, response)
}
// pauseMonitor pauses a monitor
@@ -493,11 +508,37 @@ func (h *APIHandler) getStats(e *core.RequestEvent) error {
stats24h, _ := h.scheduler.GetUptimeStats(id, 24)
stats7d, _ := h.scheduler.GetUptimeStats(id, 168)
stats30d, _ := h.scheduler.GetUptimeStats(id, 720)
avg24h := 0.0
if stats24h.Total > 0 {
records, _ := h.app.FindRecordsByFilter(
"monitor_heartbeats",
"monitor = {:monitorId} && time >= {:since} && status = {:status}",
"-time",
0,
0,
dbx.Params{
"monitorId": id,
"since": time.Now().Add(-24 * time.Hour).Format("2006-01-02 15:04:05"),
"status": string(monitor.StatusUp),
},
)
if len(records) > 0 {
totalPing := 0
for _, record := range records {
totalPing += record.GetInt("ping")
}
avg24h = float64(totalPing) / float64(len(records))
}
}
return e.JSON(http.StatusOK, map[string]interface{}{
"uptime_24h": stats24h,
"uptime_7d": stats7d,
"uptime_30d": stats30d,
"uptime_24h": stats24h,
"uptime_7d": stats7d,
"uptime_30d": stats30d,
"uptime_percent_24h": percent(stats24h),
"uptime_percent_7d": percent(stats7d),
"uptime_percent_30d": percent(stats30d),
"avg_ping_24h": avg24h,
})
}
@@ -582,16 +623,14 @@ func recordToResponse(record *core.Record) MonitorResponse {
Updated: record.GetDateTime("updated").Time(),
}
// Handle last_check
if lc := record.Get("last_check"); lc != nil {
if t, ok := lc.(time.Time); ok {
resp.LastCheck = &t
}
if lc := record.GetDateTime("last_check"); !lc.IsZero() {
t := lc.Time()
resp.LastCheck = &t
}
// Handle uptime_stats
if stats := record.Get("uptime_stats"); stats != nil {
if s, ok := stats.(map[string]float64); ok {
var s map[string]float64
if raw, err := json.Marshal(stats); err == nil && json.Unmarshal(raw, &s) == nil {
resp.UptimeStats = s
}
}
@@ -605,3 +644,10 @@ func recordToResponse(record *core.Record) MonitorResponse {
return resp
}
func percent(stats *monitor.UptimeStats) float64 {
if stats == nil || stats.Total == 0 {
return 0
}
return float64(stats.Up) / float64(stats.Total) * 100
}
@@ -0,0 +1,138 @@
package checks
import (
"context"
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/henrygd/beszel/internal/entities/monitor"
)
func TestHTTPCheckerReportsUpForSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
result := (&HTTPChecker{}).Check(context.Background(), &monitor.Monitor{
URL: server.URL,
Timeout: 2,
})
if result.Status != monitor.StatusUp {
t.Fatalf("expected status up, got %s: %s", result.Status, result.Msg)
}
if result.Ping < 0 {
t.Fatalf("expected non-negative ping, got %d", result.Ping)
}
}
func TestHTTPCheckerReportsDownForServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "broken", http.StatusInternalServerError)
}))
defer server.Close()
result := (&HTTPChecker{}).Check(context.Background(), &monitor.Monitor{
URL: server.URL,
Timeout: 2,
})
if result.Status != monitor.StatusDown {
t.Fatalf("expected status down, got %s", result.Status)
}
}
func TestKeywordCheckerHonorsKeywordAndInvert(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("service status ok"))
}))
defer server.Close()
result := (&KeywordChecker{}).Check(context.Background(), &monitor.Monitor{
URL: server.URL,
Timeout: 2,
Keyword: "status ok",
})
if result.Status != monitor.StatusUp {
t.Fatalf("expected keyword match to be up, got %s: %s", result.Status, result.Msg)
}
result = (&KeywordChecker{}).Check(context.Background(), &monitor.Monitor{
URL: server.URL,
Timeout: 2,
Keyword: "status ok",
InvertKeyword: true,
})
if result.Status != monitor.StatusDown {
t.Fatalf("expected inverted keyword match to be down, got %s", result.Status)
}
}
func TestJSONQueryCheckerMatchesNestedValue(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"status":"ok","version":2}}`))
}))
defer server.Close()
result := (&JSONQueryChecker{}).Check(context.Background(), &monitor.Monitor{
URL: server.URL,
Timeout: 2,
JSONQuery: "data.status",
ExpectedValue: "ok",
})
if result.Status != monitor.StatusUp {
t.Fatalf("expected json query match to be up, got %s: %s", result.Status, result.Msg)
}
result = (&JSONQueryChecker{}).Check(context.Background(), &monitor.Monitor{
URL: server.URL,
Timeout: 2,
JSONQuery: "data.status",
ExpectedValue: "down",
})
if result.Status != monitor.StatusDown {
t.Fatalf("expected json query mismatch to be down, got %s", result.Status)
}
}
func TestTCPCheckerUsesConfiguredHostAndPort(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
done := make(chan struct{})
go func() {
conn, err := listener.Accept()
if err == nil {
_ = conn.Close()
}
close(done)
}()
result := (&TCPChecker{}).Check(context.Background(), &monitor.Monitor{
Hostname: "127.0.0.1",
Port: listener.Addr().(*net.TCPAddr).Port,
Timeout: 2,
})
if result.Status != monitor.StatusUp {
t.Fatalf("expected tcp check to be up, got %s: %s", result.Status, result.Msg)
}
<-done
}
func TestDNSCheckerResolvesLocalhost(t *testing.T) {
result := (&DNSChecker{}).Check(context.Background(), &monitor.Monitor{
Hostname: "localhost",
DNSResolverMode: "A",
Timeout: 2,
})
if result.Status != monitor.StatusUp {
t.Fatalf("expected localhost DNS to resolve, got %s: %s", result.Status, result.Msg)
}
}
+151 -57
View File
@@ -2,13 +2,16 @@ 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"
)
@@ -194,50 +197,15 @@ func (s *Scheduler) runCheck(m *monitor.Monitor) {
// 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)
if err != nil {
return fmt.Errorf("failed to find monitor: %w", err)
}
// Get previous status for change detection
prevStatus := monitor.Status(record.GetString("status"))
newStatus := result.Status
now := time.Now()
// Update 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)
}
// Simple rolling uptime calculation (can be improved)
if result.Status == monitor.StatusUp {
m.UptimeStats["total"] = m.UptimeStats["total"] + 1
m.UptimeStats["up"] = m.UptimeStats["up"] + 1
} else {
m.UptimeStats["total"] = m.UptimeStats["total"] + 1
m.UptimeStats["down"] = m.UptimeStats["down"] + 1
}
if total := m.UptimeStats["total"]; total > 0 {
m.UptimeStats["uptime_24h"] = (m.UptimeStats["up"] / total) * 100
}
record.Set("uptime_stats", m.UptimeStats)
if err := s.app.Save(record); err != nil {
return fmt.Errorf("failed to update monitor: %w", err)
}
// Create heartbeat record
hbCollection, err := s.app.FindCollectionByNameOrId("monitor_heartbeats")
if err != nil {
return fmt.Errorf("failed to find heartbeats collection: %w", err)
@@ -250,12 +218,34 @@ func (s *Scheduler) saveResult(m *monitor.Monitor, result *monitor.CheckResult)
hbRecord.Set("msg", result.Msg)
hbRecord.Set("cert_expiry", result.CertExpiry)
hbRecord.Set("cert_valid", result.CertValid)
hbRecord.Set("time", time.Now())
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
}
@@ -270,23 +260,17 @@ func (s *Scheduler) handleStatusChange(m *monitor.Monitor, record *core.Record,
isRecovery := false
switch {
case prevStatus == monitor.StatusUp && newStatus == monitor.StatusDown:
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
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
@@ -301,23 +285,72 @@ func (s *Scheduler) handleStatusChange(m *monitor.Monitor, record *core.Record,
// 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 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 {
// 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())
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(incident); err != nil {
if err := s.app.Save(record); err != nil {
log.Printf("[monitor-scheduler] Failed to save incident: %v", err)
}
}
@@ -403,6 +436,9 @@ func (s *Scheduler) RunManualCheck(monitorID string) (*monitor.CheckResult, erro
defer cancel()
result := checker.Check(ctx, m)
if err := s.saveResult(m, result); err != nil {
return nil, err
}
return result, nil
}
@@ -453,6 +489,61 @@ func (s *Scheduler) GetUptimeStats(monitorID string, hours int) (*monitor.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{
@@ -493,8 +584,11 @@ func recordToMonitor(record *core.Record) *monitor.Monitor {
}
if statsData := record.Get("uptime_stats"); statsData != nil {
if stats, ok := statsData.(map[string]float64); ok {
m.UptimeStats = stats
stats := map[string]float64{}
if raw, err := json.Marshal(statsData); err == nil {
if err := json.Unmarshal(raw, &stats); err == nil {
m.UptimeStats = stats
}
}
}