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