Files
Beszel/internal/hub/badges/api.go
T
Tomas Dvorak 8011d487f1 Add public monitoring features and CI updates
- Add status pages, incidents, badges, maintenance, bulk ops, and metrics
- Add Docker packaging, env example, and frontend routes
- Refresh GitHub workflows and project metadata
2026-04-27 11:10:18 +02:00

443 lines
12 KiB
Go

package badges
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
// APIHandler handles badge generation requests
type APIHandler struct {
app core.App
}
// NewAPIHandler creates a new badges API handler
func NewAPIHandler(app core.App) *APIHandler {
return &APIHandler{app: app}
}
// RegisterRoutes registers badge API routes
func (h *APIHandler) RegisterRoutes(se *core.ServeEvent) {
// Public badge endpoints (no auth required)
se.Router.GET("/badge/:type/:id", h.generateBadge)
se.Router.GET("/badge/:type/:id.svg", h.generateBadge)
// Protected badge management
api := se.Router.Group("/api/beszel/badges")
api.GET("/", h.listBadges)
api.POST("/", h.createBadge)
api.DELETE("/{id}", h.deleteBadge)
}
// BadgeRequest for creating a badge
type BadgeRequest struct {
Name string `json:"name"`
MonitorID string `json:"monitor_id,omitempty"`
DomainID string `json:"domain_id,omitempty"`
SystemID string `json:"system_id,omitempty"`
StatusPageID string `json:"status_page_id,omitempty"`
Type string `json:"type"` // uptime, status, response, cert
Label string `json:"label,omitempty"`
Color string `json:"color,omitempty"`
Style string `json:"style,omitempty"` // flat, flat-square, plastic, for-the-badge
}
// listBadges lists all badges for the authenticated user
func (h *APIHandler) listBadges(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
records, err := h.app.FindAllRecords("badges",
dbx.NewExp("user = {:user}", dbx.Params{"user": authRecord.Id}),
)
if err != nil {
return e.InternalServerError("failed to fetch badges", err)
}
badges := make([]map[string]interface{}, 0, len(records))
for _, record := range records {
badges = append(badges, map[string]interface{}{
"id": record.Id,
"name": record.GetString("name"),
"type": record.GetString("type"),
"monitor_id": record.GetString("monitor"),
"domain_id": record.GetString("domain"),
"system_id": record.GetString("system"),
"status_page_id": record.GetString("status_page"),
"label": record.GetString("label"),
"color": record.GetString("color"),
"style": record.GetString("style"),
"created": record.GetDateTime("created").Time(),
})
}
return e.JSON(http.StatusOK, badges)
}
// createBadge creates a new badge configuration
func (h *APIHandler) createBadge(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
var req BadgeRequest
if err := e.BindBody(&req); err != nil {
return e.BadRequestError("invalid request body", err)
}
if req.Name == "" || req.Type == "" {
return e.BadRequestError("name and type are required", nil)
}
collection, err := h.app.FindCollectionByNameOrId("badges")
if err != nil {
return e.InternalServerError("failed to find collection", err)
}
record := core.NewRecord(collection)
record.Set("name", req.Name)
record.Set("type", req.Type)
record.Set("monitor", req.MonitorID)
record.Set("domain", req.DomainID)
record.Set("system", req.SystemID)
record.Set("status_page", req.StatusPageID)
record.Set("label", req.Label)
record.Set("color", req.Color)
record.Set("style", req.Style)
record.Set("user", authRecord.Id)
if err := h.app.Save(record); err != nil {
return e.InternalServerError("failed to create badge", err)
}
// Generate badge URL
badgeURL := fmt.Sprintf("/badge/%s/%s.svg", req.Type, record.Id)
return e.JSON(http.StatusCreated, map[string]interface{}{
"id": record.Id,
"name": req.Name,
"type": req.Type,
"url": badgeURL,
"embed_code": fmt.Sprintf(`<img src="%s" alt="status">`, badgeURL),
"markdown": fmt.Sprintf(`![Status](%s)`, badgeURL),
})
}
// deleteBadge deletes a badge
func (h *APIHandler) deleteBadge(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("badges", id)
if err != nil {
return e.NotFoundError("badge 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 badge", err)
}
return e.NoContent(http.StatusNoContent)
}
// generateBadge generates an SVG badge
func (h *APIHandler) generateBadge(e *core.RequestEvent) error {
badgeType := e.Request.PathValue("type")
id := e.Request.PathValue("id")
// Get query parameters for customization
label := e.Request.URL.Query().Get("label")
color := e.Request.URL.Query().Get("color")
style := e.Request.URL.Query().Get("style")
if style == "" {
style = "flat"
}
// Find the resource and get status/uptime
var status, message, badgeColor string
switch badgeType {
case "monitor", "status":
record, err := h.app.FindRecordById("monitors", id)
if err != nil {
return e.NotFoundError("monitor not found", err)
}
status = record.GetString("status")
if label == "" {
label = record.GetString("name")
}
message = status
if status == "up" {
badgeColor = "brightgreen"
} else if status == "down" {
badgeColor = "red"
} else {
badgeColor = "yellow"
}
case "uptime":
record, err := h.app.FindRecordById("monitors", id)
if err != nil {
return e.NotFoundError("monitor not found", err)
}
if label == "" {
label = "uptime"
}
// Get uptime from stats
uptimeStats := record.GetString("uptime_stats")
if uptimeStats != "" {
// Parse simple uptime value if available
message = uptimeStats + "%"
} else {
message = "unknown"
}
badgeColor = "blue"
case "domain":
record, err := h.app.FindRecordById("domains", id)
if err != nil {
return e.NotFoundError("domain not found", err)
}
status = record.GetString("status")
if label == "" {
label = record.GetString("domain_name")
}
message = status
if status == "active" {
badgeColor = "brightgreen"
} else if status == "expiring" {
badgeColor = "yellow"
} else if status == "expired" {
badgeColor = "red"
} else {
badgeColor = "lightgrey"
}
case "system":
record, err := h.app.FindRecordById("systems", id)
if err != nil {
return e.NotFoundError("system not found", err)
}
status = record.GetString("status")
if label == "" {
label = record.GetString("name")
}
message = status
if status == "up" {
badgeColor = "brightgreen"
} else {
badgeColor = "red"
}
case "response":
record, err := h.app.FindRecordById("monitors", id)
if err != nil {
return e.NotFoundError("monitor not found", err)
}
if label == "" {
label = "response"
}
responseTime := record.GetInt("last_response_time")
message = fmt.Sprintf("%dms", responseTime)
if responseTime < 200 {
badgeColor = "brightgreen"
} else if responseTime < 500 {
badgeColor = "yellow"
} else {
badgeColor = "red"
}
default:
return e.BadRequestError("invalid badge type", nil)
}
// Override color if provided
if color != "" {
badgeColor = color
}
// Generate SVG badge
svg := generateSVGBadge(label, message, badgeColor, style)
e.Response.Header().Set("Content-Type", "image/svg+xml")
e.Response.Header().Set("Cache-Control", "no-cache")
return e.String(http.StatusOK, svg)
}
// generateSVGBadge creates an SVG badge
func generateSVGBadge(label, message, color, style string) string {
labelWidth := len(label) * 6 + 10
messageWidth := len(message) * 6 + 10
totalWidth := labelWidth + messageWidth
// Colors
labelColor := "#555"
if style == "flat-square" || style == "for-the-badge" {
labelColor = "#555"
}
// SVG template
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="%d" height="20" role="img" aria-label="%s: %s">
<title>%s: %s</title>
<linearGradient id="s" x2="0" y2="100%%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="%d" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="%d" height="20" fill="%s"/>
<rect x="%d" width="%d" height="20" fill="#%s"/>
<rect width="%d" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text x="%d" y="14" fill="#010101" fill-opacity=".3">%s</text>
<text x="%d" y="13">%s</text>
<text x="%d" y="14" fill="#010101" fill-opacity=".3">%s</text>
<text x="%d" y="13">%s</text>
</g>
</svg>`,
totalWidth, label, message,
label, message,
totalWidth,
labelWidth, labelColor,
labelWidth, messageWidth, color,
totalWidth,
labelWidth/2, label,
labelWidth/2, label,
labelWidth+messageWidth/2, message,
labelWidth+messageWidth/2, message,
)
return svg
}
// getOverallStatusPageStatus calculates overall status for a status page
func (h *APIHandler) getOverallStatusPageStatus(statusPageID string) (string, float64) {
// Get all monitors linked to this status page
links, err := h.app.FindAllRecords("status_page_monitors",
dbx.NewExp("status_page = {:statusPage}", dbx.Params{"statusPage": statusPageID}),
)
if err != nil {
return "unknown", 0
}
upCount := 0
downCount := 0
totalCount := len(links)
for _, link := range links {
monitorID := link.GetString("monitor")
monitor, err := h.app.FindRecordById("monitors", monitorID)
if err != nil {
continue
}
status := monitor.GetString("status")
if status == "up" {
upCount++
} else if status == "down" {
downCount++
}
}
if totalCount == 0 {
return "unknown", 0
}
uptime := float64(upCount) / float64(totalCount) * 100
if downCount > 0 {
return "down", uptime
} else if upCount == totalCount {
return "up", uptime
}
return "degraded", uptime
}
// GetEmbedCode generates embed code for a badge
func GetEmbedCode(badgeURL, format string) string {
switch format {
case "html":
return fmt.Sprintf(`<img src="%s" alt="status badge">`, badgeURL)
case "markdown":
return fmt.Sprintf(`![Status](%s)`, badgeURL)
case "rst":
return fmt.Sprintf(`.. image:: %s
:alt: status badge`, badgeURL)
case "asciidoc":
return fmt.Sprintf(`image:%s[Status]`, badgeURL)
default:
return fmt.Sprintf(`<img src="%s" alt="status badge">`, badgeURL)
}
}
// FormatDuration formats a duration for display
func FormatDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
} else if d < time.Hour {
return fmt.Sprintf("%dm", int(d.Minutes()))
} else if d < 24*time.Hour {
return fmt.Sprintf("%dh", int(d.Hours()))
} else {
days := int(d.Hours()) / 24
return fmt.Sprintf("%dd", days)
}
}
// ParseDuration parses a duration string (e.g., "24h", "7d")
func ParseDuration(s string) (time.Duration, error) {
if s == "" {
return 0, nil
}
// Try to parse as number of hours
if hours, err := strconv.Atoi(s); err == nil {
return time.Duration(hours) * time.Hour, nil
}
// Parse with suffix
if strings.HasSuffix(s, "d") {
days, err := strconv.Atoi(s[:len(s)-1])
if err != nil {
return 0, err
}
return time.Duration(days) * 24 * time.Hour, nil
}
if strings.HasSuffix(s, "h") {
hours, err := strconv.Atoi(s[:len(s)-1])
if err != nil {
return 0, err
}
return time.Duration(hours) * time.Hour, nil
}
if strings.HasSuffix(s, "m") {
minutes, err := strconv.Atoi(s[:len(s)-1])
if err != nil {
return 0, err
}
return time.Duration(minutes) * time.Minute, nil
}
return time.ParseDuration(s)
}