mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-03 21:02:56 +00:00
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
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user