Files
Beszel/internal/hub/monitors/api.go
T
Tomas Dvorak b6f40af67f
Build Docker images / Hub (push) Failing after 52s
feat(hub): improve WHOIS lookup reliability and enhance site UI
Implement enhanced WHOIS lookup strategies, specifically targeting .eu
domains through EURid web scraping and alternative services to
improve data accuracy for expiry dates.

- Add EURid web scraping and alternative WHOIS service support for .eu domains
- Increase timeouts for .eu domain lookups in TCP and native WHOIS
- Improve domain scheduler to prevent overwriting valid data with zero-value dates
- Enhance site UI with subdomain indicators in domain tables
- Add filtering capabilities to the calendar view
- Implement drag-and-drop reordering for systems table
- Add new debug and test utilities for WHOIS and date parsing logic
2026-05-08 11:07:34 +02:00

721 lines
22 KiB
Go

package monitors
import (
"encoding/json"
"log"
"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)
}
// HeartbeatSummary represents a minimal heartbeat for the monitor list
type HeartbeatSummary struct {
Status string `json:"status"`
Ping int `json:"ping"`
Time time.Time `json:"time"`
}
// 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"`
RecentHeartbeats []HeartbeatSummary `json:"recent_heartbeats,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,
})
}
// getRecentHeartbeats fetches the last N heartbeats for a monitor
func (h *APIHandler) getRecentHeartbeats(monitorID string, limit int) []HeartbeatSummary {
records, err := h.app.FindRecordsByFilter(
"monitor_heartbeats",
"monitor = {:monitorId}",
"-time",
limit,
0,
map[string]any{"monitorId": monitorID},
)
if err != nil {
return nil
}
heartbeats := make([]HeartbeatSummary, 0, len(records))
for _, hb := range records {
var t time.Time
if ts := hb.GetDateTime("time"); !ts.IsZero() {
t = ts.Time()
}
heartbeats = append(heartbeats, HeartbeatSummary{
Status: hb.GetString("status"),
Ping: hb.GetInt("ping"),
Time: t,
})
}
return heartbeats
}
// 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)
}
resp := recordToResponse(record)
resp.RecentHeartbeats = h.getRecentHeartbeats(id, 12)
return e.JSON(http.StatusOK, resp)
}
// 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)
// Run initial check synchronously so the monitor shows real status immediately
result, err := h.scheduler.RunManualCheck(record.Id)
if err != nil {
log.Printf("[monitor-api] Initial check failed for %s: %v", record.Id, err)
// Note: The monitor will remain in pending status and the scheduler will retry
} else {
log.Printf("[monitor-api] Initial check completed for %s: status=%s, ping=%v", record.Id, result.Status, result.Ping)
}
// Re-fetch the updated record to get the new status
updatedRecord, err := h.app.FindRecordById("monitors", record.Id)
if err == nil {
record = updatedRecord
}
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)
// Run an immediate check so the monitor shows real status instead of pending
if _, err := h.scheduler.RunManualCheck(record.Id); err != nil {
log.Printf("[monitor-api] Resume check failed for %s: %v", record.Id, err)
}
// Re-fetch the updated record to get the new status
updatedRecord, err := h.app.FindRecordById("monitors", record.Id)
if err == nil {
record = updatedRecord
}
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
}