Files
Beszel/internal/hub/monitors/api.go
T
Tomas Dvorak 67254f89a9 update
2026-04-29 11:32:39 +02:00

654 lines
20 KiB
Go

package monitors
import (
"encoding/json"
"net/http"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
// APIHandler handles monitor API endpoints
type APIHandler struct {
app core.App
scheduler *Scheduler
}
// NewAPIHandler creates a new monitor API handler
func NewAPIHandler(app core.App, scheduler *Scheduler) *APIHandler {
return &APIHandler{
app: app,
scheduler: scheduler,
}
}
// RegisterRoutes registers monitor API routes
func (h *APIHandler) RegisterRoutes(se *core.ServeEvent) {
api := se.Router.Group("/api/beszel/monitors")
// Require auth for all routes
api.BindFunc(func(e *core.RequestEvent) error {
if e.Auth == nil {
return e.UnauthorizedError("Authentication required", nil)
}
return e.Next()
})
// CRUD endpoints
api.GET("", h.listMonitors)
api.POST("", h.createMonitor)
api.GET("/:id", h.getMonitor)
api.PATCH("/:id", h.updateMonitor)
api.DELETE("/:id", h.deleteMonitor)
// Action endpoints
api.POST("/:id/check", h.manualCheck)
api.POST("/:id/pause", h.pauseMonitor)
api.POST("/:id/resume", h.resumeMonitor)
api.GET("/:id/stats", h.getStats)
api.GET("/:id/heartbeats", h.getHeartbeats)
}
// MonitorResponse represents a monitor in API responses
type MonitorResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
URL string `json:"url,omitempty"`
Hostname string `json:"hostname,omitempty"`
Port int `json:"port,omitempty"`
Method string `json:"method,omitempty"`
Interval int `json:"interval"`
Timeout int `json:"timeout"`
Retries int `json:"retries"`
Status string `json:"status"`
Active bool `json:"active"`
Description string `json:"description,omitempty"`
LastCheck *time.Time `json:"last_check,omitempty"`
UptimeStats map[string]float64 `json:"uptime_stats,omitempty"`
Tags []string `json:"tags,omitempty"`
Keyword string `json:"keyword,omitempty"`
JSONQuery string `json:"json_query,omitempty"`
ExpectedValue string `json:"expected_value,omitempty"`
InvertKeyword bool `json:"invert_keyword"`
DNSResolveServer string `json:"dns_resolve_server,omitempty"`
DNSResolverMode string `json:"dns_resolver_mode,omitempty"`
CertExpiryNotification bool `json:"cert_expiry_notification"`
CertExpiryDays int `json:"cert_expiry_days,omitempty"`
IgnoreTLSError bool `json:"ignore_tls_error"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
// CreateMonitorRequest represents a request to create a monitor
type CreateMonitorRequest struct {
Name string `json:"name"`
Type string `json:"type"`
URL string `json:"url,omitempty"`
Hostname string `json:"hostname,omitempty"`
Port int `json:"port,omitempty"`
Method string `json:"method,omitempty"`
Headers string `json:"headers,omitempty"`
Body string `json:"body,omitempty"`
Interval int `json:"interval"`
Timeout int `json:"timeout"`
Retries int `json:"retries,omitempty"`
RetryInterval int `json:"retry_interval,omitempty"`
MaxRedirects int `json:"max_redirects,omitempty"`
Keyword string `json:"keyword,omitempty"`
JSONQuery string `json:"json_query,omitempty"`
ExpectedValue string `json:"expected_value,omitempty"`
InvertKeyword bool `json:"invert_keyword,omitempty"`
DNSResolveServer string `json:"dns_resolve_server,omitempty"`
DNSResolverMode string `json:"dns_resolver_mode,omitempty"`
Description string `json:"description,omitempty"`
Tags []string `json:"tags,omitempty"`
CertExpiryNotification bool `json:"cert_expiry_notification,omitempty"`
CertExpiryDays int `json:"cert_expiry_days,omitempty"`
IgnoreTLSError bool `json:"ignore_tls_error,omitempty"`
}
// UpdateMonitorRequest represents a request to update a monitor
type UpdateMonitorRequest struct {
Name *string `json:"name,omitempty"`
URL *string `json:"url,omitempty"`
Hostname *string `json:"hostname,omitempty"`
Port *int `json:"port,omitempty"`
Method *string `json:"method,omitempty"`
Headers *string `json:"headers,omitempty"`
Body *string `json:"body,omitempty"`
Interval *int `json:"interval,omitempty"`
Timeout *int `json:"timeout,omitempty"`
Retries *int `json:"retries,omitempty"`
RetryInterval *int `json:"retry_interval,omitempty"`
MaxRedirects *int `json:"max_redirects,omitempty"`
Keyword *string `json:"keyword,omitempty"`
JSONQuery *string `json:"json_query,omitempty"`
ExpectedValue *string `json:"expected_value,omitempty"`
InvertKeyword *bool `json:"invert_keyword,omitempty"`
DNSResolveServer *string `json:"dns_resolve_server,omitempty"`
DNSResolverMode *string `json:"dns_resolver_mode,omitempty"`
Active *bool `json:"active,omitempty"`
Description *string `json:"description,omitempty"`
Tags []string `json:"tags,omitempty"`
CertExpiryNotification *bool `json:"cert_expiry_notification,omitempty"`
CertExpiryDays *int `json:"cert_expiry_days,omitempty"`
IgnoreTLSError *bool `json:"ignore_tls_error,omitempty"`
}
// listMonitors returns all monitors for the authenticated user
func (h *APIHandler) listMonitors(e *core.RequestEvent) error {
userID := e.Auth.Id
records, err := h.app.FindRecordsByFilter(
"monitors",
"user = {:userId}",
"-created",
0,
0,
map[string]any{"userId": userID},
)
if err != nil {
return e.InternalServerError("Failed to fetch monitors", err)
}
monitors := make([]MonitorResponse, 0, len(records))
for _, record := range records {
monitors = append(monitors, recordToResponse(record))
}
return e.JSON(http.StatusOK, map[string]interface{}{
"monitors": monitors,
})
}
// getMonitor returns a single monitor by ID
func (h *APIHandler) getMonitor(e *core.RequestEvent) error {
id := e.Request.PathValue("id")
if id == "" {
return e.BadRequestError("Monitor ID is required", nil)
}
record, err := h.app.FindRecordById("monitors", id)
if err != nil {
return e.NotFoundError("Monitor not found", err)
}
// Verify ownership
if record.GetString("user") != e.Auth.Id {
return e.ForbiddenError("Access denied", nil)
}
return e.JSON(http.StatusOK, recordToResponse(record))
}
// createMonitor creates a new monitor
func (h *APIHandler) createMonitor(e *core.RequestEvent) error {
var req CreateMonitorRequest
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
return e.BadRequestError("Invalid request body", err)
}
// Validate required fields
if req.Name == "" || req.Type == "" {
return e.BadRequestError("Name and type are required", nil)
}
// Set defaults
if req.Interval == 0 {
req.Interval = 60
}
if req.Timeout == 0 {
req.Timeout = 30
}
if req.Retries == 0 {
req.Retries = 1
}
// Get collection
collection, err := h.app.FindCollectionByNameOrId("monitors")
if err != nil {
return e.InternalServerError("Failed to get collection", err)
}
// Create record
record := core.NewRecord(collection)
record.Set("name", req.Name)
record.Set("type", req.Type)
record.Set("url", req.URL)
record.Set("hostname", req.Hostname)
record.Set("port", req.Port)
record.Set("method", req.Method)
record.Set("headers", req.Headers)
record.Set("body", req.Body)
record.Set("interval", req.Interval)
record.Set("timeout", req.Timeout)
record.Set("retries", req.Retries)
record.Set("retry_interval", req.RetryInterval)
record.Set("max_redirects", req.MaxRedirects)
record.Set("keyword", req.Keyword)
record.Set("json_query", req.JSONQuery)
record.Set("expected_value", req.ExpectedValue)
record.Set("invert_keyword", req.InvertKeyword)
record.Set("dns_resolve_server", req.DNSResolveServer)
record.Set("dns_resolver_mode", req.DNSResolverMode)
record.Set("status", string(monitor.StatusPending))
record.Set("active", true)
record.Set("user", e.Auth.Id)
record.Set("description", req.Description)
record.Set("tags", req.Tags)
record.Set("cert_expiry_notification", req.CertExpiryNotification)
record.Set("cert_expiry_days", req.CertExpiryDays)
record.Set("ignore_tls_error", req.IgnoreTLSError)
record.Set("uptime_stats", map[string]float64{})
if err := h.app.Save(record); err != nil {
return e.InternalServerError("Failed to create monitor", err)
}
// Add to scheduler
h.scheduler.AddMonitor(record)
return e.JSON(http.StatusCreated, recordToResponse(record))
}
// updateMonitor updates an existing monitor
func (h *APIHandler) updateMonitor(e *core.RequestEvent) error {
id := e.Request.PathValue("id")
if id == "" {
return e.BadRequestError("Monitor ID is required", nil)
}
record, err := h.app.FindRecordById("monitors", id)
if err != nil {
return e.NotFoundError("Monitor not found", err)
}
// Verify ownership
if record.GetString("user") != e.Auth.Id {
return e.ForbiddenError("Access denied", nil)
}
var req UpdateMonitorRequest
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
return e.BadRequestError("Invalid request body", err)
}
// Update fields
if req.Name != nil {
record.Set("name", *req.Name)
}
if req.URL != nil {
record.Set("url", *req.URL)
}
if req.Hostname != nil {
record.Set("hostname", *req.Hostname)
}
if req.Port != nil {
record.Set("port", *req.Port)
}
if req.Method != nil {
record.Set("method", *req.Method)
}
if req.Headers != nil {
record.Set("headers", *req.Headers)
}
if req.Body != nil {
record.Set("body", *req.Body)
}
if req.Interval != nil {
record.Set("interval", *req.Interval)
}
if req.Timeout != nil {
record.Set("timeout", *req.Timeout)
}
if req.Retries != nil {
record.Set("retries", *req.Retries)
}
if req.RetryInterval != nil {
record.Set("retry_interval", *req.RetryInterval)
}
if req.MaxRedirects != nil {
record.Set("max_redirects", *req.MaxRedirects)
}
if req.Keyword != nil {
record.Set("keyword", *req.Keyword)
}
if req.JSONQuery != nil {
record.Set("json_query", *req.JSONQuery)
}
if req.ExpectedValue != nil {
record.Set("expected_value", *req.ExpectedValue)
}
if req.InvertKeyword != nil {
record.Set("invert_keyword", *req.InvertKeyword)
}
if req.DNSResolveServer != nil {
record.Set("dns_resolve_server", *req.DNSResolveServer)
}
if req.DNSResolverMode != nil {
record.Set("dns_resolver_mode", *req.DNSResolverMode)
}
if req.Active != nil {
record.Set("active", *req.Active)
}
if req.Description != nil {
record.Set("description", *req.Description)
}
if req.Tags != nil {
record.Set("tags", req.Tags)
}
if req.CertExpiryNotification != nil {
record.Set("cert_expiry_notification", *req.CertExpiryNotification)
}
if req.CertExpiryDays != nil {
record.Set("cert_expiry_days", *req.CertExpiryDays)
}
if req.IgnoreTLSError != nil {
record.Set("ignore_tls_error", *req.IgnoreTLSError)
}
if err := h.app.Save(record); err != nil {
return e.InternalServerError("Failed to update monitor", err)
}
// Update scheduler
h.scheduler.UpdateMonitor(record)
return e.JSON(http.StatusOK, recordToResponse(record))
}
// deleteMonitor deletes a monitor
func (h *APIHandler) deleteMonitor(e *core.RequestEvent) error {
id := e.Request.PathValue("id")
if id == "" {
return e.BadRequestError("Monitor ID is required", nil)
}
record, err := h.app.FindRecordById("monitors", id)
if err != nil {
return e.NotFoundError("Monitor not found", err)
}
// Verify ownership
if record.GetString("user") != e.Auth.Id {
return e.ForbiddenError("Access denied", nil)
}
// Remove from scheduler first
h.scheduler.RemoveMonitor(id)
if err := h.app.Delete(record); err != nil {
return e.InternalServerError("Failed to delete monitor", err)
}
return e.JSON(http.StatusOK, map[string]string{"message": "Monitor deleted"})
}
// manualCheck runs a manual check for a monitor
func (h *APIHandler) manualCheck(e *core.RequestEvent) error {
id := e.Request.PathValue("id")
if id == "" {
return e.BadRequestError("Monitor ID is required", nil)
}
record, err := h.app.FindRecordById("monitors", id)
if err != nil {
return e.NotFoundError("Monitor not found", err)
}
// Verify ownership
if record.GetString("user") != e.Auth.Id {
return e.ForbiddenError("Access denied", nil)
}
result, err := h.scheduler.RunManualCheck(id)
if err != nil {
return e.InternalServerError("Check failed", err)
}
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
func (h *APIHandler) pauseMonitor(e *core.RequestEvent) error {
id := e.Request.PathValue("id")
if id == "" {
return e.BadRequestError("Monitor ID is required", nil)
}
record, err := h.app.FindRecordById("monitors", id)
if err != nil {
return e.NotFoundError("Monitor not found", err)
}
// Verify ownership
if record.GetString("user") != e.Auth.Id {
return e.ForbiddenError("Access denied", nil)
}
record.Set("active", false)
record.Set("status", string(monitor.StatusPaused))
if err := h.app.Save(record); err != nil {
return e.InternalServerError("Failed to pause monitor", err)
}
h.scheduler.UpdateMonitor(record)
return e.JSON(http.StatusOK, recordToResponse(record))
}
// resumeMonitor resumes a paused monitor
func (h *APIHandler) resumeMonitor(e *core.RequestEvent) error {
id := e.Request.PathValue("id")
if id == "" {
return e.BadRequestError("Monitor ID is required", nil)
}
record, err := h.app.FindRecordById("monitors", id)
if err != nil {
return e.NotFoundError("Monitor not found", err)
}
// Verify ownership
if record.GetString("user") != e.Auth.Id {
return e.ForbiddenError("Access denied", nil)
}
record.Set("active", true)
record.Set("status", string(monitor.StatusPending))
if err := h.app.Save(record); err != nil {
return e.InternalServerError("Failed to resume monitor", err)
}
h.scheduler.UpdateMonitor(record)
return e.JSON(http.StatusOK, recordToResponse(record))
}
// getStats returns uptime statistics for a monitor
func (h *APIHandler) getStats(e *core.RequestEvent) error {
id := e.Request.PathValue("id")
if id == "" {
return e.BadRequestError("Monitor ID is required", nil)
}
record, err := h.app.FindRecordById("monitors", id)
if err != nil {
return e.NotFoundError("Monitor not found", err)
}
// Verify ownership
if record.GetString("user") != e.Auth.Id {
return e.ForbiddenError("Access denied", nil)
}
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_percent_24h": percent(stats24h),
"uptime_percent_7d": percent(stats7d),
"uptime_percent_30d": percent(stats30d),
"avg_ping_24h": avg24h,
})
}
// getHeartbeats returns recent heartbeats for a monitor
func (h *APIHandler) getHeartbeats(e *core.RequestEvent) error {
id := e.Request.PathValue("id")
if id == "" {
return e.BadRequestError("Monitor ID is required", nil)
}
record, err := h.app.FindRecordById("monitors", id)
if err != nil {
return e.NotFoundError("Monitor not found", err)
}
// Verify ownership
if record.GetString("user") != e.Auth.Id {
return e.ForbiddenError("Access denied", nil)
}
// Get limit from query, default 100
limit := 100
records, err := h.app.FindRecordsByFilter(
"monitor_heartbeats",
"monitor = {:monitorId}",
"-time",
limit,
0,
map[string]any{
"monitorId": id,
},
)
if err != nil {
return e.InternalServerError("Failed to fetch heartbeats", err)
}
heartbeats := make([]map[string]interface{}, 0, len(records))
for _, hb := range records {
heartbeats = append(heartbeats, map[string]interface{}{
"id": hb.Id,
"status": hb.GetString("status"),
"ping": hb.GetInt("ping"),
"msg": hb.GetString("msg"),
"cert_expiry": hb.GetInt("cert_expiry"),
"cert_valid": hb.GetBool("cert_valid"),
"time": hb.Get("time"),
})
}
return e.JSON(http.StatusOK, map[string]interface{}{
"heartbeats": heartbeats,
})
}
// recordToResponse converts a PocketBase record to MonitorResponse
func recordToResponse(record *core.Record) MonitorResponse {
resp := MonitorResponse{
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"),
Interval: record.GetInt("interval"),
Timeout: record.GetInt("timeout"),
Retries: record.GetInt("retries"),
Status: record.GetString("status"),
Active: record.GetBool("active"),
Description: record.GetString("description"),
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"),
CertExpiryNotification: record.GetBool("cert_expiry_notification"),
CertExpiryDays: record.GetInt("cert_expiry_days"),
IgnoreTLSError: record.GetBool("ignore_tls_error"),
Created: record.GetDateTime("created").Time(),
Updated: record.GetDateTime("updated").Time(),
}
if lc := record.GetDateTime("last_check"); !lc.IsZero() {
t := lc.Time()
resp.LastCheck = &t
}
if stats := record.Get("uptime_stats"); stats != nil {
var s map[string]float64
if raw, err := json.Marshal(stats); err == nil && json.Unmarshal(raw, &s) == nil {
resp.UptimeStats = s
}
}
// Handle tags
if tags := record.Get("tags"); tags != nil {
if t, ok := tags.([]string); ok {
resp.Tags = t
}
}
return resp
}
func percent(stats *monitor.UptimeStats) float64 {
if stats == nil || stats.Total == 0 {
return 0
}
return float64(stats.Up) / float64(stats.Total) * 100
}