mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-03 21:02:56 +00:00
8011d487f1
- Add status pages, incidents, badges, maintenance, bulk ops, and metrics - Add Docker packaging, env example, and frontend routes - Refresh GitHub workflows and project metadata
443 lines
12 KiB
Go
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(``, 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(``, 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)
|
|
}
|