package metastore import ( "encoding/json" "errors" "fmt" "sort" "strings" "time" "github.com/dgraph-io/badger/v4" "warpbox/lib/helpers" ) const ( AlertSeverityLow = "low" AlertSeverityMedium = "medium" AlertSeverityHigh = "high" AlertStatusOpen = "open" AlertStatusAcknowledged = "acknowledged" AlertStatusClosed = "closed" ) func (store *Store) CreateAlert(input AlertInput) (Alert, error) { alert, err := normalizeAlertInput(input) if err != nil { return Alert{}, err } id, err := helpers.RandomHexID(16) if err != nil { return Alert{}, err } now := time.Now().UTC() alert.ID = id alert.Status = AlertStatusOpen alert.CreatedAt = now alert.UpdatedAt = now err = store.db.Update(func(txn *badger.Txn) error { return putJSON(txn, alertKey(alert.ID), alert) }) return alert, err } func (store *Store) ListAlerts(filters AlertFilters) ([]Alert, error) { alerts := []Alert{} err := store.db.View(func(txn *badger.Txn) error { opts := badger.DefaultIteratorOptions opts.Prefix = []byte("alert/") it := txn.NewIterator(opts) defer it.Close() for it.Rewind(); it.Valid(); it.Next() { var alert Alert if err := it.Item().Value(func(data []byte) error { return json.Unmarshal(data, &alert) }); err != nil { return err } if alertMatchesFilters(alert, filters) { alerts = append(alerts, alert) } } return nil }) if err != nil { return nil, err } sortAlerts(alerts, filters.Sort) return alerts, nil } func (store *Store) GetAlert(id string) (Alert, bool, error) { id = strings.TrimSpace(id) if id == "" { return Alert{}, false, nil } var alert Alert err := store.db.View(func(txn *badger.Txn) error { return getJSON(txn, alertKey(id), &alert) }) if errors.Is(err, ErrNotFound) { return Alert{}, false, nil } return alert, err == nil, err } func (store *Store) AcknowledgeAlert(id string) error { return store.updateAlertStatus(id, AlertStatusAcknowledged) } func (store *Store) CloseAlert(id string) error { return store.updateAlertStatus(id, AlertStatusClosed) } func (store *Store) updateAlertStatus(id string, status string) error { id = strings.TrimSpace(id) if id == "" { return fmt.Errorf("%w: alert id cannot be empty", ErrInvalid) } status, err := normalizeAlertStatus(status) if err != nil { return err } now := time.Now().UTC() return store.db.Update(func(txn *badger.Txn) error { var alert Alert if err := getJSON(txn, alertKey(id), &alert); err != nil { return err } alert.Status = status alert.UpdatedAt = now switch status { case AlertStatusAcknowledged: alert.AcknowledgedAt = &now case AlertStatusClosed: alert.ClosedAt = &now } return putJSON(txn, alertKey(id), alert) }) } func normalizeAlertInput(input AlertInput) (Alert, error) { title := strings.TrimSpace(input.Title) description := strings.TrimSpace(input.Description) code := strings.TrimSpace(input.Code) trace := strings.TrimSpace(input.Trace) severity, err := normalizeAlertSeverity(input.Severity) if err != nil { return Alert{}, err } if title == "" { return Alert{}, fmt.Errorf("%w: alert title cannot be empty", ErrInvalid) } if code == "" { return Alert{}, fmt.Errorf("%w: alert code cannot be empty", ErrInvalid) } if trace == "" { return Alert{}, fmt.Errorf("%w: alert trace cannot be empty", ErrInvalid) } metadata := input.Metadata if len(metadata) == 0 { metadata = json.RawMessage(`{}`) } var object map[string]any if err := json.Unmarshal(metadata, &object); err != nil { return Alert{}, fmt.Errorf("%w: alert metadata must be a JSON object", ErrInvalid) } normalizedMetadata, err := json.Marshal(object) if err != nil { return Alert{}, err } return Alert{ Title: title, Description: description, Severity: severity, Code: code, Trace: trace, Metadata: normalizedMetadata, CreatedBy: strings.TrimSpace(input.CreatedBy), }, nil } func normalizeAlertSeverity(value string) (string, error) { switch strings.ToLower(strings.TrimSpace(value)) { case AlertSeverityLow, AlertSeverityMedium, AlertSeverityHigh: return strings.ToLower(strings.TrimSpace(value)), nil default: return "", fmt.Errorf("%w: invalid alert severity", ErrInvalid) } } func normalizeAlertStatus(value string) (string, error) { switch strings.ToLower(strings.TrimSpace(value)) { case AlertStatusOpen, AlertStatusAcknowledged, AlertStatusClosed: return strings.ToLower(strings.TrimSpace(value)), nil default: return "", fmt.Errorf("%w: invalid alert status", ErrInvalid) } } func alertMatchesFilters(alert Alert, filters AlertFilters) bool { query := strings.ToLower(strings.TrimSpace(filters.Query)) if query != "" { haystack := strings.ToLower(strings.Join([]string{alert.Title, alert.Description, alert.Code, alert.Trace}, " ")) if !strings.Contains(haystack, query) { return false } } if severity := strings.ToLower(strings.TrimSpace(filters.Severity)); severity != "" && severity != "all" && alert.Severity != severity { return false } if status := strings.ToLower(strings.TrimSpace(filters.Status)); status != "" && status != "all" && alert.Status != status { return false } if group := strings.ToLower(strings.TrimSpace(filters.Group)); group != "" && group != "all" && alertGroup(alert.Trace) != group { return false } return true } func sortAlerts(alerts []Alert, sortKey string) { switch strings.ToLower(strings.TrimSpace(sortKey)) { case "oldest": sort.Slice(alerts, func(i int, j int) bool { return alerts[i].CreatedAt.Before(alerts[j].CreatedAt) }) case "severity": sort.Slice(alerts, func(i int, j int) bool { left := alertSeverityRank(alerts[i].Severity) right := alertSeverityRank(alerts[j].Severity) if left == right { return alerts[i].CreatedAt.After(alerts[j].CreatedAt) } return left > right }) default: sort.Slice(alerts, func(i int, j int) bool { return alerts[i].CreatedAt.After(alerts[j].CreatedAt) }) } } func alertSeverityRank(severity string) int { switch severity { case AlertSeverityHigh: return 3 case AlertSeverityMedium: return 2 default: return 1 } } func alertGroup(trace string) string { trace = strings.TrimSpace(trace) if trace == "" { return "system" } before, _, found := strings.Cut(trace, ".") if !found || before == "" { return "system" } return strings.ToLower(before) } func alertKey(id string) []byte { return []byte("alert/" + strings.TrimSpace(id)) }