feat(models): add alert and box models
Adds comprehensive data structures for Alert and Box functionality across models.
This commit is contained in:
386
lib/server/account_alerts.go
Normal file
386
lib/server/account_alerts.go
Normal file
@@ -0,0 +1,386 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
type AlertPageView struct {
|
||||
PageTitle string
|
||||
WindowTitle string
|
||||
WindowIcon string
|
||||
PageScripts []string
|
||||
AccountNav AccountNavView
|
||||
CSRFToken string
|
||||
Filters AlertFiltersView
|
||||
Stats AlertStatsView
|
||||
Alerts []AlertRowView
|
||||
SelectedAlert *AlertRowView
|
||||
Groups []string
|
||||
CanManageAlerts bool
|
||||
}
|
||||
|
||||
type AlertFiltersView struct {
|
||||
Query string
|
||||
Severity string
|
||||
Status string
|
||||
Group string
|
||||
Sort string
|
||||
}
|
||||
|
||||
type AlertStatsView struct {
|
||||
Open int
|
||||
Acknowledged int
|
||||
Closed int
|
||||
High int
|
||||
Medium int
|
||||
Low int
|
||||
}
|
||||
|
||||
type AlertRowView struct {
|
||||
ID string
|
||||
Title string
|
||||
Description string
|
||||
Severity string
|
||||
Status string
|
||||
Code string
|
||||
Trace string
|
||||
Group string
|
||||
MetadataPretty string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlerts(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
page, err := app.ListAlerts(ctx, actor, accountAlertFiltersFromRequest(ctx))
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "account_alerts.html", page)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlertAcknowledge(ctx *gin.Context) {
|
||||
app.handleAccountAlertAction(ctx, func(actor metastore.User, id string) error {
|
||||
return app.AcknowledgeAlert(ctx, actor, id)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlertClose(ctx *gin.Context) {
|
||||
app.handleAccountAlertAction(ctx, func(actor metastore.User, id string) error {
|
||||
return app.CloseAlert(ctx, actor, id)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlertBulkAcknowledge(ctx *gin.Context) {
|
||||
app.handleAccountAlertBulkAction(ctx, func(actor metastore.User, ids []string) error {
|
||||
return app.BulkAcknowledgeAlerts(ctx, actor, ids)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlertBulkClose(ctx *gin.Context) {
|
||||
app.handleAccountAlertBulkAction(ctx, func(actor metastore.User, ids []string) error {
|
||||
return app.BulkCloseAlerts(ctx, actor, ids)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlertsExport(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
page, err := app.ListAlerts(ctx, actor, accountAlertFiltersFromRequest(ctx))
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
ctx.Header("Content-Disposition", `attachment; filename="warpbox-alerts.json"`)
|
||||
ctx.JSON(http.StatusOK, gin.H{"alerts": page.Alerts, "filters": page.Filters, "stats": page.Stats})
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlertAction(ctx *gin.Context, action func(metastore.User, string) error) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := action(actor, ctx.Param("id")); err != nil {
|
||||
ctx.String(http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/alerts")
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlertBulkAction(ctx *gin.Context, action func(metastore.User, []string) error) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := action(actor, ctx.PostFormArray("alert_ids")); err != nil {
|
||||
ctx.String(http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/alerts")
|
||||
}
|
||||
|
||||
func (app *App) CreateAlert(ctx *gin.Context, actor metastore.User, input metastore.AlertInput) (metastore.Alert, error) {
|
||||
if err := app.requireAlertManage(ctx); err != nil {
|
||||
return metastore.Alert{}, err
|
||||
}
|
||||
if input.CreatedBy == "" {
|
||||
input.CreatedBy = actor.Username
|
||||
}
|
||||
return app.store.CreateAlert(input)
|
||||
}
|
||||
|
||||
func (app *App) ListAlerts(ctx *gin.Context, actor metastore.User, filters metastore.AlertFilters) (AlertPageView, error) {
|
||||
if err := app.requireAlertView(ctx); err != nil {
|
||||
return AlertPageView{}, err
|
||||
}
|
||||
alerts, err := app.store.ListAlerts(filters)
|
||||
if err != nil {
|
||||
return AlertPageView{}, err
|
||||
}
|
||||
rows := make([]AlertRowView, 0, len(alerts))
|
||||
stats := AlertStatsView{}
|
||||
groupSet := map[string]bool{}
|
||||
for _, alert := range alerts {
|
||||
row := alertRowView(alert)
|
||||
rows = append(rows, row)
|
||||
groupSet[row.Group] = true
|
||||
switch alert.Status {
|
||||
case metastore.AlertStatusAcknowledged:
|
||||
stats.Acknowledged++
|
||||
case metastore.AlertStatusClosed:
|
||||
stats.Closed++
|
||||
default:
|
||||
stats.Open++
|
||||
}
|
||||
switch alert.Severity {
|
||||
case metastore.AlertSeverityHigh:
|
||||
stats.High++
|
||||
case metastore.AlertSeverityMedium:
|
||||
stats.Medium++
|
||||
default:
|
||||
stats.Low++
|
||||
}
|
||||
}
|
||||
groups := make([]string, 0, len(groupSet))
|
||||
for group := range groupSet {
|
||||
groups = append(groups, group)
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
groups = []string{"system"}
|
||||
}
|
||||
|
||||
nav := app.accountNavView(ctx, "alerts")
|
||||
nav.AlertCount, nav.AlertSeverity = app.openAlertSummary()
|
||||
|
||||
var selected *AlertRowView
|
||||
if len(rows) > 0 {
|
||||
selected = &rows[0]
|
||||
}
|
||||
return AlertPageView{
|
||||
PageTitle: "WarpBox Alerts",
|
||||
WindowTitle: "WarpBox Alerts",
|
||||
WindowIcon: "!",
|
||||
PageScripts: []string{"/static/js/account-alerts.js"},
|
||||
AccountNav: nav,
|
||||
CSRFToken: app.currentCSRFToken(ctx),
|
||||
Filters: AlertFiltersView{Query: filters.Query, Severity: filters.Severity, Status: filters.Status, Group: filters.Group, Sort: filters.Sort},
|
||||
Stats: stats,
|
||||
Alerts: rows,
|
||||
SelectedAlert: selected,
|
||||
Groups: groups,
|
||||
CanManageAlerts: currentAccountPermissions(ctx).AdminAccess,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (app *App) AcknowledgeAlert(ctx *gin.Context, actor metastore.User, id string) error {
|
||||
if err := app.requireAlertManage(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return app.store.AcknowledgeAlert(id)
|
||||
}
|
||||
|
||||
func (app *App) CloseAlert(ctx *gin.Context, actor metastore.User, id string) error {
|
||||
if err := app.requireAlertManage(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return app.store.CloseAlert(id)
|
||||
}
|
||||
|
||||
func (app *App) BulkAcknowledgeAlerts(ctx *gin.Context, actor metastore.User, ids []string) error {
|
||||
if err := app.requireAlertManage(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range uniqueNonEmpty(ids) {
|
||||
if err := app.store.AcknowledgeAlert(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) BulkCloseAlerts(ctx *gin.Context, actor metastore.User, ids []string) error {
|
||||
if err := app.requireAlertManage(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range uniqueNonEmpty(ids) {
|
||||
if err := app.store.CloseAlert(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) EmitSystemAlert(code string, severity string, title string, description string, trace string, metadata any) error {
|
||||
raw, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
log.Printf("alert metadata marshal failed: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err = app.store.CreateAlert(metastore.AlertInput{
|
||||
Title: title,
|
||||
Description: description,
|
||||
Severity: severity,
|
||||
Code: code,
|
||||
Trace: trace,
|
||||
Metadata: raw,
|
||||
CreatedBy: "system",
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("alert persistence failed: %v", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (app *App) requireAlertView(ctx *gin.Context) error {
|
||||
if !currentAccountPermissions(ctx).AdminAccess {
|
||||
return fmt.Errorf("permission denied")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) requireAlertManage(ctx *gin.Context) error {
|
||||
if !currentAccountPermissions(ctx).AdminAccess {
|
||||
return fmt.Errorf("permission denied")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func accountAlertFiltersFromRequest(ctx *gin.Context) metastore.AlertFilters {
|
||||
return metastore.AlertFilters{
|
||||
Query: strings.TrimSpace(ctx.Query("q")),
|
||||
Severity: emptyAsAll(ctx.Query("severity")),
|
||||
Status: emptyAsAll(ctx.Query("status")),
|
||||
Group: emptyAsAll(ctx.Query("group")),
|
||||
Sort: emptyAsNewest(ctx.Query("sort")),
|
||||
}
|
||||
}
|
||||
|
||||
func emptyAsAll(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "all"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func emptyAsNewest(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "newest"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func alertRowView(alert metastore.Alert) AlertRowView {
|
||||
return AlertRowView{
|
||||
ID: alert.ID,
|
||||
Title: alert.Title,
|
||||
Description: alert.Description,
|
||||
Severity: alert.Severity,
|
||||
Status: alert.Status,
|
||||
Code: alert.Code,
|
||||
Trace: alert.Trace,
|
||||
Group: alertGroupFromTrace(alert.Trace),
|
||||
MetadataPretty: prettyAlertMetadata(alert.Metadata),
|
||||
CreatedAt: formatAdminTime(alert.CreatedAt),
|
||||
UpdatedAt: formatAdminTime(alert.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func prettyAlertMetadata(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
var value any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return string(raw)
|
||||
}
|
||||
pretty, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return string(raw)
|
||||
}
|
||||
return string(pretty)
|
||||
}
|
||||
|
||||
func alertGroupFromTrace(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 (app *App) openAlertSummary() (int, string) {
|
||||
alerts, err := app.store.ListAlerts(metastore.AlertFilters{Status: metastore.AlertStatusOpen})
|
||||
if err != nil {
|
||||
return 0, "ok"
|
||||
}
|
||||
severity := "ok"
|
||||
for _, alert := range alerts {
|
||||
if alert.Severity == metastore.AlertSeverityHigh {
|
||||
return len(alerts), "danger"
|
||||
}
|
||||
if alert.Severity == metastore.AlertSeverityMedium {
|
||||
severity = "warning"
|
||||
} else if severity == "ok" {
|
||||
severity = "info"
|
||||
}
|
||||
}
|
||||
return len(alerts), severity
|
||||
}
|
||||
|
||||
func uniqueNonEmpty(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user