feat: match Bell rules and link alerts to events (#19)
This commit is contained in:
@@ -66,6 +66,10 @@ When `BELL_ENV=development` or `test`, administrators can open “合成事件
|
||||
|
||||
In `BELL_ENV=production` those routes are not registered and return `404`; hiding the menu is not the security boundary.
|
||||
|
||||
### Rules and alerts
|
||||
|
||||
Administrators configure and enable rules from “规则配置”. Each accepted Event is evaluated in the same database transaction that creates its Event/Receipt. A matching Event creates or joins an open Alert correlated by rule and location; one Event may match several rules and one Alert may collect several related Events. Every evaluation records its rule version and explanation, while every match also stores the actual rule snapshot. Unmatched Events remain visible as Events and are never labelled as notified or handled.
|
||||
|
||||
## Independent smoke check
|
||||
|
||||
Run PostgreSQL with an empty Bell-only database, set `BELL_DATABASE_URL`, start the backend and then the web client. Keep Sense and Brain stopped. Verify `/healthz`, `/readyz`, the workbench shell, and a direct request to an unregistered framework demo route returns `404`.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package model
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type Alert struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Severity string `json:"severity"`
|
||||
Summary string `json:"summary"`
|
||||
Location string `json:"location"`
|
||||
PrimaryRuleID string `json:"primary_rule_id"`
|
||||
RuleName string `json:"rule_name"`
|
||||
EventCount int `json:"event_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
type LinkedEvent struct {
|
||||
ID string `json:"id"`
|
||||
EventType string `json:"event_type"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
Location string `json:"location"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
type Match struct {
|
||||
EventID string `json:"event_id"`
|
||||
RuleID string `json:"rule_id"`
|
||||
RuleVersion int `json:"rule_version"`
|
||||
RuleSnapshot json.RawMessage `json:"rule_snapshot"`
|
||||
Explanation string `json:"explanation"`
|
||||
MatchedAt string `json:"matched_at"`
|
||||
}
|
||||
type Detail struct {
|
||||
Alert Alert `json:"alert"`
|
||||
Events []LinkedEvent `json:"events"`
|
||||
Matches []Match `json:"matches"`
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type HTTP struct {
|
||||
Service Service
|
||||
Auth auth.HTTP
|
||||
}
|
||||
|
||||
func (h HTTP) Register(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/alerts", h.Auth.Require(rbac.AlertsRead, http.HandlerFunc(h.list)))
|
||||
mux.Handle("GET /api/v1/alerts/{id}", h.Auth.Require(rbac.AlertsRead, http.HandlerFunc(h.get)))
|
||||
mux.Handle("GET /api/v1/events/{id}/alerts", h.Auth.Require(rbac.EventsRead, http.HandlerFunc(h.forEvent)))
|
||||
}
|
||||
func (h HTTP) list(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
items, err := h.Service.List(r.Context(), limit, r.URL.Query().Get("before"))
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": "读取预警失败"})
|
||||
return
|
||||
}
|
||||
next := ""
|
||||
if len(items) > 0 {
|
||||
next = items[len(items)-1].ID
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": items, "next": next})
|
||||
}
|
||||
func (h HTTP) get(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := h.Service.Get(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
status := 500
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
status = 404
|
||||
}
|
||||
writeJSON(w, status, map[string]string{"error": "预警不存在"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, item)
|
||||
}
|
||||
func (h HTTP) forEvent(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := h.Service.ForEvent(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": "读取关联预警失败"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": items})
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/alert/model"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Service struct{ DB *pgxpool.Pool }
|
||||
|
||||
const base = `SELECT a.id::text,a.status,a.severity,a.summary,a.location,a.primary_rule_id::text,r.name,(SELECT count(*) FROM bell_alert_events ae WHERE ae.alert_id=a.id),a.created_at::text,a.updated_at::text FROM bell_alerts a JOIN bell_rules r ON r.id=a.primary_rule_id`
|
||||
|
||||
func scanAlert(row interface{ Scan(...any) error }) (model.Alert, error) {
|
||||
var item model.Alert
|
||||
err := row.Scan(&item.ID, &item.Status, &item.Severity, &item.Summary, &item.Location, &item.PrimaryRuleID, &item.RuleName, &item.EventCount, &item.CreatedAt, &item.UpdatedAt)
|
||||
return item, err
|
||||
}
|
||||
func (s Service) List(ctx context.Context, limit int, before string) ([]model.Alert, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 25
|
||||
}
|
||||
rows, err := s.DB.Query(ctx, base+` WHERE ($2='' OR (a.created_at,a.id)<((SELECT created_at FROM bell_alerts WHERE id=nullif($2,'')::uuid),nullif($2,'')::uuid)) ORDER BY a.created_at DESC,a.id DESC LIMIT $1`, limit, before)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []model.Alert{}
|
||||
for rows.Next() {
|
||||
item, err := scanAlert(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
func (s Service) Get(ctx context.Context, id string) (model.Detail, error) {
|
||||
item, err := scanAlert(s.DB.QueryRow(ctx, base+` WHERE a.id=$1`, id))
|
||||
if err != nil {
|
||||
return model.Detail{}, err
|
||||
}
|
||||
detail := model.Detail{Alert: item, Events: []model.LinkedEvent{}, Matches: []model.Match{}}
|
||||
rows, err := s.DB.Query(ctx, `SELECT e.id::text,e.event_type,e.occurred_at::text,e.location,e.severity FROM bell_alert_events ae JOIN bell_events e ON e.id=ae.event_id WHERE ae.alert_id=$1 ORDER BY e.occurred_at,e.id`, id)
|
||||
if err != nil {
|
||||
return model.Detail{}, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var linked model.LinkedEvent
|
||||
if err := rows.Scan(&linked.ID, &linked.EventType, &linked.OccurredAt, &linked.Location, &linked.Severity); err != nil {
|
||||
rows.Close()
|
||||
return model.Detail{}, err
|
||||
}
|
||||
detail.Events = append(detail.Events, linked)
|
||||
}
|
||||
rows.Close()
|
||||
rows, err = s.DB.Query(ctx, `SELECT event_id::text,rule_id::text,rule_version,rule_snapshot,explanation,matched_at::text FROM bell_rule_matches WHERE alert_id=$1 ORDER BY matched_at,event_id`, id)
|
||||
if err != nil {
|
||||
return model.Detail{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var match model.Match
|
||||
if err := rows.Scan(&match.EventID, &match.RuleID, &match.RuleVersion, &match.RuleSnapshot, &match.Explanation, &match.MatchedAt); err != nil {
|
||||
return model.Detail{}, err
|
||||
}
|
||||
detail.Matches = append(detail.Matches, match)
|
||||
}
|
||||
return detail, rows.Err()
|
||||
}
|
||||
func (s Service) ForEvent(ctx context.Context, eventID string) ([]model.Alert, error) {
|
||||
rows, err := s.DB.Query(ctx, base+` JOIN bell_alert_events link ON link.alert_id=a.id WHERE link.event_id=$1 ORDER BY a.created_at,a.id`, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []model.Alert{}
|
||||
for rows.Next() {
|
||||
item, err := scanAlert(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
@@ -165,6 +165,7 @@ func (h HTTP) setCookie(w http.ResponseWriter, value string, expires time.Time)
|
||||
http.SetCookie(w, &http.Cookie{Name: CookieName, Value: value, Path: "/", HttpOnly: true, Secure: h.SecureCookie, SameSite: http.SameSiteStrictMode, Expires: expires, MaxAge: int(time.Until(expires).Seconds())})
|
||||
}
|
||||
func principal(ctx context.Context) User { user, _ := ctx.Value(principalKey{}).(User); return user }
|
||||
func Principal(ctx context.Context) User { return principal(ctx) }
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, value any) error {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
@@ -15,7 +15,11 @@ import (
|
||||
var ErrConflict = errors.New("幂等键已用于不同事件载荷")
|
||||
var ErrInvalid = errors.New("事件字段无效")
|
||||
|
||||
type Service struct{ DB *pgxpool.Pool }
|
||||
type PersistHook func(context.Context, pgx.Tx, Event) error
|
||||
type Service struct {
|
||||
DB *pgxpool.Pool
|
||||
AfterPersist PersistHook
|
||||
}
|
||||
|
||||
func normalize(command Command) ([]byte, [32]byte, error) {
|
||||
command.ProducerID = strings.TrimSpace(command.ProducerID)
|
||||
@@ -80,6 +84,11 @@ func (s Service) Ingest(ctx context.Context, command Command) (Result, error) {
|
||||
return Result{}, ErrConflict
|
||||
}
|
||||
result.Duplicate = !created
|
||||
if created && s.AfterPersist != nil {
|
||||
if err := s.AfterPersist(ctx, tx, result.Event); err != nil {
|
||||
return Result{}, fmt.Errorf("process accepted Event: %w", err)
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func TestNormalizeRejectsUnsafeEvidence(t *testing.T) {
|
||||
@@ -96,4 +97,17 @@ func TestPostgresIdempotencyAndImmutability(t *testing.T) {
|
||||
if afterRestart.Event.ID != eventID || afterRestart.Receipt.ID != receiptID || !afterRestart.Duplicate {
|
||||
t.Fatalf("receipt changed after restart: %#v", afterRestart)
|
||||
}
|
||||
failing := command
|
||||
failing.SourceEventID = key + "-rollback"
|
||||
failedService := Service{DB: reopened, AfterPersist: func(context.Context, pgx.Tx, Event) error { return errors.New("forced matching failure") }}
|
||||
if _, err := failedService.Ingest(ctx, failing); err == nil {
|
||||
t.Fatal("expected hook failure")
|
||||
}
|
||||
var persisted int
|
||||
if err := reopened.QueryRow(ctx, `SELECT count(*) FROM bell_events WHERE producer_id=$1 AND source_event_id=$2`, failing.ProducerID, failing.SourceEventID).Scan(&persisted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if persisted != 0 {
|
||||
t.Fatal("Event committed without atomic match")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
|
||||
)
|
||||
|
||||
type HTTP struct {
|
||||
Service Service
|
||||
Auth auth.HTTP
|
||||
Audit audit.Store
|
||||
}
|
||||
|
||||
func (h HTTP) Register(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/rules", h.Auth.Require(rbac.RulesRead, http.HandlerFunc(h.list)))
|
||||
mux.Handle("POST /api/v1/rules", h.Auth.Require(rbac.RulesWrite, http.HandlerFunc(h.create)))
|
||||
mux.Handle("PUT /api/v1/rules/{id}/enabled", h.Auth.Require(rbac.RulesWrite, http.HandlerFunc(h.setEnabled)))
|
||||
}
|
||||
func (h HTTP) list(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := h.Service.List(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": "读取规则失败"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": items})
|
||||
}
|
||||
func (h HTTP) create(w http.ResponseWriter, r *http.Request) {
|
||||
var input CreateInput
|
||||
if err := decode(w, r, &input); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": "请求格式无效"})
|
||||
return
|
||||
}
|
||||
item, err := h.Service.Create(r.Context(), input)
|
||||
if err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
actor := auth.Principal(r.Context())
|
||||
_ = h.Audit.Record(r.Context(), &actor.ID, "rule.created", "rule", &item.ID, "success", map[string]any{"version": item.Version})
|
||||
writeJSON(w, 201, item)
|
||||
}
|
||||
func (h HTTP) setEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := decode(w, r, &input); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": "请求格式无效"})
|
||||
return
|
||||
}
|
||||
item, err := h.Service.SetEnabled(r.Context(), r.PathValue("id"), input.Enabled)
|
||||
if err != nil {
|
||||
writeJSON(w, 404, map[string]string{"error": "规则不存在"})
|
||||
return
|
||||
}
|
||||
actor := auth.Principal(r.Context())
|
||||
_ = h.Audit.Record(r.Context(), &actor.ID, "rule.enabled_changed", "rule", &item.ID, "success", map[string]any{"enabled": item.Enabled, "version": item.Version})
|
||||
writeJSON(w, 200, item)
|
||||
}
|
||||
func decode(w http.ResponseWriter, r *http.Request, value any) error {
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
return decoder.Decode(value)
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrInvalid = errors.New("规则字段无效")
|
||||
|
||||
type Service struct{ DB *pgxpool.Pool }
|
||||
|
||||
func validSeverity(value string) bool {
|
||||
return value == "low" || value == "medium" || value == "high" || value == "critical"
|
||||
}
|
||||
func severityRank(value string) int {
|
||||
return map[string]int{"low": 1, "medium": 2, "high": 3, "critical": 4}[value]
|
||||
}
|
||||
func (s Service) Create(ctx context.Context, input CreateInput) (Rule, error) {
|
||||
input.Code = strings.ToLower(strings.TrimSpace(input.Code))
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.MinimumSeverity = strings.ToLower(strings.TrimSpace(input.MinimumSeverity))
|
||||
if input.Code == "" || input.Name == "" || !validSeverity(input.MinimumSeverity) {
|
||||
return Rule{}, ErrInvalid
|
||||
}
|
||||
if input.EventType != nil {
|
||||
v := strings.TrimSpace(*input.EventType)
|
||||
if v == "" {
|
||||
input.EventType = nil
|
||||
} else {
|
||||
input.EventType = &v
|
||||
}
|
||||
}
|
||||
if input.LocationContains != nil {
|
||||
v := strings.TrimSpace(*input.LocationContains)
|
||||
if v == "" {
|
||||
input.LocationContains = nil
|
||||
} else {
|
||||
input.LocationContains = &v
|
||||
}
|
||||
}
|
||||
var result Rule
|
||||
err := s.DB.QueryRow(ctx, `INSERT INTO bell_rules(code,name,event_type,minimum_severity,location_contains) VALUES($1,$2,$3,$4,$5) RETURNING id::text,code,name,enabled,event_type,minimum_severity,location_contains,version,created_at::text,updated_at::text`, input.Code, input.Name, input.EventType, input.MinimumSeverity, input.LocationContains).Scan(&result.ID, &result.Code, &result.Name, &result.Enabled, &result.EventType, &result.MinimumSeverity, &result.LocationContains, &result.Version, &result.CreatedAt, &result.UpdatedAt)
|
||||
return result, err
|
||||
}
|
||||
func (s Service) SetEnabled(ctx context.Context, id string, enabled bool) (Rule, error) {
|
||||
var result Rule
|
||||
err := s.DB.QueryRow(ctx, `UPDATE bell_rules SET enabled=$2,version=version+1,updated_at=now() WHERE id=$1 RETURNING id::text,code,name,enabled,event_type,minimum_severity,location_contains,version,created_at::text,updated_at::text`, id, enabled).Scan(&result.ID, &result.Code, &result.Name, &result.Enabled, &result.EventType, &result.MinimumSeverity, &result.LocationContains, &result.Version, &result.CreatedAt, &result.UpdatedAt)
|
||||
return result, err
|
||||
}
|
||||
func (s Service) List(ctx context.Context) ([]Rule, error) {
|
||||
rows, err := s.DB.Query(ctx, `SELECT id::text,code,name,enabled,event_type,minimum_severity,location_contains,version,created_at::text,updated_at::text FROM bell_rules ORDER BY created_at,id LIMIT 200`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Rule{}
|
||||
for rows.Next() {
|
||||
var item Rule
|
||||
if err := rows.Scan(&item.ID, &item.Code, &item.Name, &item.Enabled, &item.EventType, &item.MinimumSeverity, &item.LocationContains, &item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s Service) Match(ctx context.Context, tx pgx.Tx, evt event.Event) error {
|
||||
rows, err := tx.Query(ctx, `SELECT id::text,code,name,enabled,event_type,minimum_severity,location_contains,version,created_at::text,updated_at::text FROM bell_rules WHERE enabled=true ORDER BY id`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rules := []Rule{}
|
||||
for rows.Next() {
|
||||
var item Rule
|
||||
if err := rows.Scan(&item.ID, &item.Code, &item.Name, &item.Enabled, &item.EventType, &item.MinimumSeverity, &item.LocationContains, &item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rules = append(rules, item)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, current := range rules {
|
||||
matched := true
|
||||
reasons := []string{}
|
||||
if current.EventType != nil && *current.EventType != evt.EventType {
|
||||
matched = false
|
||||
reasons = append(reasons, "事件类型不匹配")
|
||||
}
|
||||
if severityRank(evt.Severity) < severityRank(current.MinimumSeverity) {
|
||||
matched = false
|
||||
reasons = append(reasons, "风险等级低于阈值")
|
||||
}
|
||||
if current.LocationContains != nil && !strings.Contains(strings.ToLower(evt.Location), strings.ToLower(*current.LocationContains)) {
|
||||
matched = false
|
||||
reasons = append(reasons, "地点条件不匹配")
|
||||
}
|
||||
explanation := "全部条件命中"
|
||||
if !matched {
|
||||
explanation = strings.Join(reasons, ";")
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO bell_rule_evaluations(event_id,rule_id,rule_version,matched,explanation) VALUES($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`, evt.ID, current.ID, current.Version, matched, explanation); err != nil {
|
||||
return err
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
snapshot, _ := json.Marshal(current)
|
||||
summary := fmt.Sprintf("%s:%s", current.Name, evt.EventType)
|
||||
var alertID string
|
||||
err := tx.QueryRow(ctx, `INSERT INTO bell_alerts(primary_rule_id,correlation_key,severity,summary,location) VALUES($1,$2,$3,$4,$5) ON CONFLICT(primary_rule_id,correlation_key) WHERE status='open' DO UPDATE SET updated_at=now(),severity=CASE WHEN array_position(ARRAY['low','medium','high','critical'],EXCLUDED.severity)>array_position(ARRAY['low','medium','high','critical'],bell_alerts.severity) THEN EXCLUDED.severity ELSE bell_alerts.severity END RETURNING id::text`, current.ID, strings.ToLower(evt.Location), evt.Severity, summary, evt.Location).Scan(&alertID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO bell_alert_events(alert_id,event_id) VALUES($1,$2) ON CONFLICT DO NOTHING`, alertID, evt.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO bell_rule_matches(alert_id,event_id,rule_id,rule_version,rule_snapshot,explanation) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING`, alertID, evt.ID, current.ID, current.Version, snapshot, explanation); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
|
||||
)
|
||||
|
||||
func TestPostgresMatchingAndNavigationShape(t *testing.T) {
|
||||
url := os.Getenv("BELL_TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("BELL_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
db, err := platform.OpenDatabase(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := migrations.Apply(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := Service{DB: db}
|
||||
prefix := fmt.Sprintf("rule-test-%d", time.Now().UnixNano())
|
||||
eventType := "rule-test-danger"
|
||||
location := "north"
|
||||
first, err := service.Create(ctx, CreateInput{Code: prefix + "-one", Name: "规则一", EventType: &eventType, MinimumSeverity: "medium", LocationContains: &location})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := service.Create(ctx, CreateInput{Code: prefix + "-two", Name: "规则二", EventType: &eventType, MinimumSeverity: "low"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventService := event.Service{DB: db, AfterPersist: service.Match}
|
||||
base := event.Command{ProducerID: prefix, SourceEventID: "1", EventType: eventType, OccurredAt: time.Now().UTC(), Location: "north gate", Severity: "high", Attributes: map[string]any{}}
|
||||
one, err := eventService.Ingest(ctx, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base.SourceEventID = "2"
|
||||
two, err := eventService.Ingest(ctx, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var alertCount, linkCount int
|
||||
if err := db.QueryRow(ctx, `SELECT count(DISTINCT m.alert_id),count(*) FROM bell_rule_matches m WHERE m.rule_id=ANY($1::uuid[])`, []string{first.ID, second.ID}).Scan(&alertCount, &linkCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if alertCount != 2 || linkCount != 4 {
|
||||
t.Fatalf("alerts=%d links=%d", alertCount, linkCount)
|
||||
}
|
||||
var shared int
|
||||
if err := db.QueryRow(ctx, `SELECT count(*) FROM bell_alert_events a JOIN bell_alert_events b ON b.alert_id=a.alert_id JOIN bell_alerts current ON current.id=a.alert_id WHERE a.event_id=$1 AND b.event_id=$2 AND current.primary_rule_id=ANY($3::uuid[])`, one.Event.ID, two.Event.ID, []string{first.ID, second.ID}).Scan(&shared); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if shared != 2 {
|
||||
t.Fatalf("expected two correlated alerts, got %d", shared)
|
||||
}
|
||||
base.SourceEventID = "3"
|
||||
base.EventType = "unmatched"
|
||||
unmatched, err := eventService.Ingest(ctx, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var evaluations, matches int
|
||||
if err := db.QueryRow(ctx, `SELECT count(*),count(*) FILTER(WHERE matched) FROM bell_rule_evaluations WHERE event_id=$1`, unmatched.Event.ID).Scan(&evaluations, &matches); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if evaluations < 2 || matches != 0 {
|
||||
t.Fatalf("evaluations=%d matches=%d", evaluations, matches)
|
||||
}
|
||||
disabled, err := service.SetEnabled(ctx, first.ID, false)
|
||||
if err != nil || disabled.Enabled || disabled.Version != 2 {
|
||||
t.Fatalf("disable: %#v %v", disabled, err)
|
||||
}
|
||||
enabled, err := service.SetEnabled(ctx, first.ID, true)
|
||||
if err != nil || !enabled.Enabled || enabled.Version != 3 {
|
||||
t.Fatalf("enable: %#v %v", enabled, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package rule
|
||||
|
||||
type Rule struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
EventType *string `json:"event_type,omitempty"`
|
||||
MinimumSeverity string `json:"minimum_severity"`
|
||||
LocationContains *string `json:"location_contains,omitempty"`
|
||||
Version int `json:"version"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
type CreateInput struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
EventType *string `json:"event_type,omitempty"`
|
||||
MinimumSeverity string `json:"minimum_severity"`
|
||||
LocationContains *string `json:"location_contains,omitempty"`
|
||||
}
|
||||
@@ -12,9 +12,11 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
alertQuery "git.ilapage.cn/ila/yovision/Bell/server/app/alert/query"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rule"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/synthetic"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/config"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
|
||||
@@ -67,9 +69,13 @@ func Run(ctx context.Context, args []string) error {
|
||||
app := platform.NewHTTPApp(db)
|
||||
authHTTP := auth.HTTP{Service: authService, Store: authStore, Audit: auditStore, SecureCookie: cfg.CookieSecure}
|
||||
authHTTP.Register(app.Router())
|
||||
event.HTTP{Service: event.Service{DB: db}, Auth: authHTTP}.Register(app.Router())
|
||||
ruleService := rule.Service{DB: db}
|
||||
eventService := event.Service{DB: db, AfterPersist: ruleService.Match}
|
||||
event.HTTP{Service: eventService, Auth: authHTTP}.Register(app.Router())
|
||||
rule.HTTP{Service: ruleService, Auth: authHTTP, Audit: auditStore}.Register(app.Router())
|
||||
alertQuery.HTTP{Service: alertQuery.Service{DB: db}, Auth: authHTTP}.Register(app.Router())
|
||||
if cfg.Environment == "development" || cfg.Environment == "test" {
|
||||
synthetic.HTTP{Service: synthetic.Service{Events: event.Service{DB: db}}, Auth: authHTTP}.Register(app.Router())
|
||||
synthetic.HTTP{Service: synthetic.Service{Events: eventService}, Auth: authHTTP}.Register(app.Router())
|
||||
}
|
||||
server := &http.Server{Addr: cfg.HTTPAddress, Handler: app.Handler()}
|
||||
serverCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
CREATE TABLE bell_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
event_type text,
|
||||
minimum_severity text NOT NULL CHECK (minimum_severity IN ('low','medium','high','critical')),
|
||||
location_contains text,
|
||||
version integer NOT NULL DEFAULT 1 CHECK (version > 0),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE bell_alerts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
primary_rule_id uuid NOT NULL REFERENCES bell_rules(id),
|
||||
correlation_key text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'open' CHECK (status IN ('open','acknowledged','closed')),
|
||||
severity text NOT NULL CHECK (severity IN ('low','medium','high','critical')),
|
||||
summary text NOT NULL,
|
||||
location text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX bell_alert_open_correlation_idx ON bell_alerts(primary_rule_id,correlation_key) WHERE status='open';
|
||||
CREATE INDEX bell_alerts_page_idx ON bell_alerts(created_at DESC,id DESC);
|
||||
CREATE TABLE bell_alert_events (
|
||||
alert_id uuid NOT NULL REFERENCES bell_alerts(id),
|
||||
event_id uuid NOT NULL REFERENCES bell_events(id),
|
||||
linked_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(alert_id,event_id)
|
||||
);
|
||||
CREATE INDEX bell_alert_events_event_idx ON bell_alert_events(event_id,alert_id);
|
||||
CREATE TABLE bell_rule_matches (
|
||||
alert_id uuid NOT NULL REFERENCES bell_alerts(id),
|
||||
event_id uuid NOT NULL REFERENCES bell_events(id),
|
||||
rule_id uuid NOT NULL REFERENCES bell_rules(id),
|
||||
rule_version integer NOT NULL,
|
||||
rule_snapshot jsonb NOT NULL,
|
||||
explanation text NOT NULL,
|
||||
matched_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(event_id,rule_id)
|
||||
);
|
||||
CREATE TABLE bell_rule_evaluations (
|
||||
event_id uuid NOT NULL REFERENCES bell_events(id),
|
||||
rule_id uuid NOT NULL REFERENCES bell_rules(id),
|
||||
rule_version integer NOT NULL,
|
||||
matched boolean NOT NULL,
|
||||
explanation text NOT NULL,
|
||||
evaluated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(event_id,rule_id)
|
||||
);
|
||||
@@ -14,7 +14,18 @@ import (
|
||||
var files embed.FS
|
||||
|
||||
func Apply(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS bell_schema_migrations (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
|
||||
conn, err := pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("acquire migration connection: %w", err)
|
||||
}
|
||||
defer conn.Release()
|
||||
if _, err = conn.Exec(ctx, `SELECT pg_advisory_lock(hashtext('yovision-bell-migrations'))`); err != nil {
|
||||
return fmt.Errorf("lock migrations: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = conn.Exec(context.Background(), `SELECT pg_advisory_unlock(hashtext('yovision-bell-migrations'))`)
|
||||
}()
|
||||
if _, err := conn.Exec(ctx, `CREATE TABLE IF NOT EXISTS bell_schema_migrations (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
|
||||
return fmt.Errorf("create migration ledger: %w", err)
|
||||
}
|
||||
entries, err := fs.ReadDir(files, ".")
|
||||
@@ -27,7 +38,7 @@ func Apply(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
continue
|
||||
}
|
||||
var applied bool
|
||||
if err := pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bell_schema_migrations WHERE name=$1)`, entry.Name()).Scan(&applied); err != nil {
|
||||
if err := conn.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bell_schema_migrations WHERE name=$1)`, entry.Name()).Scan(&applied); err != nil {
|
||||
return err
|
||||
}
|
||||
if applied {
|
||||
@@ -37,7 +48,7 @@ func Apply(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := pool.Begin(ctx)
|
||||
tx, err := conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import request from '../../bootstrap/request'
|
||||
export const listAlerts = params => request.get('/api/v1/alerts', { params })
|
||||
export const getAlert = id => request.get(`/api/v1/alerts/${id}`)
|
||||
export const alertsForEvent = id => request.get(`/api/v1/events/${id}/alerts`)
|
||||
@@ -0,0 +1,4 @@
|
||||
import request from '../../bootstrap/request'
|
||||
export const listRules = () => request.get('/api/v1/rules')
|
||||
export const createRule = data => request.post('/api/v1/rules', data)
|
||||
export const setRuleEnabled = (id, enabled) => request.put(`/api/v1/rules/${id}/enabled`, { enabled })
|
||||
@@ -5,6 +5,8 @@ import Users from '../views/system/Users.vue'
|
||||
import Audit from '../views/system/Audit.vue'
|
||||
import Events from '../views/event/Events.vue'
|
||||
import SyntheticEvent from '../views/synthetic-event/SyntheticEvent.vue'
|
||||
import Rules from '../views/rule/Rules.vue'
|
||||
import Alerts from '../views/alert/list/Alerts.vue'
|
||||
import { listFixtures } from '../api/synthetic'
|
||||
import store from './store'
|
||||
|
||||
@@ -14,6 +16,8 @@ const router = createRouter({
|
||||
{ path: '/login', name: 'login', component: Login, meta: { title: '登录', public: true } },
|
||||
{ path: '/', name: 'dashboard', component: Dashboard, meta: { title: '工作台', permission: 'dashboard:read' } },
|
||||
{ path: '/events', name: 'events', component: Events, meta: { title: '事件查询', permission: 'events:read' } },
|
||||
{ path: '/alerts', name: 'alerts', component: Alerts, meta: { title: '预警管理', permission: 'alerts:read' } },
|
||||
{ path: '/rules', name: 'rules', component: Rules, meta: { title: '规则配置', permission: 'rules:read' } },
|
||||
{ path: '/development/synthetic-events', name: 'synthetic-events', component: SyntheticEvent, meta: { title: '合成事件测试', permission: 'synthetic:write', developmentOnly: true } },
|
||||
{ path: '/system/users', name: 'users', component: Users, meta: { title: '用户与角色', permission: 'users:read' } },
|
||||
{ path: '/system/audit', name: 'audit', component: Audit, meta: { title: '认证审计', permission: 'audit:read' } }
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<nav>
|
||||
<router-link to="/" class="nav-item">工作台</router-link>
|
||||
<router-link v-if="has('events:read')" to="/events" class="nav-item">事件查询</router-link>
|
||||
<router-link v-if="has('alerts:read')" to="/alerts" class="nav-item">预警管理</router-link>
|
||||
<router-link v-if="has('rules:read')" to="/rules" class="nav-item">规则配置</router-link>
|
||||
<router-link v-if="has('users:read')" to="/system/users" class="nav-item">用户与角色</router-link>
|
||||
<router-link v-if="has('audit:read')" to="/system/audit" class="nav-item">认证审计</router-link>
|
||||
<router-link v-if="syntheticAvailable" to="/development/synthetic-events" class="nav-item">合成事件测试</router-link>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<template><main class="page" tabindex="-1"><header class="page__header"><div><h1>预警管理</h1><p>规则命中的 Event 在此形成可处置预警。</p></div></header><el-table :data="items" v-loading="loading" row-key="id" @row-dblclick="open"><el-table-column prop="created_at" label="创建时间" min-width="180"/><el-table-column prop="summary" label="预警事项" min-width="190"/><el-table-column prop="location" label="地点" min-width="130"/><el-table-column label="状态"><template #default="scope"><el-tag>{{scope.row.status==='open'?'待处理':scope.row.status}}</el-tag></template></el-table-column><el-table-column prop="event_count" label="关联事件" width="100"/><el-table-column label="操作" width="100"><template #default="scope"><el-button link type="primary" @click.stop="open(scope.row)">详情</el-button></template></el-table-column></el-table><el-empty v-if="!loading&&!items.length" description="尚无预警"/><div class="pagination-actions"><el-button :disabled="!next" @click="load(next)">下一页</el-button></div><el-drawer v-model="drawer" title="预警详情" size="min(620px, 100%)"><template v-if="detail"><el-descriptions :column="1" border><el-descriptions-item label="事项">{{detail.alert.summary}}</el-descriptions-item><el-descriptions-item label="地点">{{detail.alert.location}}</el-descriptions-item><el-descriptions-item label="规则">{{detail.alert.rule_name}}</el-descriptions-item><el-descriptions-item label="状态">{{detail.alert.status}}</el-descriptions-item></el-descriptions><h2>关联事件</h2><el-table :data="detail.events" row-key="id"><el-table-column prop="occurred_at" label="发生时间"/><el-table-column prop="event_type" label="类型"/><el-table-column label="操作" width="80"><template #default="scope"><el-button link @click="router.push({name:'events',query:{event:scope.row.id}})">查看</el-button></template></el-table-column></el-table><h2>命中说明</h2><el-timeline><el-timeline-item v-for="match in detail.matches" :key="`${match.event_id}-${match.rule_id}`" :timestamp="match.matched_at">规则 v{{match.rule_version}}:{{match.explanation}}</el-timeline-item></el-timeline></template></el-drawer></main></template>
|
||||
<script setup>
|
||||
import { onMounted,ref } from 'vue';import { useRouter } from 'vue-router';import { ElMessage } from 'element-plus';import { getAlert,listAlerts } from '../../../api/alert';const router=useRouter();const items=ref([]);const loading=ref(false);const next=ref('');const drawer=ref(false);const detail=ref(null);async function load(before=''){loading.value=true;try{const result=await listAlerts({limit:25,before});items.value=result.items;next.value=result.items.length===25?result.next:''}catch(e){ElMessage.error(e.error||'读取预警失败')}finally{loading.value=false}}async function open(row){try{detail.value=await getAlert(row.id);drawer.value=true}catch(e){ElMessage.error(e.error||'读取预警失败')}}onMounted(()=>load())
|
||||
</script>
|
||||
<style scoped>.pagination-actions{display:flex;justify-content:flex-end;margin-top:16px}h2{font-size:16px;margin-top:20px}</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template><main class="page" tabindex="-1"><header class="page__header"><div><h1>事件查询</h1><p>原始事件只读保存;处理结果不会改写事件。</p></div></header><el-table :data="items" v-loading="loading" row-key="id" @row-dblclick="open"><el-table-column prop="occurred_at" label="发生时间" min-width="180"/><el-table-column prop="event_type" label="事件类型" min-width="140"/><el-table-column prop="location" label="地点" min-width="150"/><el-table-column label="风险"><template #default="scope"><el-tag :type="severityType(scope.row.severity)">{{severityName(scope.row.severity)}}</el-tag></template></el-table-column><el-table-column label="操作" width="100"><template #default="scope"><el-button link type="primary" @click.stop="open(scope.row)">详情</el-button></template></el-table-column></el-table><el-empty v-if="!loading&&!items.length" description="尚无事件"/><div class="pagination-actions"><el-button :disabled="!next" @click="load(next)">下一页</el-button></div><el-drawer v-model="drawer" title="事件技术详情" size="min(560px, 100%)"><el-descriptions v-if="selected" :column="1" border><el-descriptions-item label="事件编号">{{selected.id}}</el-descriptions-item><el-descriptions-item label="来源">{{selected.producer_id}}</el-descriptions-item><el-descriptions-item label="来源事件编号">{{selected.source_event_id}}</el-descriptions-item><el-descriptions-item label="发生时间">{{selected.occurred_at}}</el-descriptions-item><el-descriptions-item label="证据引用">{{selected.evidence_ref||'无'}}</el-descriptions-item><el-descriptions-item label="规范化载荷"><pre>{{JSON.stringify(selected.normalized_payload,null,2)}}</pre></el-descriptions-item></el-descriptions></el-drawer></main></template>
|
||||
<template><main class="page" tabindex="-1"><header class="page__header"><div><h1>事件查询</h1><p>原始事件只读保存;处理结果不会改写事件。</p></div></header><el-table :data="items" v-loading="loading" row-key="id" @row-dblclick="open"><el-table-column prop="occurred_at" label="发生时间" min-width="180"/><el-table-column prop="event_type" label="事件类型" min-width="140"/><el-table-column prop="location" label="地点" min-width="150"/><el-table-column label="风险"><template #default="scope"><el-tag :type="severityType(scope.row.severity)">{{severityName(scope.row.severity)}}</el-tag></template></el-table-column><el-table-column label="操作" width="100"><template #default="scope"><el-button link type="primary" @click.stop="open(scope.row)">详情</el-button></template></el-table-column></el-table><el-empty v-if="!loading&&!items.length" description="尚无事件"/><div class="pagination-actions"><el-button :disabled="!next" @click="load(next)">下一页</el-button></div><el-drawer v-model="drawer" title="事件技术详情" size="min(560px, 100%)"><template v-if="selected"><el-descriptions :column="1" border><el-descriptions-item label="事件编号">{{selected.id}}</el-descriptions-item><el-descriptions-item label="来源">{{selected.producer_id}}</el-descriptions-item><el-descriptions-item label="来源事件编号">{{selected.source_event_id}}</el-descriptions-item><el-descriptions-item label="发生时间">{{selected.occurred_at}}</el-descriptions-item><el-descriptions-item label="证据引用">{{selected.evidence_ref||'无'}}</el-descriptions-item><el-descriptions-item label="规范化载荷"><pre>{{JSON.stringify(selected.normalized_payload,null,2)}}</pre></el-descriptions-item></el-descriptions><h2>关联预警</h2><el-table :data="selectedAlerts" row-key="id"><el-table-column prop="summary" label="事项"/><el-table-column label="操作" width="80"><template #default><el-button link @click="router.push('/alerts')">查看</el-button></template></el-table-column></el-table><el-empty v-if="!selectedAlerts.length" description="该事件未命中规则"/></template></el-drawer></main></template>
|
||||
<script setup>
|
||||
import { onMounted,ref } from 'vue';import { ElMessage } from 'element-plus';import { getEvent,listEvents } from '../../api/event';const items=ref([]);const loading=ref(false);const next=ref('');const drawer=ref(false);const selected=ref(null);const severityType=value=>({critical:'danger',high:'warning',medium:'primary',low:'info'}[value]||'info');const severityName=value=>({critical:'紧急',high:'高',medium:'中',low:'低'}[value]||value);async function load(before=''){loading.value=true;try{const result=await listEvents({limit:25,before});items.value=result.items;next.value=result.items.length===25?result.next:''}catch(e){ElMessage.error(e.error||'读取事件失败')}finally{loading.value=false}}async function open(row){try{selected.value=await getEvent(row.id);drawer.value=true}catch(e){ElMessage.error(e.error||'读取事件失败')}}onMounted(()=>load())
|
||||
import { onMounted,ref } from 'vue';import { useRouter } from 'vue-router';import { ElMessage } from 'element-plus';import { alertsForEvent } from '../../api/alert';import { getEvent,listEvents } from '../../api/event';const router=useRouter();const items=ref([]);const loading=ref(false);const next=ref('');const drawer=ref(false);const selected=ref(null);const selectedAlerts=ref([]);const severityType=value=>({critical:'danger',high:'warning',medium:'primary',low:'info'}[value]||'info');const severityName=value=>({critical:'紧急',high:'高',medium:'中',low:'低'}[value]||value);async function load(before=''){loading.value=true;try{const result=await listEvents({limit:25,before});items.value=result.items;next.value=result.items.length===25?result.next:''}catch(e){ElMessage.error(e.error||'读取事件失败')}finally{loading.value=false}}async function open(row){try{selected.value=await getEvent(row.id);selectedAlerts.value=(await alertsForEvent(row.id)).items;drawer.value=true}catch(e){ElMessage.error(e.error||'读取事件失败')}}onMounted(()=>load())
|
||||
</script>
|
||||
<style scoped>.pagination-actions{display:flex;justify-content:flex-end;margin-top:16px}pre{white-space:pre-wrap;word-break:break-word;margin:0}</style>
|
||||
<style scoped>.pagination-actions{display:flex;justify-content:flex-end;margin-top:16px}pre{white-space:pre-wrap;word-break:break-word;margin:0}h2{font-size:16px;margin-top:20px}</style>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<template><main class="page" tabindex="-1"><header class="page__header"><div><h1>规则配置</h1><p>启用规则会评估新 Event;版本与实际匹配快照永久保留。</p></div><el-button v-if="canWrite" type="primary" @click="dialog=true">新建规则</el-button></header><el-table :data="items" v-loading="loading" row-key="id"><el-table-column prop="name" label="规则名称" min-width="160"/><el-table-column prop="event_type" label="事件类型"><template #default="scope">{{scope.row.event_type||'全部'}}</template></el-table-column><el-table-column prop="minimum_severity" label="最低风险"/><el-table-column prop="location_contains" label="地点包含"><template #default="scope">{{scope.row.location_contains||'不限'}}</template></el-table-column><el-table-column prop="version" label="版本" width="80"/><el-table-column label="启用" width="100"><template #default="scope"><el-switch :model-value="scope.row.enabled" :disabled="!canWrite" :aria-label="`${scope.row.name}启用状态`" @change="value=>toggle(scope.row,value)"/></template></el-table-column></el-table><el-empty v-if="!loading&&!items.length" description="尚无规则"/><el-dialog v-model="dialog" title="新建预警规则" width="min(520px, calc(100vw - 32px))" @closed="reset"><el-form ref="formRef" :model="form" :rules="rules" label-position="top"><el-form-item label="规则编码" prop="code"><el-input v-model.trim="form.code" placeholder="如 north-wall-danger"/></el-form-item><el-form-item label="规则名称" prop="name"><el-input v-model.trim="form.name"/></el-form-item><el-form-item label="事件类型(留空表示全部)"><el-input v-model.trim="form.event_type"/></el-form-item><el-form-item label="最低风险" prop="minimum_severity"><el-select v-model="form.minimum_severity" style="width:100%"><el-option label="低" value="low"/><el-option label="中" value="medium"/><el-option label="高" value="high"/><el-option label="紧急" value="critical"/></el-select></el-form-item><el-form-item label="地点包含(可选)"><el-input v-model.trim="form.location_contains"/></el-form-item></el-form><template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="save">创建规则</el-button></template></el-dialog></main></template>
|
||||
<script setup>
|
||||
import { computed,onMounted,reactive,ref } from 'vue';import { ElMessage } from 'element-plus';import { useStore } from 'vuex';import { createRule,listRules,setRuleEnabled } from '../../api/rule';const store=useStore();const canWrite=computed(()=>store.getters['identity/has']('rules:write'));const items=ref([]);const loading=ref(false);const saving=ref(false);const dialog=ref(false);const formRef=ref();const form=reactive({code:'',name:'',event_type:'',minimum_severity:'high',location_contains:''});const rules={code:[{required:true,message:'请输入规则编码',trigger:'blur'}],name:[{required:true,message:'请输入规则名称',trigger:'blur'}],minimum_severity:[{required:true,message:'请选择最低风险',trigger:'change'}]};async function load(){loading.value=true;try{items.value=(await listRules()).items}catch(e){ElMessage.error(e.error||'读取规则失败')}finally{loading.value=false}}async function save(){try{await formRef.value.validate();saving.value=true;const payload={...form,event_type:form.event_type||null,location_contains:form.location_contains||null};await createRule(payload);dialog.value=false;ElMessage.success('规则已创建');await load()}catch(e){if(e?.error)ElMessage.error(e.error)}finally{saving.value=false}}async function toggle(item,value){try{await setRuleEnabled(item.id,value);ElMessage.success(value?'规则已启用':'规则已停用');await load()}catch(e){ElMessage.error(e.error||'更新失败')}}function reset(){Object.assign(form,{code:'',name:'',event_type:'',minimum_severity:'high',location_contains:''});formRef.value?.clearValidate()}onMounted(load)
|
||||
</script>
|
||||
Reference in New Issue
Block a user