mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-03 21:02:56 +00:00
Initial commit: Beszel fork with Domain Locker integration
This commit is contained in:
@@ -0,0 +1,564 @@
|
||||
package incidents
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/incident"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// APIHandler handles incident API requests
|
||||
type APIHandler struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
// NewAPIHandler creates a new incidents API handler
|
||||
func NewAPIHandler(app core.App) *APIHandler {
|
||||
return &APIHandler{app: app}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers incident API routes
|
||||
func (h *APIHandler) RegisterRoutes(se *core.ServeEvent) {
|
||||
api := se.Router.Group("/api/beszel/incidents")
|
||||
api.Bind(apis.RequireAuth())
|
||||
|
||||
api.GET("/", h.listIncidents)
|
||||
api.POST("/", h.createIncident)
|
||||
api.GET("/stats", h.getIncidentStats)
|
||||
api.GET("/calendar", h.getCalendarEvents)
|
||||
api.GET("/{id}", h.getIncident)
|
||||
api.PATCH("/{id}", h.updateIncident)
|
||||
api.POST("/{id}/acknowledge", h.acknowledgeIncident)
|
||||
api.POST("/{id}/resolve", h.resolveIncident)
|
||||
api.POST("/{id}/close", h.closeIncident)
|
||||
api.POST("/{id}/updates", h.addIncidentUpdate)
|
||||
api.GET("/{id}/updates", h.getIncidentUpdates)
|
||||
}
|
||||
|
||||
// listIncidents lists all incidents for the authenticated user
|
||||
func (h *APIHandler) listIncidents(e *core.RequestEvent) error {
|
||||
authRecord := e.Auth
|
||||
if authRecord == nil {
|
||||
return e.UnauthorizedError("unauthorized", nil)
|
||||
}
|
||||
|
||||
// Get query params for filtering
|
||||
status := e.Request.URL.Query().Get("status")
|
||||
severity := e.Request.URL.Query().Get("severity")
|
||||
|
||||
query := dbx.NewExp("user = {:user}", dbx.Params{"user": authRecord.Id})
|
||||
|
||||
if status != "" {
|
||||
query = dbx.And(query, dbx.NewExp("status = {:status}", dbx.Params{"status": status}))
|
||||
}
|
||||
if severity != "" {
|
||||
query = dbx.And(query, dbx.NewExp("severity = {:severity}", dbx.Params{"severity": severity}))
|
||||
}
|
||||
|
||||
records, err := h.app.FindAllRecords("incidents", query)
|
||||
if err != nil {
|
||||
return e.InternalServerError("failed to fetch incidents", err)
|
||||
}
|
||||
|
||||
incidents := make([]map[string]interface{}, 0, len(records))
|
||||
for _, record := range records {
|
||||
incidents = append(incidents, h.recordToResponse(record))
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, incidents)
|
||||
}
|
||||
|
||||
// createIncident creates a new incident
|
||||
func (h *APIHandler) createIncident(e *core.RequestEvent) error {
|
||||
authRecord := e.Auth
|
||||
if authRecord == nil {
|
||||
return e.UnauthorizedError("unauthorized", nil)
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"`
|
||||
Severity string `json:"severity"`
|
||||
MonitorID *string `json:"monitor,omitempty"`
|
||||
DomainID *string `json:"domain,omitempty"`
|
||||
SystemID *string `json:"system,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
|
||||
return e.BadRequestError("invalid request body", err)
|
||||
}
|
||||
|
||||
if req.Title == "" || req.Type == "" {
|
||||
return e.BadRequestError("title and type are required", nil)
|
||||
}
|
||||
|
||||
collection, err := h.app.FindCollectionByNameOrId("incidents")
|
||||
if err != nil {
|
||||
return e.InternalServerError("failed to find collection", err)
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
record.Set("title", req.Title)
|
||||
record.Set("description", req.Description)
|
||||
record.Set("type", req.Type)
|
||||
record.Set("severity", req.Severity)
|
||||
record.Set("status", incident.StatusOpen)
|
||||
record.Set("started_at", time.Now())
|
||||
if req.MonitorID != nil {
|
||||
record.Set("monitor", *req.MonitorID)
|
||||
}
|
||||
if req.DomainID != nil {
|
||||
record.Set("domain", *req.DomainID)
|
||||
}
|
||||
if req.SystemID != nil {
|
||||
record.Set("system", *req.SystemID)
|
||||
}
|
||||
record.Set("user", authRecord.Id)
|
||||
|
||||
if err := h.app.Save(record); err != nil {
|
||||
return e.InternalServerError("failed to create incident", err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusCreated, h.recordToResponse(record))
|
||||
}
|
||||
|
||||
// getIncident gets a single incident
|
||||
func (h *APIHandler) getIncident(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("incidents", id)
|
||||
if err != nil {
|
||||
return e.NotFoundError("incident not found", err)
|
||||
}
|
||||
|
||||
if record.GetString("user") != authRecord.Id {
|
||||
return e.ForbiddenError("not authorized", nil)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, h.recordToResponse(record))
|
||||
}
|
||||
|
||||
// updateIncident updates an incident
|
||||
func (h *APIHandler) updateIncident(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("incidents", id)
|
||||
if err != nil {
|
||||
return e.NotFoundError("incident not found", err)
|
||||
}
|
||||
|
||||
if record.GetString("user") != authRecord.Id {
|
||||
return e.ForbiddenError("not authorized", nil)
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
AssignedTo *string `json:"assigned_to,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
|
||||
return e.BadRequestError("invalid request body", err)
|
||||
}
|
||||
|
||||
if req.Title != nil {
|
||||
record.Set("title", *req.Title)
|
||||
}
|
||||
if req.Description != nil {
|
||||
record.Set("description", *req.Description)
|
||||
}
|
||||
if req.AssignedTo != nil {
|
||||
record.Set("assigned_to", *req.AssignedTo)
|
||||
}
|
||||
|
||||
if err := h.app.Save(record); err != nil {
|
||||
return e.InternalServerError("failed to update incident", err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, h.recordToResponse(record))
|
||||
}
|
||||
|
||||
// acknowledgeIncident acknowledges an incident
|
||||
func (h *APIHandler) acknowledgeIncident(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("incidents", id)
|
||||
if err != nil {
|
||||
return e.NotFoundError("incident not found", err)
|
||||
}
|
||||
|
||||
if record.GetString("user") != authRecord.Id {
|
||||
return e.ForbiddenError("not authorized", nil)
|
||||
}
|
||||
|
||||
oldStatus := record.GetString("status")
|
||||
now := time.Now()
|
||||
record.Set("status", incident.StatusAcknowledged)
|
||||
record.Set("acknowledged_at", now)
|
||||
|
||||
if err := h.app.Save(record); err != nil {
|
||||
return e.InternalServerError("failed to acknowledge incident", err)
|
||||
}
|
||||
|
||||
// Add update record
|
||||
h.addUpdate(id, "Incident acknowledged", "status_change", &oldStatus, strPtr(incident.StatusAcknowledged), authRecord.Id)
|
||||
|
||||
return e.JSON(http.StatusOK, h.recordToResponse(record))
|
||||
}
|
||||
|
||||
// resolveIncident resolves an incident
|
||||
func (h *APIHandler) resolveIncident(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("incidents", id)
|
||||
if err != nil {
|
||||
return e.NotFoundError("incident not found", err)
|
||||
}
|
||||
|
||||
if record.GetString("user") != authRecord.Id {
|
||||
return e.ForbiddenError("not authorized", nil)
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
RootCause string `json:"root_cause,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
|
||||
return e.BadRequestError("invalid request body", err)
|
||||
}
|
||||
|
||||
oldStatus := record.GetString("status")
|
||||
now := time.Now()
|
||||
record.Set("status", incident.StatusResolved)
|
||||
record.Set("resolved_at", now)
|
||||
if req.Resolution != "" {
|
||||
record.Set("resolution", req.Resolution)
|
||||
}
|
||||
if req.RootCause != "" {
|
||||
record.Set("root_cause", req.RootCause)
|
||||
}
|
||||
|
||||
if err := h.app.Save(record); err != nil {
|
||||
return e.InternalServerError("failed to resolve incident", err)
|
||||
}
|
||||
|
||||
// Add update record
|
||||
h.addUpdate(id, "Incident resolved: "+req.Resolution, "status_change", &oldStatus, strPtr(incident.StatusResolved), authRecord.Id)
|
||||
|
||||
return e.JSON(http.StatusOK, h.recordToResponse(record))
|
||||
}
|
||||
|
||||
// closeIncident closes an incident
|
||||
func (h *APIHandler) closeIncident(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("incidents", id)
|
||||
if err != nil {
|
||||
return e.NotFoundError("incident not found", err)
|
||||
}
|
||||
|
||||
if record.GetString("user") != authRecord.Id {
|
||||
return e.ForbiddenError("not authorized", nil)
|
||||
}
|
||||
|
||||
oldStatus := record.GetString("status")
|
||||
now := time.Now()
|
||||
record.Set("status", incident.StatusClosed)
|
||||
record.Set("closed_at", now)
|
||||
|
||||
if err := h.app.Save(record); err != nil {
|
||||
return e.InternalServerError("failed to close incident", err)
|
||||
}
|
||||
|
||||
// Add update record
|
||||
h.addUpdate(id, "Incident closed", "status_change", &oldStatus, strPtr(incident.StatusClosed), authRecord.Id)
|
||||
|
||||
return e.JSON(http.StatusOK, h.recordToResponse(record))
|
||||
}
|
||||
|
||||
// addIncidentUpdate adds an update to an incident
|
||||
func (h *APIHandler) addIncidentUpdate(e *core.RequestEvent) error {
|
||||
authRecord := e.Auth
|
||||
if authRecord == nil {
|
||||
return e.UnauthorizedError("unauthorized", nil)
|
||||
}
|
||||
|
||||
id := e.Request.PathValue("id")
|
||||
|
||||
// Verify incident exists and belongs to user
|
||||
incident, err := h.app.FindRecordById("incidents", id)
|
||||
if err != nil {
|
||||
return e.NotFoundError("incident not found", err)
|
||||
}
|
||||
if incident.GetString("user") != authRecord.Id {
|
||||
return e.ForbiddenError("not authorized", nil)
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
|
||||
return e.BadRequestError("invalid request body", err)
|
||||
}
|
||||
|
||||
if req.Message == "" {
|
||||
return e.BadRequestError("message is required", nil)
|
||||
}
|
||||
|
||||
h.addUpdate(id, req.Message, "note", nil, nil, authRecord.Id)
|
||||
|
||||
return e.JSON(http.StatusCreated, map[string]string{"status": "added"})
|
||||
}
|
||||
|
||||
// getIncidentUpdates gets all updates for an incident
|
||||
func (h *APIHandler) getIncidentUpdates(e *core.RequestEvent) error {
|
||||
authRecord := e.Auth
|
||||
if authRecord == nil {
|
||||
return e.UnauthorizedError("unauthorized", nil)
|
||||
}
|
||||
|
||||
id := e.Request.PathValue("id")
|
||||
|
||||
// Verify incident exists and belongs to user
|
||||
incident, err := h.app.FindRecordById("incidents", id)
|
||||
if err != nil {
|
||||
return e.NotFoundError("incident not found", err)
|
||||
}
|
||||
if incident.GetString("user") != authRecord.Id {
|
||||
return e.ForbiddenError("not authorized", nil)
|
||||
}
|
||||
|
||||
records, err := h.app.FindAllRecords("incident_updates",
|
||||
dbx.NewExp("incident = {:incident}", dbx.Params{"incident": id}),
|
||||
)
|
||||
if err != nil {
|
||||
return e.InternalServerError("failed to fetch updates", err)
|
||||
}
|
||||
|
||||
updates := make([]map[string]interface{}, 0, len(records))
|
||||
for _, record := range records {
|
||||
updates = append(updates, map[string]interface{}{
|
||||
"id": record.Id,
|
||||
"message": record.GetString("message"),
|
||||
"update_type": record.GetString("update_type"),
|
||||
"old_status": record.GetString("old_status"),
|
||||
"new_status": record.GetString("new_status"),
|
||||
"created_by": record.GetString("created_by"),
|
||||
"created_at": record.GetDateTime("created_at").String(),
|
||||
})
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, updates)
|
||||
}
|
||||
|
||||
// getIncidentStats returns incident statistics
|
||||
func (h *APIHandler) getIncidentStats(e *core.RequestEvent) error {
|
||||
authRecord := e.Auth
|
||||
if authRecord == nil {
|
||||
return e.UnauthorizedError("unauthorized", nil)
|
||||
}
|
||||
|
||||
// Count by status
|
||||
total, _ := h.app.CountRecords("incidents", dbx.NewExp("user = {:user}", dbx.Params{"user": authRecord.Id}))
|
||||
open, _ := h.app.CountRecords("incidents", dbx.HashExp{"user": authRecord.Id, "status": incident.StatusOpen})
|
||||
acknowledged, _ := h.app.CountRecords("incidents", dbx.HashExp{"user": authRecord.Id, "status": incident.StatusAcknowledged})
|
||||
resolved, _ := h.app.CountRecords("incidents", dbx.HashExp{"user": authRecord.Id, "status": incident.StatusResolved})
|
||||
|
||||
// Calculate MTTR
|
||||
resolvedRecords, _ := h.app.FindAllRecords("incidents",
|
||||
dbx.And(
|
||||
dbx.NewExp("user = {:user}", dbx.Params{"user": authRecord.Id}),
|
||||
dbx.NewExp("status = {:status}", dbx.Params{"status": incident.StatusResolved}),
|
||||
),
|
||||
)
|
||||
|
||||
var totalResolutionTime time.Duration
|
||||
for _, r := range resolvedRecords {
|
||||
started := r.GetDateTime("started_at").Time()
|
||||
resolved := r.GetDateTime("resolved_at").Time()
|
||||
if !started.IsZero() && !resolved.IsZero() {
|
||||
totalResolutionTime += resolved.Sub(started)
|
||||
}
|
||||
}
|
||||
|
||||
mttr := 0.0
|
||||
if len(resolvedRecords) > 0 {
|
||||
mttr = totalResolutionTime.Hours() / float64(len(resolvedRecords))
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]interface{}{
|
||||
"total_incidents": total,
|
||||
"open_incidents": open,
|
||||
"acknowledged_incidents": acknowledged,
|
||||
"resolved_incidents": resolved,
|
||||
"mttr_hours": mttr,
|
||||
})
|
||||
}
|
||||
|
||||
// getCalendarEvents returns events for calendar view
|
||||
func (h *APIHandler) getCalendarEvents(e *core.RequestEvent) error {
|
||||
authRecord := e.Auth
|
||||
if authRecord == nil {
|
||||
return e.UnauthorizedError("unauthorized", nil)
|
||||
}
|
||||
|
||||
events := []map[string]interface{}{}
|
||||
|
||||
// Domain expirations
|
||||
domains, _ := h.app.FindAllRecords("domains",
|
||||
dbx.NewExp("user = {:user} && expiry_date != ''", dbx.Params{"user": authRecord.Id}),
|
||||
)
|
||||
for _, d := range domains {
|
||||
expiryDate := d.GetDateTime("expiry_date").Time()
|
||||
domainName := d.GetString("domain_name")
|
||||
daysUntil := int(expiryDate.Sub(time.Now()).Hours() / 24)
|
||||
|
||||
var color string
|
||||
if daysUntil <= 7 {
|
||||
color = "#ef4444" // red
|
||||
} else if daysUntil <= 30 {
|
||||
color = "#f59e0b" // orange
|
||||
} else {
|
||||
color = "#3b82f6" // blue
|
||||
}
|
||||
|
||||
events = append(events, map[string]interface{}{
|
||||
"id": "domain-" + d.Id,
|
||||
"title": "🌐 " + domainName + " expires",
|
||||
"date": expiryDate.Format("2006-01-02"),
|
||||
"type": "domain_expiry",
|
||||
"color": color,
|
||||
})
|
||||
}
|
||||
|
||||
// SSL expirations
|
||||
for _, d := range domains {
|
||||
sslExpiry := d.GetDateTime("ssl_valid_to").Time()
|
||||
if sslExpiry.IsZero() {
|
||||
continue
|
||||
}
|
||||
domainName := d.GetString("domain_name")
|
||||
daysUntil := int(sslExpiry.Sub(time.Now()).Hours() / 24)
|
||||
|
||||
var color string
|
||||
if daysUntil <= 7 {
|
||||
color = "#ef4444"
|
||||
} else if daysUntil <= 14 {
|
||||
color = "#f59e0b"
|
||||
} else {
|
||||
color = "#8b5cf6"
|
||||
}
|
||||
|
||||
events = append(events, map[string]interface{}{
|
||||
"id": "ssl-" + d.Id,
|
||||
"title": "🔒 " + domainName + " SSL expires",
|
||||
"date": sslExpiry.Format("2006-01-02"),
|
||||
"type": "ssl_expiry",
|
||||
"color": color,
|
||||
})
|
||||
}
|
||||
|
||||
// Incidents
|
||||
incidents, _ := h.app.FindAllRecords("incidents",
|
||||
dbx.NewExp("user = {:user}", dbx.Params{"user": authRecord.Id}),
|
||||
)
|
||||
for _, i := range incidents {
|
||||
startedAt := i.GetDateTime("started_at").Time()
|
||||
title := i.GetString("title")
|
||||
severity := i.GetString("severity")
|
||||
|
||||
var color string
|
||||
switch severity {
|
||||
case incident.SeverityCritical:
|
||||
color = "#dc2626"
|
||||
case incident.SeverityHigh:
|
||||
color = "#ea580c"
|
||||
default:
|
||||
color = "#6b7280"
|
||||
}
|
||||
|
||||
events = append(events, map[string]interface{}{
|
||||
"id": "incident-" + i.Id,
|
||||
"title": "⚠️ " + title,
|
||||
"date": startedAt.Format("2006-01-02"),
|
||||
"type": "incident",
|
||||
"color": color,
|
||||
})
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, events)
|
||||
}
|
||||
|
||||
// addUpdate adds an update record
|
||||
func (h *APIHandler) addUpdate(incidentID, message, updateType string, oldStatus, newStatus *string, createdBy string) {
|
||||
collection, err := h.app.FindCollectionByNameOrId("incident_updates")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
record.Set("incident", incidentID)
|
||||
record.Set("message", message)
|
||||
record.Set("update_type", updateType)
|
||||
if oldStatus != nil {
|
||||
record.Set("old_status", *oldStatus)
|
||||
}
|
||||
if newStatus != nil {
|
||||
record.Set("new_status", *newStatus)
|
||||
}
|
||||
record.Set("created_by", createdBy)
|
||||
record.Set("created_at", time.Now())
|
||||
|
||||
h.app.Save(record)
|
||||
}
|
||||
|
||||
// recordToResponse converts a record to API response
|
||||
func (h *APIHandler) recordToResponse(record *core.Record) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"id": record.Id,
|
||||
"title": record.GetString("title"),
|
||||
"description": record.GetString("description"),
|
||||
"type": record.GetString("type"),
|
||||
"severity": record.GetString("severity"),
|
||||
"status": record.GetString("status"),
|
||||
"monitor": record.GetString("monitor"),
|
||||
"domain": record.GetString("domain"),
|
||||
"system": record.GetString("system"),
|
||||
"assigned_to": record.GetString("assigned_to"),
|
||||
"started_at": record.GetDateTime("started_at").String(),
|
||||
"acknowledged_at": record.GetDateTime("acknowledged_at").String(),
|
||||
"resolved_at": record.GetDateTime("resolved_at").String(),
|
||||
"closed_at": record.GetDateTime("closed_at").String(),
|
||||
"resolution": record.GetString("resolution"),
|
||||
"root_cause": record.GetString("root_cause"),
|
||||
"created": record.GetDateTime("created").String(),
|
||||
"updated": record.GetDateTime("updated").String(),
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
Reference in New Issue
Block a user