package api import ( "context" "encoding/json" "net/http" "strconv" "strings" "sync" "time" "github.com/rcourtman/pulse-go-rewrite/internal/ai/memory" "github.com/rcourtman/pulse-go-rewrite/internal/alerts" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/mock" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/notifications" "github.com/rcourtman/pulse-go-rewrite/internal/utils" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" "github.com/rs/zerolog/log" ) // AlertManager defines the interface for alert management operations. type AlertManager interface { GetConfig() alerts.AlertConfig UpdateConfig(alerts.AlertConfig) GetActiveAlerts() []alerts.Alert NotifyExistingAlert(id string) ClearAlertHistory() error UnacknowledgeAlert(id string) error AcknowledgeAlert(id, user string) error ClearAlert(id string) bool GetAlertHistory(limit int) []alerts.Alert GetAlertHistorySince(since time.Time, limit int) []alerts.Alert } // ConfigPersistence defines the interface for saving configuration. type ConfigPersistence interface { SaveAlertConfig(alerts.AlertConfig) error } // AlertMonitor defines the interface for monitoring operations used by alert handlers. type AlertMonitor interface { GetAlertManager() AlertManager GetConfigPersistence() ConfigPersistence GetIncidentStore() *memory.IncidentStore GetNotificationManager() *notifications.NotificationManager SyncAlertState() BuildFrontendState() models.StateFrontend } // AlertHandlers handles alert-related HTTP endpoints type AlertHandlers struct { stateMu sync.RWMutex mtMonitor *monitoring.MultiTenantMonitor defaultMonitor AlertMonitor wsHub *websocket.Hub } // NewAlertHandlers creates new alert handlers func NewAlertHandlers(mtm *monitoring.MultiTenantMonitor, monitor AlertMonitor, wsHub *websocket.Hub) *AlertHandlers { // If mtm is provided, try to populate defaultMonitor from "default" org if not provided. if monitor == nil && mtm != nil { if m, err := mtm.GetMonitor("default"); err == nil { monitor = NewAlertMonitorWrapper(m) } } return &AlertHandlers{ mtMonitor: mtm, defaultMonitor: monitor, wsHub: wsHub, } } // SetMultiTenantMonitor updates the multi-tenant monitor reference func (h *AlertHandlers) SetMultiTenantMonitor(mtm *monitoring.MultiTenantMonitor) { var defaultMonitor AlertMonitor if mtm != nil { if m, err := mtm.GetMonitor("default"); err == nil { defaultMonitor = NewAlertMonitorWrapper(m) } } h.stateMu.Lock() defer h.stateMu.Unlock() h.mtMonitor = mtm if defaultMonitor != nil { h.defaultMonitor = defaultMonitor } } // SetMonitor updates the monitor reference for alert handlers. func (h *AlertHandlers) SetMonitor(m AlertMonitor) { h.stateMu.Lock() defer h.stateMu.Unlock() h.defaultMonitor = m } func (h *AlertHandlers) getMonitor(ctx context.Context) AlertMonitor { h.stateMu.RLock() mtMonitor := h.mtMonitor defaultMonitor := h.defaultMonitor h.stateMu.RUnlock() orgID := GetOrgID(ctx) if mtMonitor != nil { if m, err := mtMonitor.GetMonitor(orgID); err == nil && m != nil { return NewAlertMonitorWrapper(m) } } return defaultMonitor } func (h *AlertHandlers) broadcastStateForContext(ctx context.Context) { if h.wsHub == nil { return } orgID := GetOrgID(ctx) frontendState := h.getMonitor(ctx).BuildFrontendState() if orgID != "" { h.wsHub.BroadcastStateToTenant(orgID, frontendState) return } h.wsHub.BroadcastState(frontendState) } // validateAlertIdentifier validates an alert identifier for security. // Alert identifiers may contain user-supplied data (e.g., Docker hostnames), so we allow // printable ASCII characters while blocking control characters and path traversal. func validateAlertIdentifier(alertIdentifier string) bool { // Guard against empty strings or extremely large payloads that could impact memory usage. if len(alertIdentifier) == 0 || len(alertIdentifier) > 500 { return false } // Reject attempts to traverse directories via crafted path segments. if strings.Contains(alertIdentifier, "../") || strings.Contains(alertIdentifier, "/..") { return false } // Allow printable ASCII characters (32-126) to support user-supplied identifiers // like Docker hostnames that may contain parentheses, brackets, etc. // Reject control characters (0-31) and DEL (127) which could cause logging issues. for _, r := range alertIdentifier { if r < 32 || r > 126 { return false } } // Reject IDs that start or end with spaces (likely malformed) if alertIdentifier[0] == ' ' || alertIdentifier[len(alertIdentifier)-1] == ' ' { return false } return true } // GetAlertConfig returns the current alert configuration func (h *AlertHandlers) GetAlertConfig(w http.ResponseWriter, r *http.Request) { config := h.getMonitor(r.Context()).GetAlertManager().GetConfig() if err := utils.WriteJSONResponse(w, config); err != nil { log.Error().Err(err).Msg("Failed to write alert config response") } } // UpdateAlertConfig updates the alert configuration func (h *AlertHandlers) UpdateAlertConfig(w http.ResponseWriter, r *http.Request) { // Limit request body to 64KB to prevent memory exhaustion r.Body = http.MaxBytesReader(w, r.Body, 64*1024) var config alerts.AlertConfig if err := json.NewDecoder(r.Body).Decode(&config); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } h.getMonitor(r.Context()).GetAlertManager().UpdateConfig(config) updatedConfig := h.getMonitor(r.Context()).GetAlertManager().GetConfig() // Update notification manager with schedule settings notificationMgr := h.getMonitor(r.Context()).GetNotificationManager() notificationMgr.SetEnabled(updatedConfig.Enabled && updatedConfig.ActivationState == alerts.ActivationActive) notificationMgr.SetCooldown(updatedConfig.Schedule.Cooldown) notificationMgr.SetGroupingWindow(updatedConfig.Schedule.Grouping.Window) notificationMgr.SetGroupingOptions( updatedConfig.Schedule.Grouping.ByNode, updatedConfig.Schedule.Grouping.ByGuest, ) notificationMgr.SetNotifyOnResolve(updatedConfig.Schedule.NotifyOnResolve) // Save to persistent storage if err := h.getMonitor(r.Context()).GetConfigPersistence().SaveAlertConfig(updatedConfig); err != nil { // Log error but don't fail the request log.Error().Err(err).Msg("Failed to save alert configuration") } if err := utils.WriteJSONResponse(w, map[string]interface{}{ "success": true, "message": "Alert configuration updated successfully", }); err != nil { log.Error().Err(err).Msg("Failed to write alert config update response") } } // ActivateAlerts activates alert notifications func (h *AlertHandlers) ActivateAlerts(w http.ResponseWriter, r *http.Request) { // Get current config config := h.getMonitor(r.Context()).GetAlertManager().GetConfig() // Check if already active if config.ActivationState == alerts.ActivationActive { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "success": true, "message": "Alerts already activated", "state": string(config.ActivationState), "activationTime": config.ActivationTime, }); err != nil { log.Error().Err(err).Msg("Failed to write activate response") } return } // Activate notifications now := time.Now() config.ActivationState = alerts.ActivationActive config.ActivationTime = &now // Update config h.getMonitor(r.Context()).GetAlertManager().UpdateConfig(config) h.getMonitor(r.Context()).GetNotificationManager().SetEnabled( config.Enabled && config.ActivationState == alerts.ActivationActive, ) // Save to persistent storage if err := h.getMonitor(r.Context()).GetConfigPersistence().SaveAlertConfig(config); err != nil { log.Error().Err(err).Msg("Failed to save alert configuration after activation") http.Error(w, "Failed to save configuration", http.StatusInternalServerError) return } // Notify about existing critical alerts after activation activeAlerts := h.getMonitor(r.Context()).GetAlertManager().GetActiveAlerts() criticalCount := 0 for _, alert := range activeAlerts { if alert.Level == alerts.AlertLevelCritical && !alert.Acknowledged { // Re-dispatch critical alerts to trigger notifications h.getMonitor(r.Context()).GetAlertManager().NotifyExistingAlert(alert.ID) criticalCount++ } } if criticalCount > 0 { log.Info(). Int("criticalAlerts", criticalCount). Msg("Sent notifications for existing critical alerts after activation") } log.Info().Msg("Alert notifications activated") if err := utils.WriteJSONResponse(w, map[string]interface{}{ "success": true, "message": "Alert notifications activated", "state": string(config.ActivationState), "activationTime": config.ActivationTime, }); err != nil { log.Error().Err(err).Msg("Failed to write activate response") } } // GetActiveAlerts returns all active alerts func (h *AlertHandlers) GetActiveAlerts(w http.ResponseWriter, r *http.Request) { alerts := h.getMonitor(r.Context()).GetAlertManager().GetActiveAlerts() if err := utils.WriteJSONResponse(w, alerts); err != nil { log.Error().Err(err).Msg("Failed to write active alerts response") } } // GetAlertHistory returns alert history func (h *AlertHandlers) GetAlertHistory(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() limit := 100 if limitStr := query.Get("limit"); limitStr != "" { l, err := strconv.Atoi(limitStr) switch { case err != nil: log.Warn().Str("limit", limitStr).Msg("Invalid limit parameter, using default") case l < 0: http.Error(w, "limit must be non-negative", http.StatusBadRequest) return case l == 0: limit = 0 case l > 10000: log.Warn().Int("limit", l).Msg("Limit exceeds maximum, capping at 10000") limit = 10000 default: limit = l } } offset := 0 if offsetStr := query.Get("offset"); offsetStr != "" { if o, err := strconv.Atoi(offsetStr); err == nil { if o < 0 { http.Error(w, "offset must be non-negative", http.StatusBadRequest) return } offset = o } else { log.Warn().Str("offset", offsetStr).Msg("Invalid offset parameter, ignoring") } } var startTime *time.Time if startStr := query.Get("startTime"); startStr != "" { parsed, err := time.Parse(time.RFC3339, startStr) if err != nil { http.Error(w, "invalid startTime parameter", http.StatusBadRequest) return } startTime = &parsed } var endTime *time.Time if endStr := query.Get("endTime"); endStr != "" { parsed, err := time.Parse(time.RFC3339, endStr) if err != nil { http.Error(w, "invalid endTime parameter", http.StatusBadRequest) return } endTime = &parsed } if startTime != nil && endTime != nil && endTime.Before(*startTime) { http.Error(w, "endTime must be after startTime", http.StatusBadRequest) return } severity := strings.ToLower(strings.TrimSpace(query.Get("severity"))) switch severity { case "", "all": severity = "" case "warning", "critical": default: log.Warn().Str("severity", severity).Msg("Invalid severity filter, ignoring") severity = "" } resourceID := strings.TrimSpace(query.Get("resourceId")) matchesFilters := func(alertTime time.Time, alertLevel string, alertResourceID string) bool { if startTime != nil && alertTime.Before(*startTime) { return false } if endTime != nil && alertTime.After(*endTime) { return false } if severity != "" && !strings.EqualFold(alertLevel, severity) { return false } if resourceID != "" && alertResourceID != resourceID { return false } return true } trimAlerts := func(alerts []alerts.Alert) []alerts.Alert { if offset > 0 { if offset >= len(alerts) { return alerts[:0] } alerts = alerts[offset:] } if limit > 0 && len(alerts) > limit { alerts = alerts[:limit] } return alerts } trimMockAlerts := func(alerts []models.Alert) []models.Alert { if offset > 0 { if offset >= len(alerts) { return alerts[:0] } alerts = alerts[offset:] } if limit > 0 && len(alerts) > limit { alerts = alerts[:limit] } return alerts } // Check if mock mode is enabled mockEnabled := mock.IsMockEnabled() log.Debug().Bool("mockEnabled", mockEnabled).Msg("GetAlertHistory: mock mode status") fetchLimit := limit if fetchLimit > 0 && offset > 0 { fetchLimit += offset } if mockEnabled { mockHistory := mock.GetMockAlertHistory(fetchLimit) filtered := make([]models.Alert, 0, len(mockHistory)) for _, alert := range mockHistory { if matchesFilters(alert.StartTime, alert.Level, alert.ResourceID) { filtered = append(filtered, alert) } } filtered = trimMockAlerts(filtered) if err := utils.WriteJSONResponse(w, filtered); err != nil { log.Error().Err(err).Msg("Failed to write mock alert history response") } return } if h.getMonitor(r.Context()) == nil { http.Error(w, "monitor is not initialized", http.StatusServiceUnavailable) return } manager := h.getMonitor(r.Context()).GetAlertManager() var history []alerts.Alert if startTime != nil { history = manager.GetAlertHistorySince(*startTime, fetchLimit) } else { history = manager.GetAlertHistory(fetchLimit) } filtered := make([]alerts.Alert, 0, len(history)) for _, alert := range history { if matchesFilters(alert.StartTime, string(alert.Level), alert.ResourceID) { filtered = append(filtered, alert) } } filtered = trimAlerts(filtered) if err := utils.WriteJSONResponse(w, filtered); err != nil { log.Error().Err(err).Msg("Failed to write alert history response") } } // GetAlertIncidentTimeline returns the incident timeline for an alert or resource. func (h *AlertHandlers) GetAlertIncidentTimeline(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } store := h.getMonitor(r.Context()).GetIncidentStore() if store == nil { http.Error(w, "Incident store unavailable", http.StatusServiceUnavailable) return } query := r.URL.Query() alertID := strings.TrimSpace(query.Get("alertIdentifier")) resourceID := strings.TrimSpace(query.Get("resource_id")) startedAtRaw := strings.TrimSpace(query.Get("started_at")) if startedAtRaw == "" { startedAtRaw = strings.TrimSpace(query.Get("start_time")) } limit := 0 if rawLimit := strings.TrimSpace(query.Get("limit")); rawLimit != "" { if parsed, err := strconv.Atoi(rawLimit); err == nil && parsed > 0 { limit = parsed } } if alertID != "" { if !validateAlertIdentifier(alertID) { http.Error(w, "Invalid alert identifier", http.StatusBadRequest) return } var startedAt time.Time if startedAtRaw != "" { parsed, err := time.Parse(time.RFC3339, startedAtRaw) if err != nil { http.Error(w, "Invalid started_at time", http.StatusBadRequest) return } startedAt = parsed } var incident *memory.Incident if !startedAt.IsZero() { incident = store.GetTimelineByAlertAt(alertID, startedAt) } else { incident = store.GetTimelineByAlertIdentifier(alertID) } if err := utils.WriteJSONResponse(w, exportIncident(incident)); err != nil { log.Error().Err(err).Msg("Failed to write incident timeline response") } return } if resourceID != "" { if len(resourceID) > 500 { http.Error(w, "Invalid resource ID", http.StatusBadRequest) return } incidents := store.ListIncidentsByResource(resourceID, limit) if err := utils.WriteJSONResponse(w, exportIncidents(incidents)); err != nil { log.Error().Err(err).Msg("Failed to write incident list response") } return } http.Error(w, "Missing alertIdentifier or resource_id", http.StatusBadRequest) } // SaveAlertIncidentNote stores a user note in the incident timeline. func (h *AlertHandlers) SaveAlertIncidentNote(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } store := h.getMonitor(r.Context()).GetIncidentStore() if store == nil { http.Error(w, "Incident store unavailable", http.StatusServiceUnavailable) return } r.Body = http.MaxBytesReader(w, r.Body, 8*1024) var req struct { AlertIdentifier string `json:"alertIdentifier"` IncidentID string `json:"incident_id"` Note string `json:"note"` User string `json:"user,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } req.AlertIdentifier = strings.TrimSpace(req.AlertIdentifier) req.IncidentID = strings.TrimSpace(req.IncidentID) req.Note = strings.TrimSpace(req.Note) req.User = strings.TrimSpace(req.User) alertIdentifier := req.AlertIdentifier if alertIdentifier == "" && req.IncidentID == "" { http.Error(w, "alertIdentifier or incident_id is required", http.StatusBadRequest) return } if alertIdentifier != "" && !validateAlertIdentifier(alertIdentifier) { http.Error(w, "Invalid alert identifier", http.StatusBadRequest) return } if req.Note == "" { http.Error(w, "note is required", http.StatusBadRequest) return } if ok := store.RecordNote(alertIdentifier, req.IncidentID, req.Note, req.User); !ok { http.Error(w, "Failed to save note", http.StatusBadRequest) return } if err := utils.WriteJSONResponse(w, map[string]interface{}{ "success": true, }); err != nil { log.Error().Err(err).Msg("Failed to write incident note response") } } type incidentEventView struct { ID string `json:"id"` Type memory.IncidentEventType `json:"type"` Timestamp time.Time `json:"timestamp"` Summary string `json:"summary"` Details map[string]interface{} `json:"details"` } func emptyIncidentEventView() incidentEventView { return incidentEventView{}.NormalizeCollections() } func (v incidentEventView) NormalizeCollections() incidentEventView { if v.Details == nil { v.Details = map[string]interface{}{} } return v } type incidentView struct { ID string `json:"id"` AlertIdentifier string `json:"alertIdentifier"` AlertType string `json:"alertType"` Level string `json:"level"` ResourceID string `json:"resourceId"` ResourceName string `json:"resourceName"` ResourceType string `json:"resourceType,omitempty"` Node string `json:"node,omitempty"` Instance string `json:"instance,omitempty"` Message string `json:"message,omitempty"` Status memory.IncidentStatus `json:"status"` OpenedAt time.Time `json:"openedAt"` ClosedAt *time.Time `json:"closedAt,omitempty"` Acknowledged bool `json:"acknowledged"` AckUser string `json:"ackUser,omitempty"` AckTime *time.Time `json:"ackTime,omitempty"` Events []incidentEventView `json:"events"` } func emptyIncidentView() incidentView { return incidentView{}.NormalizeCollections() } func (v incidentView) NormalizeCollections() incidentView { if v.Events == nil { v.Events = []incidentEventView{} } for i := range v.Events { v.Events[i] = v.Events[i].NormalizeCollections() } return v } func exportIncident(incident *memory.Incident) *incidentView { if incident == nil { return nil } events := make([]incidentEventView, 0, len(incident.Events)) for _, event := range incident.Events { events = append(events, incidentEventView{ ID: event.ID, Type: event.Type, Timestamp: event.Timestamp, Summary: event.Summary, Details: event.Details, }.NormalizeCollections()) } alertIdentifier := strings.TrimSpace(incident.AlertIdentifier) view := emptyIncidentView() view.ID = incident.ID view.AlertIdentifier = alertIdentifier view.AlertType = incident.AlertType view.Level = incident.Level view.ResourceID = incident.ResourceID view.ResourceName = incident.ResourceName view.ResourceType = incident.ResourceType view.Node = incident.Node view.Instance = incident.Instance view.Message = incident.Message view.Status = incident.Status view.OpenedAt = incident.OpenedAt view.ClosedAt = incident.ClosedAt view.Acknowledged = incident.Acknowledged view.AckUser = incident.AckUser view.AckTime = incident.AckTime view.Events = events returnValue := view.NormalizeCollections() return &returnValue } func exportIncidents(incidents []*memory.Incident) []*incidentView { if len(incidents) == 0 { return []*incidentView{} } exported := make([]*incidentView, 0, len(incidents)) for _, incident := range incidents { if view := exportIncident(incident); view != nil { exported = append(exported, view) } } return exported } // ClearAlertHistory clears all alert history func (h *AlertHandlers) ClearAlertHistory(w http.ResponseWriter, r *http.Request) { if err := h.getMonitor(r.Context()).GetAlertManager().ClearAlertHistory(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if err := utils.WriteJSONResponse(w, map[string]string{"status": "success", "message": "Alert history cleared"}); err != nil { log.Error().Err(err).Msg("Failed to write alert history clear confirmation") } } // alertIdentifierRequest is used for endpoints that accept one canonical alert identifier in the request body. type alertIdentifierRequest struct { AlertIdentifier string `json:"alertIdentifier"` } func (r alertIdentifierRequest) identifier() string { return strings.TrimSpace(r.AlertIdentifier) } type alertIdentifiersRequest struct { AlertIdentifiers []string `json:"alertIdentifiers"` User string `json:"user,omitempty"` } func (r alertIdentifiersRequest) identifiers() []string { raw := r.AlertIdentifiers identifiers := make([]string, 0, len(raw)) for _, identifier := range raw { trimmed := strings.TrimSpace(identifier) if trimmed != "" { identifiers = append(identifiers, trimmed) } } return identifiers } // AcknowledgeAlertByBody acknowledges an alert using a canonical alert identifier from the request body. // POST /api/alerts/acknowledge with {"alertIdentifier": "alert-id"} func (h *AlertHandlers) AcknowledgeAlertByBody(w http.ResponseWriter, r *http.Request) { var req alertIdentifierRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Error().Err(err).Msg("Failed to decode acknowledge request body") http.Error(w, "Invalid request body", http.StatusBadRequest) return } alertID := req.identifier() if alertID == "" { http.Error(w, "Alert identifier is required", http.StatusBadRequest) return } if !validateAlertIdentifier(alertID) { log.Error().Str("alertIdentifier", alertID).Msg("Invalid alert identifier") http.Error(w, "Invalid alert identifier", http.StatusBadRequest) return } // Get the authenticated user from the auth middleware (set in X-Authenticated-User header) user := w.Header().Get("X-Authenticated-User") if user == "" { user = "unknown" // Fallback if auth header not set (shouldn't happen in normal flow) } if err := h.getMonitor(r.Context()).GetAlertManager().AcknowledgeAlert(alertID, user); err != nil { log.Error().Err(err).Str("alertID", alertID).Msg("Failed to acknowledge alert") http.Error(w, err.Error(), http.StatusNotFound) return } h.getMonitor(r.Context()).SyncAlertState() log.Info().Str("alertID", alertID).Str("user", user).Msg("Alert acknowledged successfully") if err := utils.WriteJSONResponse(w, map[string]bool{"success": true}); err != nil { log.Error().Err(err).Str("alertID", alertID).Msg("Failed to write acknowledge response") } if h.wsHub != nil { ctx := r.Context() go func() { h.broadcastStateForContext(ctx) }() } } // UnacknowledgeAlertByBody unacknowledges an alert using a canonical alert identifier from the request body. // POST /api/alerts/unacknowledge with {"alertIdentifier": "alert-id"} func (h *AlertHandlers) UnacknowledgeAlertByBody(w http.ResponseWriter, r *http.Request) { var req alertIdentifierRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Error().Err(err).Msg("Failed to decode unacknowledge request body") http.Error(w, "Invalid request body", http.StatusBadRequest) return } alertID := req.identifier() if alertID == "" { http.Error(w, "Alert identifier is required", http.StatusBadRequest) return } if !validateAlertIdentifier(alertID) { log.Error().Str("alertIdentifier", alertID).Msg("Invalid alert identifier") http.Error(w, "Invalid alert identifier", http.StatusBadRequest) return } if err := h.getMonitor(r.Context()).GetAlertManager().UnacknowledgeAlert(alertID); err != nil { log.Error().Err(err).Str("alertID", alertID).Msg("Failed to unacknowledge alert") http.Error(w, err.Error(), http.StatusNotFound) return } h.getMonitor(r.Context()).SyncAlertState() log.Info().Str("alertID", alertID).Msg("Alert unacknowledged successfully") if err := utils.WriteJSONResponse(w, map[string]bool{"success": true}); err != nil { log.Error().Err(err).Str("alertID", alertID).Msg("Failed to write unacknowledge response") } if h.wsHub != nil { ctx := r.Context() go func() { h.broadcastStateForContext(ctx) }() } } // ClearAlertByBody clears an alert using a canonical alert identifier from the request body. // POST /api/alerts/clear with {"alertIdentifier": "alert-id"} func (h *AlertHandlers) ClearAlertByBody(w http.ResponseWriter, r *http.Request) { var req alertIdentifierRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Error().Err(err).Msg("Failed to decode clear request body") http.Error(w, "Invalid request body", http.StatusBadRequest) return } alertID := req.identifier() if alertID == "" { http.Error(w, "Alert identifier is required", http.StatusBadRequest) return } if !validateAlertIdentifier(alertID) { log.Error().Str("alertIdentifier", alertID).Msg("Invalid alert identifier") http.Error(w, "Invalid alert identifier", http.StatusBadRequest) return } if !h.getMonitor(r.Context()).GetAlertManager().ClearAlert(alertID) { http.Error(w, "Alert not found", http.StatusNotFound) return } h.getMonitor(r.Context()).SyncAlertState() log.Info().Str("alertID", alertID).Msg("Alert cleared successfully") if err := utils.WriteJSONResponse(w, map[string]bool{"success": true}); err != nil { log.Error().Err(err).Str("alertID", alertID).Msg("Failed to write clear response") } if h.wsHub != nil { ctx := r.Context() go func() { h.broadcastStateForContext(ctx) }() } } // BulkAcknowledgeAlerts acknowledges multiple alerts at once func (h *AlertHandlers) BulkAcknowledgeAlerts(w http.ResponseWriter, r *http.Request) { // Limit request body to 32KB to prevent memory exhaustion r.Body = http.MaxBytesReader(w, r.Body, 32*1024) var request alertIdentifiersRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } alertIdentifiers := request.identifiers() if len(alertIdentifiers) == 0 { http.Error(w, "No alert identifiers provided", http.StatusBadRequest) return } // Get the authenticated user from the auth middleware - ignore request.User to prevent spoofing user := w.Header().Get("X-Authenticated-User") if user == "" { user = "unknown" // Fallback if auth header not set (shouldn't happen in normal flow) } var ( results []map[string]interface{} anySuccess bool ) for _, alertID := range alertIdentifiers { result := map[string]interface{}{ "alertIdentifier": alertID, "success": true, } if err := h.getMonitor(r.Context()).GetAlertManager().AcknowledgeAlert(alertID, user); err != nil { result["success"] = false result["error"] = err.Error() } else { anySuccess = true } results = append(results, result) } if anySuccess { h.getMonitor(r.Context()).SyncAlertState() } // Send response immediately if err := utils.WriteJSONResponse(w, map[string]interface{}{ "results": results, }); err != nil { log.Error().Err(err).Msg("Failed to write bulk acknowledge response") } // Broadcast updated state to all WebSocket clients if any alerts were acknowledged // Do this in a goroutine to avoid blocking the HTTP response if h.wsHub != nil && anySuccess { ctx := r.Context() go func() { h.broadcastStateForContext(ctx) log.Debug().Msg("Broadcasted state after bulk alert acknowledgment") }() } } // BulkClearAlerts clears multiple alerts at once func (h *AlertHandlers) BulkClearAlerts(w http.ResponseWriter, r *http.Request) { // Limit request body to 32KB to prevent memory exhaustion r.Body = http.MaxBytesReader(w, r.Body, 32*1024) var request alertIdentifiersRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } alertIdentifiers := request.identifiers() if len(alertIdentifiers) == 0 { http.Error(w, "No alert identifiers provided", http.StatusBadRequest) return } var ( results []map[string]interface{} anySuccess bool ) for _, alertID := range alertIdentifiers { result := map[string]interface{}{ "alertIdentifier": alertID, "success": true, } if h.getMonitor(r.Context()).GetAlertManager().ClearAlert(alertID) { anySuccess = true } else { result["success"] = false result["error"] = "alert not found" } results = append(results, result) } if anySuccess { h.getMonitor(r.Context()).SyncAlertState() } // Send response immediately if err := utils.WriteJSONResponse(w, map[string]interface{}{ "results": results, }); err != nil { log.Error().Err(err).Msg("Failed to write bulk clear response") } // Broadcast updated state to all WebSocket clients after response // Do this in a goroutine to avoid blocking the HTTP response if h.wsHub != nil && anySuccess { ctx := r.Context() go func() { h.broadcastStateForContext(ctx) log.Debug().Msg("Broadcasted state after bulk alert clear") }() } } // HandleAlerts routes alert requests to appropriate handlers func (h *AlertHandlers) HandleAlerts(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/alerts/") log.Debug(). Str("originalPath", r.URL.Path). Str("trimmedPath", path). Str("method", r.Method). Msg("HandleAlerts routing request") switch { case path == "config" && r.Method == http.MethodGet: if !ensureScope(w, r, config.ScopeMonitoringRead) { return } h.GetAlertConfig(w, r) case path == "config" && r.Method == http.MethodPut: if !ensureScope(w, r, config.ScopeMonitoringWrite) { return } h.UpdateAlertConfig(w, r) case path == "activate" && r.Method == http.MethodPost: if !ensureScope(w, r, config.ScopeMonitoringWrite) { return } h.ActivateAlerts(w, r) case path == "active" && r.Method == http.MethodGet: if !ensureScope(w, r, config.ScopeMonitoringRead) { return } h.GetActiveAlerts(w, r) case path == "history" && r.Method == http.MethodGet: if !ensureScope(w, r, config.ScopeMonitoringRead) { return } h.GetAlertHistory(w, r) case path == "incidents" && r.Method == http.MethodGet: if !ensureScope(w, r, config.ScopeMonitoringRead) { return } h.GetAlertIncidentTimeline(w, r) case path == "incidents/note" && r.Method == http.MethodPost: if !ensureScope(w, r, config.ScopeMonitoringWrite) { return } h.SaveAlertIncidentNote(w, r) case path == "history" && r.Method == http.MethodDelete: if !ensureScope(w, r, config.ScopeMonitoringWrite) { return } h.ClearAlertHistory(w, r) case path == "bulk/acknowledge" && r.Method == http.MethodPost: if !ensureScope(w, r, config.ScopeMonitoringWrite) { return } h.BulkAcknowledgeAlerts(w, r) case path == "bulk/clear" && r.Method == http.MethodPost: if !ensureScope(w, r, config.ScopeMonitoringWrite) { return } h.BulkClearAlerts(w, r) // Body-based endpoints (preferred - avoids URL encoding issues with reverse proxies) case path == "acknowledge" && r.Method == http.MethodPost: if !ensureScope(w, r, config.ScopeMonitoringWrite) { return } h.AcknowledgeAlertByBody(w, r) case path == "unacknowledge" && r.Method == http.MethodPost: if !ensureScope(w, r, config.ScopeMonitoringWrite) { return } h.UnacknowledgeAlertByBody(w, r) case path == "clear" && r.Method == http.MethodPost: if !ensureScope(w, r, config.ScopeMonitoringWrite) { return } h.ClearAlertByBody(w, r) default: http.Error(w, "Not found", http.StatusNotFound) } }