Files
Beszel/internal/hub/statuspages/api.go
T

435 lines
13 KiB
Go

package statuspages
import (
"encoding/json"
"net/http"
"strings"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/henrygd/beszel/internal/entities/statuspage"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
)
// APIHandler handles status page API requests
type APIHandler struct {
app core.App
}
// NewAPIHandler creates a new status page API handler
func NewAPIHandler(app core.App) *APIHandler {
return &APIHandler{app: app}
}
// RegisterRoutes registers status page API routes
func (h *APIHandler) RegisterRoutes(se *core.ServeEvent) {
// Public status page (no auth required)
se.Router.GET("/status/:slug", h.getPublicStatusPage)
// Protected routes
api := se.Router.Group("/api/beszel/status-pages")
api.Bind(apis.RequireAuth())
api.GET("/", h.listStatusPages)
api.POST("/", h.createStatusPage)
api.GET("/{id}", h.getStatusPage)
api.PATCH("/{id}", h.updateStatusPage)
api.DELETE("/{id}", h.deleteStatusPage)
api.POST("/{id}/monitors", h.addMonitor)
api.DELETE("/{id}/monitors/{monitorId}", h.removeMonitor)
api.GET("/{id}/monitors", h.listMonitors)
}
// listStatusPages lists all status pages for the authenticated user
func (h *APIHandler) listStatusPages(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
records, err := h.app.FindAllRecords("status_pages",
dbx.NewExp("user = {:user}", dbx.Params{"user": authRecord.Id}),
)
if err != nil {
return e.InternalServerError("failed to fetch status pages", err)
}
pages := make([]statuspage.StatusPageResponse, 0, len(records))
for _, record := range records {
pages = append(pages, h.recordToResponse(record))
}
return e.JSON(http.StatusOK, pages)
}
// createStatusPage creates a new status page
func (h *APIHandler) createStatusPage(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
var req statuspage.CreateStatusPageRequest
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
return e.BadRequestError("invalid request body", err)
}
if req.Name == "" || req.Slug == "" {
return e.BadRequestError("name and slug are required", nil)
}
// Check if slug is unique
existing, _ := h.app.FindFirstRecordByFilter("status_pages", "slug = {:slug}",
dbx.Params{"slug": req.Slug})
if existing != nil {
return e.BadRequestError("slug already exists", nil)
}
collection, err := h.app.FindCollectionByNameOrId("status_pages")
if err != nil {
return e.InternalServerError("failed to find collection", err)
}
record := core.NewRecord(collection)
record.Set("name", req.Name)
record.Set("slug", strings.ToLower(req.Slug))
record.Set("title", req.Title)
record.Set("description", req.Description)
record.Set("logo", req.Logo)
record.Set("favicon", req.Favicon)
record.Set("theme", statuspage.ValidateTheme(req.Theme))
record.Set("custom_css", req.CustomCSS)
record.Set("public", req.Public)
record.Set("show_uptime", req.ShowUptime)
record.Set("user", authRecord.Id)
if err := h.app.Save(record); err != nil {
return e.InternalServerError("failed to create status page", err)
}
return e.JSON(http.StatusCreated, h.recordToResponse(record))
}
// getStatusPage gets a single status page
func (h *APIHandler) getStatusPage(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
id := e.Request.PathValue("id")
record, err := h.app.FindRecordById("status_pages", id)
if err != nil {
return e.NotFoundError("status page not found", err)
}
if record.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
return e.JSON(http.StatusOK, h.recordToResponse(record))
}
// updateStatusPage updates a status page
func (h *APIHandler) updateStatusPage(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
id := e.Request.PathValue("id")
record, err := h.app.FindRecordById("status_pages", id)
if err != nil {
return e.NotFoundError("status page not found", err)
}
if record.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
var req statuspage.UpdateStatusPageRequest
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
return e.BadRequestError("invalid request body", err)
}
if req.Name != nil {
record.Set("name", *req.Name)
}
if req.Title != nil {
record.Set("title", *req.Title)
}
if req.Description != nil {
record.Set("description", *req.Description)
}
if req.Logo != nil {
record.Set("logo", *req.Logo)
}
if req.Favicon != nil {
record.Set("favicon", *req.Favicon)
}
if req.Theme != nil {
record.Set("theme", statuspage.ValidateTheme(*req.Theme))
}
if req.CustomCSS != nil {
record.Set("custom_css", *req.CustomCSS)
}
if req.Public != nil {
record.Set("public", *req.Public)
}
if req.ShowUptime != nil {
record.Set("show_uptime", *req.ShowUptime)
}
if err := h.app.Save(record); err != nil {
return e.InternalServerError("failed to update status page", err)
}
return e.JSON(http.StatusOK, h.recordToResponse(record))
}
// deleteStatusPage deletes a status page
func (h *APIHandler) deleteStatusPage(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
id := e.Request.PathValue("id")
record, err := h.app.FindRecordById("status_pages", id)
if err != nil {
return e.NotFoundError("status page not found", err)
}
if record.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
if err := h.app.Delete(record); err != nil {
return e.InternalServerError("failed to delete status page", err)
}
return e.NoContent(http.StatusNoContent)
}
// addMonitor adds a monitor to a status page
func (h *APIHandler) addMonitor(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
statusPageID := e.Request.PathValue("id")
statusPage, err := h.app.FindRecordById("status_pages", statusPageID)
if err != nil {
return e.NotFoundError("status page not found", err)
}
if statusPage.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
var req statuspage.StatusPageMonitorRequest
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
return e.BadRequestError("invalid request body", err)
}
// Verify monitor exists and belongs to user
monitorRecord, err := h.app.FindRecordById("monitors", req.MonitorID)
if err != nil {
return e.NotFoundError("monitor not found", err)
}
if monitorRecord.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized for this monitor", nil)
}
collection, err := h.app.FindCollectionByNameOrId("status_page_monitors")
if err != nil {
return e.InternalServerError("failed to find collection", err)
}
record := core.NewRecord(collection)
record.Set("status_page", statusPageID)
record.Set("monitor", req.MonitorID)
record.Set("display_name", req.DisplayName)
record.Set("group", req.Group)
record.Set("sort_order", req.SortOrder)
record.Set("user", authRecord.Id)
if err := h.app.Save(record); err != nil {
return e.InternalServerError("failed to add monitor", err)
}
return e.JSON(http.StatusCreated, map[string]string{"status": "added"})
}
// removeMonitor removes a monitor from a status page
func (h *APIHandler) removeMonitor(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
statusPageID := e.Request.PathValue("id")
monitorID := e.Request.PathValue("monitorId")
// Find the link record
records, err := h.app.FindAllRecords("status_page_monitors",
dbx.HashExp{
"status_page": statusPageID,
"monitor": monitorID,
"user": authRecord.Id,
},
)
if err != nil || len(records) == 0 {
return e.NotFoundError("monitor link not found", err)
}
if err := h.app.Delete(records[0]); err != nil {
return e.InternalServerError("failed to remove monitor", err)
}
return e.NoContent(http.StatusNoContent)
}
// listMonitors lists monitors on a status page
func (h *APIHandler) listMonitors(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
statusPageID := e.Request.PathValue("id")
statusPage, err := h.app.FindRecordById("status_pages", statusPageID)
if err != nil {
return e.NotFoundError("status page not found", err)
}
if statusPage.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
records, err := h.app.FindAllRecords("status_page_monitors",
dbx.NewExp("status_page = {:statusPage}", dbx.Params{"statusPage": statusPageID}),
)
if err != nil {
return e.InternalServerError("failed to fetch monitors", err)
}
monitors := make([]map[string]interface{}, 0, len(records))
for _, record := range records {
monitors = append(monitors, map[string]interface{}{
"id": record.Id,
"monitor_id": record.GetString("monitor"),
"display_name": record.GetString("display_name"),
"group": record.GetString("group"),
"sort_order": record.GetInt("sort_order"),
})
}
return e.JSON(http.StatusOK, monitors)
}
// getPublicStatusPage gets a public status page by slug
func (h *APIHandler) getPublicStatusPage(e *core.RequestEvent) error {
slug := e.Request.PathValue("slug")
record, err := h.app.FindFirstRecordByFilter("status_pages", "slug = {:slug} && public = true",
dbx.Params{"slug": slug})
if err != nil {
return e.NotFoundError("status page not found", err)
}
// Build public status page
publicPage := h.buildPublicStatusPage(record)
return e.JSON(http.StatusOK, publicPage)
}
// buildPublicStatusPage builds a public status page from a record
func (h *APIHandler) buildPublicStatusPage(record *core.Record) *statuspage.PublicStatusPage {
statusPageID := record.Id
// Get linked monitors
links, err := h.app.FindAllRecords("status_page_monitors",
dbx.NewExp("status_page = {:statusPage}", dbx.Params{"statusPage": statusPageID}),
)
if err != nil {
links = []*core.Record{}
}
publicMonitors := make([]statuspage.PublicMonitorStatus, 0, len(links))
overallStatus := statuspage.StatusOperational
for _, link := range links {
monitorID := link.GetString("monitor")
monitorRecord, err := h.app.FindRecordById("monitors", monitorID)
if err != nil {
continue
}
status := monitorRecord.GetString("status")
if status == string(monitor.StatusDown) && overallStatus == statuspage.StatusOperational {
overallStatus = statuspage.StatusMajor
}
uptimeStats := make(map[string]float64)
if statsJSON := monitorRecord.GetString("uptime_stats"); statsJSON != "" {
json.Unmarshal([]byte(statsJSON), &uptimeStats)
}
publicMonitors = append(publicMonitors, statuspage.PublicMonitorStatus{
ID: monitorID,
Name: monitorRecord.GetString("name"),
DisplayName: link.GetString("display_name"),
Group: link.GetString("group"),
Status: status,
Uptime24h: uptimeStats["24h"],
Uptime7d: uptimeStats["7d"],
Uptime30d: uptimeStats["30d"],
LastCheck: monitorRecord.GetDateTime("last_check").Time(),
})
}
return &statuspage.PublicStatusPage{
ID: record.Id,
Name: record.GetString("name"),
Title: record.GetString("title"),
Description: record.GetString("description"),
Logo: record.GetString("logo"),
Favicon: record.GetString("favicon"),
Theme: record.GetString("theme"),
CustomCSS: record.GetString("custom_css"),
Monitors: publicMonitors,
OverallStatus: overallStatus,
UpdatedAt: record.GetDateTime("updated").Time(),
}
}
// recordToResponse converts a record to a response
func (h *APIHandler) recordToResponse(record *core.Record) statuspage.StatusPageResponse {
// Count monitors
count := 0
links, _ := h.app.FindAllRecords("status_page_monitors",
dbx.NewExp("status_page = {:statusPage}", dbx.Params{"statusPage": record.Id}),
)
count = len(links)
return statuspage.StatusPageResponse{
ID: record.Id,
Name: record.GetString("name"),
Slug: record.GetString("slug"),
Title: record.GetString("title"),
Description: record.GetString("description"),
Logo: record.GetString("logo"),
Favicon: record.GetString("favicon"),
Theme: record.GetString("theme"),
Public: record.GetBool("public"),
ShowUptime: record.GetBool("show_uptime"),
MonitorCount: count,
Created: record.GetDateTime("created").String(),
Updated: record.GetDateTime("updated").String(),
}
}