mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-03 21:02:56 +00:00
565 lines
16 KiB
Go
565 lines
16 KiB
Go
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
|
|
}
|