feat: persist immutable Bell events and receipts (#17)
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
package event
|
||||
|
||||
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/events", h.Auth.Require(rbac.EventsRead, http.HandlerFunc(h.list)))
|
||||
mux.Handle("GET /api/v1/events/{id}", h.Auth.Require(rbac.EventsRead, http.HandlerFunc(h.get)))
|
||||
}
|
||||
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, http.StatusInternalServerError, map[string]string{"error": "读取事件失败"})
|
||||
return
|
||||
}
|
||||
next := ""
|
||||
if len(items) > 0 {
|
||||
next = items[len(items)-1].ID
|
||||
}
|
||||
writeJSON(w, http.StatusOK, 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 {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "事件不存在"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "读取事件失败"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
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,134 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrConflict = errors.New("幂等键已用于不同事件载荷")
|
||||
var ErrInvalid = errors.New("事件字段无效")
|
||||
|
||||
type Service struct{ DB *pgxpool.Pool }
|
||||
|
||||
func normalize(command Command) ([]byte, [32]byte, error) {
|
||||
command.ProducerID = strings.TrimSpace(command.ProducerID)
|
||||
command.SourceEventID = strings.TrimSpace(command.SourceEventID)
|
||||
command.EventType = strings.TrimSpace(command.EventType)
|
||||
command.Location = strings.TrimSpace(command.Location)
|
||||
command.Severity = strings.ToLower(strings.TrimSpace(command.Severity))
|
||||
command.OccurredAt = command.OccurredAt.UTC()
|
||||
if command.ProducerID == "" || command.SourceEventID == "" || command.EventType == "" || command.Location == "" || command.OccurredAt.IsZero() {
|
||||
return nil, [32]byte{}, ErrInvalid
|
||||
}
|
||||
if command.Severity != "low" && command.Severity != "medium" && command.Severity != "high" && command.Severity != "critical" {
|
||||
return nil, [32]byte{}, ErrInvalid
|
||||
}
|
||||
if command.EvidenceRef != nil {
|
||||
value := strings.TrimSpace(*command.EvidenceRef)
|
||||
if value == "" || strings.Contains(value, "@") || strings.Contains(value, "\\") || strings.HasPrefix(strings.ToLower(value), "file:") {
|
||||
return nil, [32]byte{}, fmt.Errorf("%w: evidence_ref must be a safe logical reference", ErrInvalid)
|
||||
}
|
||||
command.EvidenceRef = &value
|
||||
}
|
||||
if command.Attributes == nil {
|
||||
command.Attributes = map[string]any{}
|
||||
}
|
||||
data, err := json.Marshal(command)
|
||||
if err != nil {
|
||||
return nil, [32]byte{}, fmt.Errorf("normalize event: %w", err)
|
||||
}
|
||||
return data, sha256.Sum256(data), nil
|
||||
}
|
||||
|
||||
func (s Service) Ingest(ctx context.Context, command Command) (Result, error) {
|
||||
payload, digest, err := normalize(command)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
var normalized Command
|
||||
if err := json.Unmarshal(payload, &normalized); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
tx, err := s.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var eventID string
|
||||
err = tx.QueryRow(ctx, `INSERT INTO bell_events(producer_id,source_event_id,event_type,occurred_at,location,severity,evidence_ref,normalized_payload,payload_sha256) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT(producer_id,source_event_id) DO NOTHING RETURNING id::text`, normalized.ProducerID, normalized.SourceEventID, normalized.EventType, normalized.OccurredAt, normalized.Location, normalized.Severity, normalized.EvidenceRef, payload, digest[:]).Scan(&eventID)
|
||||
created := err == nil
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Result{}, err
|
||||
}
|
||||
if created {
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO bell_event_receipts(event_id,producer_id,source_event_id,payload_sha256) VALUES($1,$2,$3,$4)`, eventID, normalized.ProducerID, normalized.SourceEventID, digest[:]); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
}
|
||||
result, storedDigest, err := loadResult(ctx, tx, normalized.ProducerID, normalized.SourceEventID)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if !created && !equalDigest(storedDigest, digest[:]) {
|
||||
return Result{}, ErrConflict
|
||||
}
|
||||
result.Duplicate = !created
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadResult(ctx context.Context, q interface {
|
||||
QueryRow(context.Context, string, ...any) pgx.Row
|
||||
}, producerID, sourceEventID string) (Result, []byte, error) {
|
||||
var result Result
|
||||
var digest []byte
|
||||
err := q.QueryRow(ctx, `SELECT e.id::text,e.producer_id,e.source_event_id,e.event_type,e.occurred_at::text,e.location,e.severity,e.evidence_ref,e.normalized_payload,e.created_at::text,e.payload_sha256,r.id::text,r.accepted_at::text FROM bell_events e JOIN bell_event_receipts r ON r.event_id=e.id WHERE e.producer_id=$1 AND e.source_event_id=$2`, producerID, sourceEventID).Scan(&result.Event.ID, &result.Event.ProducerID, &result.Event.SourceEventID, &result.Event.EventType, &result.Event.OccurredAt, &result.Event.Location, &result.Event.Severity, &result.Event.EvidenceRef, &result.Event.NormalizedPayload, &result.Event.CreatedAt, &digest, &result.Receipt.ID, &result.Receipt.AcceptedAt)
|
||||
result.Receipt.EventID = result.Event.ID
|
||||
result.Receipt.ProducerID = producerID
|
||||
result.Receipt.SourceEventID = sourceEventID
|
||||
return result, digest, err
|
||||
}
|
||||
func equalDigest(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
var different byte
|
||||
for i := range a {
|
||||
different |= a[i] ^ b[i]
|
||||
}
|
||||
return different == 0
|
||||
}
|
||||
|
||||
func (s Service) List(ctx context.Context, limit int, before string) ([]Event, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 25
|
||||
}
|
||||
rows, err := s.DB.Query(ctx, `SELECT id::text,producer_id,source_event_id,event_type,occurred_at::text,location,severity,evidence_ref,normalized_payload,created_at::text FROM bell_events WHERE ($2='' OR (created_at,id)<((SELECT created_at FROM bell_events WHERE id=nullif($2,'')::uuid),nullif($2,'')::uuid)) ORDER BY created_at DESC,id DESC LIMIT $1`, limit, before)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Event{}
|
||||
for rows.Next() {
|
||||
var item Event
|
||||
if err := rows.Scan(&item.ID, &item.ProducerID, &item.SourceEventID, &item.EventType, &item.OccurredAt, &item.Location, &item.Severity, &item.EvidenceRef, &item.NormalizedPayload, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
func (s Service) Get(ctx context.Context, id string) (Event, error) {
|
||||
var item Event
|
||||
err := s.DB.QueryRow(ctx, `SELECT id::text,producer_id,source_event_id,event_type,occurred_at::text,location,severity,evidence_ref,normalized_payload,created_at::text FROM bell_events WHERE id=$1`, id).Scan(&item.ID, &item.ProducerID, &item.SourceEventID, &item.EventType, &item.OccurredAt, &item.Location, &item.Severity, &item.EvidenceRef, &item.NormalizedPayload, &item.CreatedAt)
|
||||
return item, err
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
|
||||
)
|
||||
|
||||
func TestNormalizeRejectsUnsafeEvidence(t *testing.T) {
|
||||
value := `file:C:\camera\secret.mp4`
|
||||
_, _, err := normalize(Command{ProducerID: "test", SourceEventID: "1", EventType: "intrusion", OccurredAt: time.Now(), Location: "gate", Severity: "high", EvidenceRef: &value})
|
||||
if !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("expected invalid evidence reference, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresIdempotencyAndImmutability(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}
|
||||
key := fmt.Sprintf("event-test-%d", time.Now().UnixNano())
|
||||
command := Command{ProducerID: "integration-test", SourceEventID: key, EventType: "danger-zone", OccurredAt: time.Now().UTC().Truncate(time.Millisecond), Location: "test-zone", Severity: "high", Attributes: map[string]any{"track_id": "anonymous-1"}}
|
||||
const count = 16
|
||||
results := make(chan Result, count)
|
||||
errorsFound := make(chan error, count)
|
||||
var wg sync.WaitGroup
|
||||
for range count {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result, err := service.Ingest(ctx, command)
|
||||
if err != nil {
|
||||
errorsFound <- err
|
||||
return
|
||||
}
|
||||
results <- result
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
close(errorsFound)
|
||||
for err := range errorsFound {
|
||||
t.Errorf("concurrent ingest: %v", err)
|
||||
}
|
||||
var eventID, receiptID string
|
||||
seen := 0
|
||||
for result := range results {
|
||||
seen++
|
||||
if eventID == "" {
|
||||
eventID = result.Event.ID
|
||||
receiptID = result.Receipt.ID
|
||||
}
|
||||
if result.Event.ID != eventID || result.Receipt.ID != receiptID {
|
||||
t.Errorf("unstable identities: %#v", result)
|
||||
}
|
||||
}
|
||||
if seen != count {
|
||||
t.Fatalf("received %d results, want %d", seen, count)
|
||||
}
|
||||
conflict := command
|
||||
conflict.Location = "different"
|
||||
if _, err := service.Ingest(ctx, conflict); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("expected conflict, got %v", err)
|
||||
}
|
||||
if _, err := db.Exec(ctx, `UPDATE bell_events SET location='tampered' WHERE id=$1`, eventID); err == nil {
|
||||
t.Fatal("immutable Event accepted update")
|
||||
}
|
||||
db.Close()
|
||||
reopened, err := platform.OpenDatabase(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
afterRestart, err := (Service{DB: reopened}).Ingest(ctx, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if afterRestart.Event.ID != eventID || afterRestart.Receipt.ID != receiptID || !afterRestart.Duplicate {
|
||||
t.Fatalf("receipt changed after restart: %#v", afterRestart)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/receipt"
|
||||
)
|
||||
|
||||
type Command struct {
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Location string `json:"location"`
|
||||
Severity string `json:"severity"`
|
||||
EvidenceRef *string `json:"evidence_ref,omitempty"`
|
||||
Attributes map[string]any `json:"attributes"`
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
Location string `json:"location"`
|
||||
Severity string `json:"severity"`
|
||||
EvidenceRef *string `json:"evidence_ref,omitempty"`
|
||||
NormalizedPayload json.RawMessage `json:"normalized_payload"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Event Event `json:"event"`
|
||||
Receipt receipt.Receipt `json:"receipt"`
|
||||
Duplicate bool `json:"duplicate"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package receipt
|
||||
|
||||
type Receipt struct {
|
||||
ID string `json:"id"`
|
||||
EventID string `json:"event_id"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
AcceptedAt string `json:"accepted_at"`
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"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/config"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
|
||||
@@ -63,7 +64,9 @@ func Run(ctx context.Context, args []string) error {
|
||||
return authService.BootstrapAdministrator(ctx, username, displayName, password)
|
||||
}
|
||||
app := platform.NewHTTPApp(db)
|
||||
auth.HTTP{Service: authService, Store: authStore, Audit: auditStore, SecureCookie: cfg.CookieSecure}.Register(app.Router())
|
||||
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())
|
||||
server := &http.Server{Addr: cfg.HTTPAddress, Handler: app.Handler()}
|
||||
serverCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE bell_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
producer_id text NOT NULL,
|
||||
source_event_id text NOT NULL,
|
||||
event_type text NOT NULL,
|
||||
occurred_at timestamptz NOT NULL,
|
||||
location text NOT NULL,
|
||||
severity text NOT NULL CHECK (severity IN ('low','medium','high','critical')),
|
||||
evidence_ref text,
|
||||
normalized_payload jsonb NOT NULL,
|
||||
payload_sha256 bytea NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (producer_id, source_event_id)
|
||||
);
|
||||
CREATE INDEX bell_events_created_page_idx ON bell_events(created_at DESC,id DESC);
|
||||
CREATE TABLE bell_event_receipts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_id uuid NOT NULL UNIQUE REFERENCES bell_events(id),
|
||||
producer_id text NOT NULL,
|
||||
source_event_id text NOT NULL,
|
||||
payload_sha256 bytea NOT NULL,
|
||||
accepted_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (producer_id, source_event_id)
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION bell_reject_event_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN RAISE EXCEPTION 'Bell Event and Receipt facts are immutable'; END $$;
|
||||
CREATE TRIGGER bell_event_no_update BEFORE UPDATE OR DELETE ON bell_events FOR EACH ROW EXECUTE FUNCTION bell_reject_event_mutation();
|
||||
CREATE TRIGGER bell_receipt_no_update BEFORE UPDATE OR DELETE ON bell_event_receipts FOR EACH ROW EXECUTE FUNCTION bell_reject_event_mutation();
|
||||
@@ -0,0 +1,3 @@
|
||||
import request from '../../bootstrap/request'
|
||||
export const listEvents = params => request.get('/api/v1/events', { params })
|
||||
export const getEvent = id => request.get(`/api/v1/events/${id}`)
|
||||
@@ -3,6 +3,7 @@ import Dashboard from './Dashboard.vue'
|
||||
import Login from '../views/login/Login.vue'
|
||||
import Users from '../views/system/Users.vue'
|
||||
import Audit from '../views/system/Audit.vue'
|
||||
import Events from '../views/event/Events.vue'
|
||||
import store from './store'
|
||||
|
||||
const router = createRouter({
|
||||
@@ -10,6 +11,7 @@ const router = createRouter({
|
||||
routes: [
|
||||
{ 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: '/system/users', name: 'users', component: Users, meta: { title: '用户与角色', permission: 'users:read' } },
|
||||
{ path: '/system/audit', name: 'audit', component: Audit, meta: { title: '认证审计', permission: 'audit:read' } }
|
||||
]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<div class="brand"><span class="brand__mark">B</span><div><strong>Bell</strong><small>预警中心</small></div></div>
|
||||
<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('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>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +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>
|
||||
<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())
|
||||
</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>
|
||||
Reference in New Issue
Block a user