Adds comprehensive data structures for Alert and Box functionality across models.
248 lines
6.3 KiB
Go
248 lines
6.3 KiB
Go
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))
|
|
}
|