Files
Beszel/internal/hub/monitors/api.go
T
Tomas Dvorak fe5c7eaa95
Build Docker images / Hub (push) Failing after 1m35s
feat(hub,site): enhance domain intelligence and monitor performance
Implement comprehensive domain data collection including provider detection (DNS, hosting, email, CA), HTTP headers, TLS certificate chains, and SEO metadata. Added PageSpeed Insights integration for monitors to track Core Web Vitals.

- **hub**:
  - Add provider detection logic for DNS, email, and hosting.
  - Expand `Domain` entity to include SEO, headers, certificates, and enhanced registration details.
  - Implement automated collection of TLD, WHOIS raw data, and host country codes.
  - Update scheduler to track changes in providers and security settings (privacy/transfer lock).
  - Add PageSpeed check endpoint to monitor API.
- **site**:
  - Update domain table and detail views to display new intelligence (providers, headers, SEO).
  - Implement PageSpeed metrics visualization with Core Web Vitals status indicators.
  - Add display options for provider information in the domain list.
- **db**:
  - Add migration for new domain collection fields.
2026-05-14 13:33:03 +02:00

781 lines
24 KiB
Go

package monitors
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/henrygd/beszel/internal/hub/pagespeed"
"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)
api.POST("/{id}/pagespeed", h.runPageSpeedCheck)
}
// 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,
})
}
// runPageSpeedCheck runs a PageSpeed Insights check for a monitor
func (h *APIHandler) runPageSpeedCheck(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)
}
if record.GetString("user") != e.Auth.Id {
return e.ForbiddenError("Access denied", nil)
}
url := record.GetString("url")
if url == "" {
return e.BadRequestError("Monitor does not have a URL", nil)
}
// Get strategy from query param, default to mobile
strategy := e.Request.URL.Query().Get("strategy")
if strategy == "" {
strategy = "mobile"
}
if strategy != "mobile" && strategy != "desktop" {
return e.BadRequestError("strategy must be 'mobile' or 'desktop'", nil)
}
checker := pagespeed.NewChecker("")
metrics, err := checker.CheckURL(url, strategy)
if err != nil {
return e.InternalServerError("PageSpeed check failed", err)
}
vitals := pagespeed.GetCoreWebVitalsStatus(metrics)
return e.JSON(http.StatusOK, map[string]interface{}{
"performance": metrics.Performance,
"accessibility": metrics.Accessibility,
"bestPractices": metrics.BestPractices,
"seo": metrics.SEO,
"pwa": metrics.PWA,
"fcp": metrics.FCP,
"lcp": metrics.LCP,
"ttfb": metrics.TTFB,
"cls": metrics.CLS,
"tbt": metrics.TBT,
"speedIndex": metrics.SpeedIndex,
"tti": metrics.TTI,
"strategy": metrics.Strategy,
"checkedAt": metrics.CheckedAt,
"url": metrics.URL,
"vitals": vitals,
})
}
// 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
}