package monitoring import ( "context" "database/sql" "encoding/json" "fmt" "time" "github.com/atlasos/calypso/internal/common/database" "github.com/atlasos/calypso/internal/common/logger" "github.com/google/uuid" ) // AlertSeverity represents the severity level of an alert type AlertSeverity string const ( AlertSeverityInfo AlertSeverity = "info" AlertSeverityWarning AlertSeverity = "warning" AlertSeverityCritical AlertSeverity = "critical" ) // AlertSource represents where the alert originated type AlertSource string const ( AlertSourceSystem AlertSource = "system" AlertSourceStorage AlertSource = "storage" AlertSourceSCST AlertSource = "scst" AlertSourceTape AlertSource = "tape" AlertSourceVTL AlertSource = "vtl" AlertSourceTask AlertSource = "task" AlertSourceAPI AlertSource = "api" ) // Alert represents a system alert type Alert struct { ID string `json:"id"` Severity AlertSeverity `json:"severity"` Source AlertSource `json:"source"` Title string `json:"title"` Message string `json:"message"` ResourceType string `json:"resource_type,omitempty"` ResourceID string `json:"resource_id,omitempty"` IsAcknowledged bool `json:"is_acknowledged"` AcknowledgedBy string `json:"acknowledged_by,omitempty"` AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"` ResolvedAt *time.Time `json:"resolved_at,omitempty"` CreatedAt time.Time `json:"created_at"` Metadata map[string]interface{} `json:"metadata,omitempty"` } // AlertService manages alerts type AlertService struct { db *database.DB logger *logger.Logger eventHub *EventHub } // NewAlertService creates a new alert service func NewAlertService(db *database.DB, log *logger.Logger) *AlertService { return &AlertService{ db: db, logger: log, } } // SetEventHub sets the event hub for broadcasting alerts func (s *AlertService) SetEventHub(eventHub *EventHub) { s.eventHub = eventHub } // CreateAlert creates a new alert func (s *AlertService) CreateAlert(ctx context.Context, alert *Alert) error { alert.ID = uuid.New().String() alert.CreatedAt = time.Now() var metadataJSON *string if alert.Metadata != nil { bytes, err := json.Marshal(alert.Metadata) if err != nil { return fmt.Errorf("failed to marshal metadata: %w", err) } jsonStr := string(bytes) metadataJSON = &jsonStr } query := ` INSERT INTO alerts (id, severity, source, title, message, resource_type, resource_id, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ` _, err := s.db.ExecContext(ctx, query, alert.ID, string(alert.Severity), string(alert.Source), alert.Title, alert.Message, alert.ResourceType, alert.ResourceID, metadataJSON, ) if err != nil { return fmt.Errorf("failed to create alert: %w", err) } s.logger.Info("Alert created", "alert_id", alert.ID, "severity", alert.Severity, "source", alert.Source, "title", alert.Title, ) // Broadcast alert via WebSocket if event hub is set if s.eventHub != nil { s.eventHub.BroadcastAlert(alert) } return nil } // ListAlerts retrieves alerts with optional filters func (s *AlertService) ListAlerts(ctx context.Context, filters *AlertFilters) ([]*Alert, error) { query := ` SELECT id, severity, source, title, message, resource_type, resource_id, is_acknowledged, acknowledged_by, acknowledged_at, resolved_at, created_at, metadata FROM alerts WHERE 1=1 ` args := []interface{}{} argIndex := 1 if filters != nil { if filters.Severity != "" { query += fmt.Sprintf(" AND severity = $%d", argIndex) args = append(args, string(filters.Severity)) argIndex++ } if filters.Source != "" { query += fmt.Sprintf(" AND source = $%d", argIndex) args = append(args, string(filters.Source)) argIndex++ } if filters.IsAcknowledged != nil { query += fmt.Sprintf(" AND is_acknowledged = $%d", argIndex) args = append(args, *filters.IsAcknowledged) argIndex++ } if filters.ResourceType != "" { query += fmt.Sprintf(" AND resource_type = $%d", argIndex) args = append(args, filters.ResourceType) argIndex++ } if filters.ResourceID != "" { query += fmt.Sprintf(" AND resource_id = $%d", argIndex) args = append(args, filters.ResourceID) argIndex++ } } query += " ORDER BY created_at DESC" if filters != nil && filters.Limit > 0 { query += fmt.Sprintf(" LIMIT $%d", argIndex) args = append(args, filters.Limit) } rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("failed to query alerts: %w", err) } defer rows.Close() var alerts []*Alert for rows.Next() { alert, err := s.scanAlert(rows) if err != nil { return nil, fmt.Errorf("failed to scan alert: %w", err) } alerts = append(alerts, alert) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("error iterating alerts: %w", err) } return alerts, nil } // GetAlert retrieves a single alert by ID func (s *AlertService) GetAlert(ctx context.Context, alertID string) (*Alert, error) { query := ` SELECT id, severity, source, title, message, resource_type, resource_id, is_acknowledged, acknowledged_by, acknowledged_at, resolved_at, created_at, metadata FROM alerts WHERE id = $1 ` row := s.db.QueryRowContext(ctx, query, alertID) alert, err := s.scanAlertRow(row) if err != nil { if err == sql.ErrNoRows { return nil, fmt.Errorf("alert not found") } return nil, fmt.Errorf("failed to get alert: %w", err) } return alert, nil } // AcknowledgeAlert marks an alert as acknowledged func (s *AlertService) AcknowledgeAlert(ctx context.Context, alertID string, userID string) error { query := ` UPDATE alerts SET is_acknowledged = true, acknowledged_by = $1, acknowledged_at = NOW() WHERE id = $2 AND is_acknowledged = false ` result, err := s.db.ExecContext(ctx, query, userID, alertID) if err != nil { return fmt.Errorf("failed to acknowledge alert: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get rows affected: %w", err) } if rows == 0 { return fmt.Errorf("alert not found or already acknowledged") } s.logger.Info("Alert acknowledged", "alert_id", alertID, "user_id", userID) return nil } // ResolveAlert marks an alert as resolved func (s *AlertService) ResolveAlert(ctx context.Context, alertID string) error { query := ` UPDATE alerts SET resolved_at = NOW() WHERE id = $1 AND resolved_at IS NULL ` result, err := s.db.ExecContext(ctx, query, alertID) if err != nil { return fmt.Errorf("failed to resolve alert: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get rows affected: %w", err) } if rows == 0 { return fmt.Errorf("alert not found or already resolved") } s.logger.Info("Alert resolved", "alert_id", alertID) return nil } // DeleteAlert deletes an alert (soft delete by resolving it) func (s *AlertService) DeleteAlert(ctx context.Context, alertID string) error { // For safety, we'll just resolve it instead of hard delete return s.ResolveAlert(ctx, alertID) } // AlertFilters represents filters for listing alerts type AlertFilters struct { Severity AlertSeverity Source AlertSource IsAcknowledged *bool ResourceType string ResourceID string Limit int } // scanAlert scans a row into an Alert struct func (s *AlertService) scanAlert(rows *sql.Rows) (*Alert, error) { var alert Alert var severity, source string var resourceType, resourceID, acknowledgedBy sql.NullString var acknowledgedAt, resolvedAt sql.NullTime var metadata sql.NullString err := rows.Scan( &alert.ID, &severity, &source, &alert.Title, &alert.Message, &resourceType, &resourceID, &alert.IsAcknowledged, &acknowledgedBy, &acknowledgedAt, &resolvedAt, &alert.CreatedAt, &metadata, ) if err != nil { return nil, err } alert.Severity = AlertSeverity(severity) alert.Source = AlertSource(source) if resourceType.Valid { alert.ResourceType = resourceType.String } if resourceID.Valid { alert.ResourceID = resourceID.String } if acknowledgedBy.Valid { alert.AcknowledgedBy = acknowledgedBy.String } if acknowledgedAt.Valid { alert.AcknowledgedAt = &acknowledgedAt.Time } if resolvedAt.Valid { alert.ResolvedAt = &resolvedAt.Time } if metadata.Valid && metadata.String != "" { json.Unmarshal([]byte(metadata.String), &alert.Metadata) } return &alert, nil } // scanAlertRow scans a single row into an Alert struct func (s *AlertService) scanAlertRow(row *sql.Row) (*Alert, error) { var alert Alert var severity, source string var resourceType, resourceID, acknowledgedBy sql.NullString var acknowledgedAt, resolvedAt sql.NullTime var metadata sql.NullString err := row.Scan( &alert.ID, &severity, &source, &alert.Title, &alert.Message, &resourceType, &resourceID, &alert.IsAcknowledged, &acknowledgedBy, &acknowledgedAt, &resolvedAt, &alert.CreatedAt, &metadata, ) if err != nil { return nil, err } alert.Severity = AlertSeverity(severity) alert.Source = AlertSource(source) if resourceType.Valid { alert.ResourceType = resourceType.String } if resourceID.Valid { alert.ResourceID = resourceID.String } if acknowledgedBy.Valid { alert.AcknowledgedBy = acknowledgedBy.String } if acknowledgedAt.Valid { alert.AcknowledgedAt = &acknowledgedAt.Time } if resolvedAt.Valid { alert.ResolvedAt = &resolvedAt.Time } if metadata.Valid && metadata.String != "" { json.Unmarshal([]byte(metadata.String), &alert.Metadata) } return &alert, nil }