feat(bell): add alert acknowledgement vertical slice
Harness governance / validate (pull_request) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
This commit is contained in:
+26
-4
@@ -1,6 +1,6 @@
|
||||
# Bell 事件存储、审计与 Brain 事件入口
|
||||
# Bell 事件、规则与预警处置纵切
|
||||
|
||||
Bell 当前实现 M3 的事件域基础、Sense 审计 relay 与默认关闭的 Brain 事件 ingress:
|
||||
Bell 当前实现 M3 的事件域基础、Sense 审计 relay、默认关闭的 Brain 事件 ingress,以及默认关闭的规则→Alert→ack/close 工程纵切:
|
||||
|
||||
- Bell 在可信 ingress 内为不含 `id` 的候选事实生成 `evt_` ULID。
|
||||
- 最终事件同时通过冻结 v0.1 JSON Schema 与六项代码级断言。
|
||||
@@ -10,8 +10,11 @@ Bell 当前实现 M3 的事件域基础、Sense 审计 relay 与默认关闭的
|
||||
- `(key_id, nonce)` 收据保存 10 分钟;相同摘要重放原结果,不同摘要返回冲突。非回环监听必须配置 TLS 证书和私钥。
|
||||
- T-019 可选 `/internal/v1/event-candidates` 把 HMAC key 绑定到一个 `producer_id`,通过 `event_ingress_bindings` 解析数字事件身份并复查当前 Site/Area/Sense Device;缺失、删除、Area 不一致或 `non_imaging_only` 均失败关闭。
|
||||
- `(producer_id, source_event_id)` 永久收据、最终事件和成功 nonce 响应同事务提交;相同 canonical candidate 返回原 Bell ID,不同 candidate 返回 `source_event_conflict`。
|
||||
- 规则配置按 canonical hash 幂等发布不可变版本;worker 为每个 Event 写 durable sweep/evaluation,命中时由 Bell 生成 `alt_` ULID 并关联 Event。
|
||||
- Alert 的 `open → acknowledged → closed` 状态从 append-only transition 推导;8 路并发 ack 只有首个成功,后到者得到实际首位处置人/时间,同一幂等键跨重启重放原响应。
|
||||
- `/bell-console/` 是自包含、无 CDN 的回环工程值班台,显示真实规则版本、关联 Event 和处置时间线;证据与升级/通知未实现时明确显示不可用,不伪造成功事实。
|
||||
|
||||
公共认证/事件 API、规则、Alert 和证据对象存储仍需后续任务冻结。两条 HMAC ingress 都是内部适配器,不得当成 Bell 公共协议或共用 key。
|
||||
公共认证/事件 API、正式规则管理、证据对象存储、升级/通知和正式前端框架仍需后续任务冻结。两条 HMAC ingress 与回环控制台都是工程适配器,不得当成 Bell 公共协议或共用 key/token。
|
||||
|
||||
启动内部 receiver 前必须私下设置 `BELL_DB_DSN` 和仓库外绝对路径 `BELL_AUDIT_KEYS_FILE`。远端监听还必须设置 `BELL_TLS_CERT_FILE`、`BELL_TLS_KEY_FILE`;仓库不保存 DSN、key 或证书:
|
||||
|
||||
@@ -19,7 +22,7 @@ Bell 当前实现 M3 的事件域基础、Sense 审计 relay 与默认关闭的
|
||||
go -C Bell run ./cmd/bell-api
|
||||
```
|
||||
|
||||
事件 ingress 默认关闭。启用前,管理员先在专用数据库执行 `001`~`017` migration,并用受控 SQL 创建与现有 Bell Site/Area、Sense Device 一致的绑定;运行角色不能写绑定。然后私下设置:
|
||||
事件 ingress 默认关闭。启用前,管理员先在专用数据库执行 `001`~`019` migration,并用受控 SQL 创建与现有 Bell Site/Area、Sense Device 一致的绑定;运行角色不能写绑定。然后私下设置:
|
||||
|
||||
```powershell
|
||||
$env:BELL_EVENT_INGRESS_ENABLED = 'true'
|
||||
@@ -46,6 +49,25 @@ INSERT INTO bell.event_ingress_bindings(
|
||||
|
||||
外键只负责资源存在;runtime 还会复查 Area 属于同 Site、设备当前 Area/modality 一致、Site/Area 未删除且策略允许成像。换绑或停用由管理员显式更新/删除 binding,不能修改永久来源收据。
|
||||
|
||||
规则 worker 和工程值班台分别由 feature flag 开启;启用值班台必须同时启用规则 worker,整个 Bell 监听地址必须为显式回环。规则和 token 文件均在仓库外,规则文件结构如下(示例值不是客户配置):
|
||||
|
||||
```json
|
||||
{"version":1,"rules":[{"tenant_id":1,"site_id":1,"rule_key":"zone-entry","display_name":"区域闯入","event_kind":"zone_entry","minimum_severity":"medium","enabled":true,"effective_from":"2026-08-11T00:00:00Z"}]}
|
||||
```
|
||||
|
||||
```powershell
|
||||
$env:BELL_ALERTS_ENABLED = 'true'
|
||||
$env:BELL_ALERT_RULES_FILE = 'D:\private\bell-alert-rules.json'
|
||||
$env:BELL_ALERT_CONSOLE_ENABLED = 'true'
|
||||
$env:BELL_ALERT_CONSOLE_TOKEN_FILE = 'D:\private\bell-console.token'
|
||||
$env:BELL_ALERT_CONSOLE_TENANT_ID = '1'
|
||||
$env:BELL_ALERT_CONSOLE_SITE_ID = '1'
|
||||
$env:BELL_ALERT_CONSOLE_ACTOR_REF = 'operator:local'
|
||||
go -C Bell run ./cmd/bell-api
|
||||
```
|
||||
|
||||
token 文件去除首尾换行后必须为 32~256 个非空白字符。浏览器访问 `http://127.0.0.1:8081/bell-console/` 后手动输入 token;页面只保存在内存,刷新即丢失。工程 API 冻结在 `docs/contracts/bell-alert-console-v1.openapi.json`,tenant/Site/actor 只来自启动上下文。
|
||||
|
||||
## 验证
|
||||
|
||||
```powershell
|
||||
|
||||
+123
-15
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -19,23 +20,32 @@ import (
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
|
||||
"yovision/bell/contracts"
|
||||
"yovision/bell/internal/alert"
|
||||
"yovision/bell/internal/audit"
|
||||
"yovision/bell/internal/event"
|
||||
"yovision/bell/internal/ingress"
|
||||
"yovision/bell/internal/store"
|
||||
bellweb "yovision/bell/web"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
type configuration struct {
|
||||
address string
|
||||
dsn string
|
||||
keyFile string
|
||||
tlsCert string
|
||||
tlsKey string
|
||||
eventIngressEnabled bool
|
||||
eventKeyFile string
|
||||
forbiddenNamesFile string
|
||||
address string
|
||||
dsn string
|
||||
keyFile string
|
||||
tlsCert string
|
||||
tlsKey string
|
||||
eventIngressEnabled bool
|
||||
eventKeyFile string
|
||||
forbiddenNamesFile string
|
||||
alertsEnabled bool
|
||||
alertRulesFile string
|
||||
alertConsoleEnabled bool
|
||||
alertConsoleTokenFile string
|
||||
alertConsoleTenantID int64
|
||||
alertConsoleSiteID int64
|
||||
alertConsoleActorRef string
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -48,13 +58,16 @@ func main() {
|
||||
|
||||
func loadConfiguration() (configuration, error) {
|
||||
value := configuration{
|
||||
address: envOr("BELL_HTTP_ADDR", "127.0.0.1:8081"),
|
||||
dsn: os.Getenv("BELL_DB_DSN"),
|
||||
keyFile: os.Getenv("BELL_AUDIT_KEYS_FILE"),
|
||||
tlsCert: os.Getenv("BELL_TLS_CERT_FILE"),
|
||||
tlsKey: os.Getenv("BELL_TLS_KEY_FILE"),
|
||||
eventKeyFile: os.Getenv("BELL_EVENT_INGRESS_KEYS_FILE"),
|
||||
forbiddenNamesFile: os.Getenv("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE"),
|
||||
address: envOr("BELL_HTTP_ADDR", "127.0.0.1:8081"),
|
||||
dsn: os.Getenv("BELL_DB_DSN"),
|
||||
keyFile: os.Getenv("BELL_AUDIT_KEYS_FILE"),
|
||||
tlsCert: os.Getenv("BELL_TLS_CERT_FILE"),
|
||||
tlsKey: os.Getenv("BELL_TLS_KEY_FILE"),
|
||||
eventKeyFile: os.Getenv("BELL_EVENT_INGRESS_KEYS_FILE"),
|
||||
forbiddenNamesFile: os.Getenv("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE"),
|
||||
alertRulesFile: os.Getenv("BELL_ALERT_RULES_FILE"),
|
||||
alertConsoleTokenFile: os.Getenv("BELL_ALERT_CONSOLE_TOKEN_FILE"),
|
||||
alertConsoleActorRef: os.Getenv("BELL_ALERT_CONSOLE_ACTOR_REF"),
|
||||
}
|
||||
switch os.Getenv("BELL_EVENT_INGRESS_ENABLED") {
|
||||
case "", "false":
|
||||
@@ -89,6 +102,36 @@ func loadConfiguration() (configuration, error) {
|
||||
return configuration{}, errors.New("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE must be an absolute external path when event ingress is enabled")
|
||||
}
|
||||
}
|
||||
value.alertsEnabled, err = strictBoolEnv("BELL_ALERTS_ENABLED")
|
||||
if err != nil {
|
||||
return configuration{}, err
|
||||
}
|
||||
value.alertConsoleEnabled, err = strictBoolEnv("BELL_ALERT_CONSOLE_ENABLED")
|
||||
if err != nil {
|
||||
return configuration{}, err
|
||||
}
|
||||
if value.alertsEnabled && (value.alertRulesFile == "" || !filepath.IsAbs(value.alertRulesFile)) {
|
||||
return configuration{}, errors.New("BELL_ALERT_RULES_FILE must be an absolute external path when alerts are enabled")
|
||||
}
|
||||
if value.alertConsoleEnabled {
|
||||
if !value.alertsEnabled || !loopback || os.Getenv("BELL_HTTP_ADDR") == "" {
|
||||
return configuration{}, errors.New("Bell alert console requires alerts and an explicit loopback bind")
|
||||
}
|
||||
if value.alertConsoleTokenFile == "" || !filepath.IsAbs(value.alertConsoleTokenFile) {
|
||||
return configuration{}, errors.New("BELL_ALERT_CONSOLE_TOKEN_FILE must be an absolute external path")
|
||||
}
|
||||
value.alertConsoleTenantID, err = positiveEnv("BELL_ALERT_CONSOLE_TENANT_ID")
|
||||
if err != nil {
|
||||
return configuration{}, err
|
||||
}
|
||||
value.alertConsoleSiteID, err = positiveEnv("BELL_ALERT_CONSOLE_SITE_ID")
|
||||
if err != nil {
|
||||
return configuration{}, err
|
||||
}
|
||||
if value.alertConsoleActorRef == "" {
|
||||
return configuration{}, errors.New("BELL_ALERT_CONSOLE_ACTOR_REF is required")
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
@@ -156,6 +199,34 @@ func run(logger *slog.Logger) error {
|
||||
}
|
||||
mux.Handle(ingress.Path, eventHandler)
|
||||
}
|
||||
if cfg.alertsEnabled {
|
||||
if err := repository.AlertReady(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
rules, err := alert.LoadRules(cfg.alertRulesFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := alert.Publish(ctx, repository, rules); err != nil {
|
||||
return err
|
||||
}
|
||||
go alert.RunWorker(ctx, repository, func(workerErr error) {
|
||||
logger.Error("Bell alert worker retrying", "error", workerErr)
|
||||
})
|
||||
}
|
||||
if cfg.alertConsoleEnabled {
|
||||
token, err := loadConsoleToken(cfg.alertConsoleTokenFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
console, err := bellweb.NewHandler(repository, bellweb.Config{
|
||||
Token: token, TenantID: cfg.alertConsoleTenantID, SiteID: cfg.alertConsoleSiteID, ActorRef: cfg.alertConsoleActorRef,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
console.Register(mux)
|
||||
}
|
||||
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writeStatus(writer, http.StatusOK, "ok")
|
||||
})
|
||||
@@ -170,6 +241,12 @@ func run(logger *slog.Logger) error {
|
||||
return
|
||||
}
|
||||
}
|
||||
if cfg.alertsEnabled {
|
||||
if err := repository.AlertReady(request.Context()); err != nil {
|
||||
writeStatus(writer, http.StatusServiceUnavailable, "not_ready")
|
||||
return
|
||||
}
|
||||
}
|
||||
writeStatus(writer, http.StatusOK, "ready")
|
||||
})
|
||||
server := &http.Server{Addr: cfg.address, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}}
|
||||
@@ -225,3 +302,34 @@ func envOr(name, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func strictBoolEnv(name string) (bool, error) {
|
||||
switch os.Getenv(name) {
|
||||
case "", "false":
|
||||
return false, nil
|
||||
case "true":
|
||||
return true, nil
|
||||
default:
|
||||
return false, fmt.Errorf("%s must be true or false", name)
|
||||
}
|
||||
}
|
||||
|
||||
func positiveEnv(name string) (int64, error) {
|
||||
value, err := strconv.ParseInt(os.Getenv(name), 10, 64)
|
||||
if err != nil || value < 1 {
|
||||
return 0, fmt.Errorf("%s must be a positive integer", name)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func loadConsoleToken(path string) (string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil || len(raw) > 4096 {
|
||||
return "", errors.New("read Bell alert console token file")
|
||||
}
|
||||
value := strings.TrimSpace(string(raw))
|
||||
if len(value) < 32 || len(value) > 256 || strings.ContainsAny(value, " \t\r\n") {
|
||||
return "", errors.New("Bell alert console token must contain 32 to 256 non-whitespace characters")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
@@ -18,6 +19,49 @@ func TestConfigurationRequiresDatabaseAndExternalKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertsAreDisabledByDefaultAndConsoleRequiresLoopbackContext(t *testing.T) {
|
||||
t.Setenv("BELL_DB_DSN", "postgres://bell@127.0.0.1/yovision")
|
||||
t.Setenv("BELL_AUDIT_KEYS_FILE", filepath.Join(t.TempDir(), "audit.json"))
|
||||
value, err := loadConfiguration()
|
||||
if err != nil || value.alertsEnabled || value.alertConsoleEnabled {
|
||||
t.Fatalf("default alert configuration: %+v %v", value, err)
|
||||
}
|
||||
t.Setenv("BELL_ALERTS_ENABLED", "true")
|
||||
if _, err := loadConfiguration(); err == nil {
|
||||
t.Fatal("alerts without an external rule file were accepted")
|
||||
}
|
||||
t.Setenv("BELL_ALERT_RULES_FILE", filepath.Join(t.TempDir(), "rules.json"))
|
||||
t.Setenv("BELL_ALERT_CONSOLE_ENABLED", "true")
|
||||
if _, err := loadConfiguration(); err == nil {
|
||||
t.Fatal("console without external context was accepted")
|
||||
}
|
||||
t.Setenv("BELL_ALERT_CONSOLE_TOKEN_FILE", filepath.Join(t.TempDir(), "token"))
|
||||
t.Setenv("BELL_ALERT_CONSOLE_TENANT_ID", "1")
|
||||
t.Setenv("BELL_ALERT_CONSOLE_SITE_ID", "2")
|
||||
t.Setenv("BELL_ALERT_CONSOLE_ACTOR_REF", "operator:local")
|
||||
t.Setenv("BELL_HTTP_ADDR", "127.0.0.1:8081")
|
||||
value, err = loadConfiguration()
|
||||
if err != nil || !value.alertsEnabled || !value.alertConsoleEnabled {
|
||||
t.Fatalf("valid alert console configuration: %+v %v", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConsoleToken(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "token")
|
||||
if err := os.WriteFile(path, []byte("12345678901234567890123456789012\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value, err := loadConsoleToken(path); err != nil || len(value) != 32 {
|
||||
t.Fatalf("valid token: %q %v", value, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("short"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadConsoleToken(path); err == nil {
|
||||
t.Fatal("short token was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationRequiresTLSOutsideLoopback(t *testing.T) {
|
||||
t.Setenv("BELL_DB_DSN", "postgres://bell@127.0.0.1/yovision")
|
||||
t.Setenv("BELL_AUDIT_KEYS_FILE", filepath.Join(t.TempDir(), "keys.json"))
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
// Package alert owns Bell's small, deterministic rule and Alert domain.
|
||||
package alert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultPageSize = 16
|
||||
MaxPageSize = 100
|
||||
MaxRulesFile = 256 << 10
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("alert not found")
|
||||
ErrIdempotencyConflict = errors.New("idempotency key was used for another command")
|
||||
ruleKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{2,63}$`)
|
||||
eventKindPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{2,63}$`)
|
||||
actorRefPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:@/-]*$`)
|
||||
idempotencyKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`)
|
||||
)
|
||||
|
||||
type RuleSpec struct {
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
SiteID *int64 `json:"site_id,omitempty"`
|
||||
RuleKey string `json:"rule_key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
EventKind string `json:"event_kind"`
|
||||
MinimumSeverity string `json:"minimum_severity"`
|
||||
Enabled bool `json:"enabled"`
|
||||
EffectiveFrom time.Time `json:"effective_from"`
|
||||
}
|
||||
|
||||
func (r RuleSpec) Validate() error {
|
||||
if r.TenantID < 1 || (r.SiteID != nil && *r.SiteID < 1) {
|
||||
return errors.New("rule scope must use positive identifiers")
|
||||
}
|
||||
if !ruleKeyPattern.MatchString(r.RuleKey) || !eventKindPattern.MatchString(r.EventKind) {
|
||||
return errors.New("rule key or event kind is invalid")
|
||||
}
|
||||
if strings.TrimSpace(r.DisplayName) == "" || utf8.RuneCountInString(r.DisplayName) > 120 || r.EffectiveFrom.IsZero() {
|
||||
return errors.New("rule name or effective time is invalid")
|
||||
}
|
||||
if _, ok := SeverityRank(r.MinimumSeverity); !ok {
|
||||
return errors.New("rule minimum severity is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r RuleSpec) Digest() ([sha256.Size]byte, error) {
|
||||
value, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return [sha256.Size]byte{}, err
|
||||
}
|
||||
return sha256.Sum256(value), nil
|
||||
}
|
||||
|
||||
type rulesDocument struct {
|
||||
Version int `json:"version"`
|
||||
Rules []RuleSpec `json:"rules"`
|
||||
}
|
||||
|
||||
func LoadRules(path string) ([]RuleSpec, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, errors.New("open Bell alert rules file")
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil || info.Size() > MaxRulesFile {
|
||||
return nil, errors.New("Bell alert rules file is too large")
|
||||
}
|
||||
decoder := json.NewDecoder(file)
|
||||
decoder.DisallowUnknownFields()
|
||||
var document rulesDocument
|
||||
if err := decoder.Decode(&document); err != nil {
|
||||
return nil, errors.New("decode Bell alert rules file")
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("Bell alert rules file contains multiple JSON values")
|
||||
}
|
||||
if document.Version != 1 || len(document.Rules) < 1 || len(document.Rules) > 256 {
|
||||
return nil, errors.New("Bell alert rules file must contain 1 to 256 v1 rules")
|
||||
}
|
||||
seen := make(map[string]bool, len(document.Rules))
|
||||
for _, rule := range document.Rules {
|
||||
if err := rule.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid Bell alert rule %q: %w", rule.RuleKey, err)
|
||||
}
|
||||
key := fmt.Sprintf("%d:%s", rule.TenantID, rule.RuleKey)
|
||||
if seen[key] {
|
||||
return nil, fmt.Errorf("duplicate Bell alert rule %q", rule.RuleKey)
|
||||
}
|
||||
seen[key] = true
|
||||
}
|
||||
return document.Rules, nil
|
||||
}
|
||||
|
||||
func SeverityRank(value string) (int, bool) {
|
||||
for rank, severity := range []string{"low", "medium", "high", "critical"} {
|
||||
if value == severity {
|
||||
return rank, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func ValidActorRef(value string) bool {
|
||||
return len(value) <= 80 && actorRefPattern.MatchString(value)
|
||||
}
|
||||
|
||||
func ValidIdempotencyKey(value string) bool {
|
||||
return len(value) >= 8 && len(value) <= 128 && idempotencyKeyPattern.MatchString(value)
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
ID string `json:"id"`
|
||||
Severity string `json:"severity"`
|
||||
Title string `json:"title"`
|
||||
State string `json:"state"`
|
||||
RuleKey string `json:"rule_key"`
|
||||
RuleVersion int `json:"rule_version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type EventRef struct {
|
||||
ID string `json:"id"`
|
||||
DeviceID int64 `json:"device_id"`
|
||||
Kind string `json:"kind"`
|
||||
Severity string `json:"severity"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
|
||||
type Transition struct {
|
||||
Sequence int `json:"sequence"`
|
||||
FromState *string `json:"from_state"`
|
||||
ToState string `json:"to_state"`
|
||||
ActorRef string `json:"actor_ref"`
|
||||
Note *string `json:"note"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
Summary
|
||||
Events []EventRef `json:"events"`
|
||||
Transitions []Transition `json:"transitions"`
|
||||
EvidenceStatus string `json:"evidence_status"`
|
||||
DeliveryStatus string `json:"delivery_status"`
|
||||
}
|
||||
|
||||
type Page struct {
|
||||
Items []Summary `json:"items"`
|
||||
NextCursor *string `json:"next_cursor"`
|
||||
}
|
||||
|
||||
type CommandResponse struct {
|
||||
AlertID string `json:"alert_id"`
|
||||
State string `json:"state"`
|
||||
ActorRef string `json:"actor_ref"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
AlertReady(context.Context) error
|
||||
PublishRule(context.Context, RuleSpec) (bool, error)
|
||||
EvaluateNext(context.Context) (bool, error)
|
||||
ListAlerts(context.Context, int64, int64, string, int, string) (Page, error)
|
||||
GetAlert(context.Context, int64, int64, string) (Detail, error)
|
||||
Command(context.Context, int64, int64, string, string, string, string, *string) (int, CommandResponse, error)
|
||||
}
|
||||
|
||||
func Publish(ctx context.Context, repository Repository, rules []RuleSpec) error {
|
||||
for _, rule := range rules {
|
||||
if _, err := repository.PublishRule(ctx, rule); err != nil {
|
||||
return fmt.Errorf("publish Bell alert rule %q: %w", rule.RuleKey, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func RunWorker(ctx context.Context, repository Repository, onError func(error)) {
|
||||
idle := time.NewTicker(250 * time.Millisecond)
|
||||
defer idle.Stop()
|
||||
for {
|
||||
worked, err := repository.EvaluateNext(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if onError != nil {
|
||||
onError(err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
continue
|
||||
}
|
||||
if worked {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-idle.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadRulesRejectsUnknownAndDuplicateValues(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "rules.json")
|
||||
valid := `{"version":1,"rules":[{"tenant_id":1,"site_id":2,"rule_key":"zone-entry","display_name":"区域闯入","event_kind":"zone_entry","minimum_severity":"medium","enabled":true,"effective_from":"2026-08-11T00:00:00Z"}]}`
|
||||
if err := os.WriteFile(path, []byte(valid), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rules, err := LoadRules(path)
|
||||
if err != nil || len(rules) != 1 {
|
||||
t.Fatalf("load valid rules: %v %#v", err, rules)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(`{"version":1,"unknown":true,"rules":[]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadRules(path); err == nil {
|
||||
t.Fatal("unknown rule document property was accepted")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(valid+` trailing`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadRules(path); err == nil {
|
||||
t.Fatal("trailing rule file data was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeverityRankIsStable(t *testing.T) {
|
||||
for index, value := range []string{"low", "medium", "high", "critical"} {
|
||||
rank, ok := SeverityRank(value)
|
||||
if !ok || rank != index {
|
||||
t.Fatalf("rank %q: %d %v", value, rank, ok)
|
||||
}
|
||||
}
|
||||
if _, ok := SeverityRank("urgent"); ok {
|
||||
t.Fatal("unknown severity was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
|
||||
"yovision/bell/internal/alert"
|
||||
)
|
||||
|
||||
func (p *Postgres) AlertReady(ctx context.Context) error {
|
||||
var version int64
|
||||
if err := p.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version),0) FROM bell.schema_migrations`).Scan(&version); err != nil || version < 6 {
|
||||
return errors.New("postgres Bell schema migration v6 is required for alerts")
|
||||
}
|
||||
for _, table := range []string{"rule_versions", "event_rule_sweeps", "rule_evaluations", "alerts", "alert_events", "alert_transitions", "alert_command_receipts"} {
|
||||
var selectAllowed, insertAllowed, updateAllowed, deleteAllowed, truncateAllowed bool
|
||||
if err := p.db.QueryRowContext(ctx, `SELECT
|
||||
has_table_privilege(current_user,$1,'SELECT'), has_table_privilege(current_user,$1,'INSERT'),
|
||||
has_table_privilege(current_user,$1,'UPDATE'), has_table_privilege(current_user,$1,'DELETE'),
|
||||
has_table_privilege(current_user,$1,'TRUNCATE')`, "bell."+table).Scan(
|
||||
&selectAllowed, &insertAllowed, &updateAllowed, &deleteAllowed, &truncateAllowed,
|
||||
); err != nil || !selectAllowed || !insertAllowed || updateAllowed || deleteAllowed || truncateAllowed {
|
||||
return fmt.Errorf("Bell runtime alert privileges violate append-only boundary for %s", table)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Postgres) PublishRule(ctx context.Context, rule alert.RuleSpec) (bool, error) {
|
||||
if err := rule.Validate(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
digest, err := rule.Digest()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
tx, err := p.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, errors.New("begin Bell rule publish")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, fmt.Sprintf("rule:%d:%s", rule.TenantID, rule.RuleKey)); err != nil {
|
||||
return false, errors.New("lock Bell rule key")
|
||||
}
|
||||
var existing string
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM bell.rule_versions WHERE tenant_id=$1 AND rule_key=$2 AND config_hash=$3`, rule.TenantID, rule.RuleKey, digest[:]).Scan(&existing)
|
||||
if err == nil {
|
||||
return false, tx.Commit()
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return false, errors.New("read Bell rule version")
|
||||
}
|
||||
var version int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(version),0)+1 FROM bell.rule_versions WHERE tenant_id=$1 AND rule_key=$2`, rule.TenantID, rule.RuleKey).Scan(&version); err != nil {
|
||||
return false, errors.New("allocate Bell rule version")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.rule_versions(
|
||||
id,tenant_id,site_id,rule_key,version,display_name,event_kind,minimum_severity,enabled,effective_from,config_hash
|
||||
) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, newPrefixedID("ruv_"), rule.TenantID, rule.SiteID,
|
||||
rule.RuleKey, version, rule.DisplayName, rule.EventKind, rule.MinimumSeverity, rule.Enabled, rule.EffectiveFrom, digest[:]); err != nil {
|
||||
return false, fmt.Errorf("insert Bell rule version: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, errors.New("commit Bell rule version")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) EvaluateNext(ctx context.Context) (bool, error) {
|
||||
tx, err := p.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, errors.New("begin Bell rule evaluation")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var eventID, kind, severity string
|
||||
var tenantID, siteID, deviceID int64
|
||||
var occurredAt time.Time
|
||||
err = tx.QueryRowContext(ctx, `SELECT e.id,e.tenant_id,e.site_id,e.device_id,e.kind,e.severity,e.occurred_at
|
||||
FROM bell.events e WHERE NOT EXISTS(SELECT 1 FROM bell.event_rule_sweeps s WHERE s.event_id=e.id)
|
||||
ORDER BY e.created_at,e.id LIMIT 1`).Scan(&eventID, &tenantID, &siteID, &deviceID, &kind, &severity, &occurredAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, errors.New("select Bell event for rules")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "event-rule:"+eventID); err != nil {
|
||||
return false, errors.New("lock Bell event evaluation")
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO bell.event_rule_sweeps(event_id) VALUES($1) ON CONFLICT DO NOTHING`, eventID)
|
||||
if err != nil {
|
||||
return false, errors.New("claim Bell event evaluation")
|
||||
}
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected == 0 {
|
||||
return false, tx.Commit()
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id,rule_key,version,display_name,event_kind,minimum_severity,enabled
|
||||
FROM (SELECT DISTINCT ON (rule_key) id,rule_key,version,display_name,event_kind,minimum_severity,enabled,effective_from
|
||||
FROM bell.rule_versions WHERE tenant_id=$1 AND effective_from <= $3 AND (site_id IS NULL OR site_id=$2)
|
||||
ORDER BY rule_key,version DESC,effective_from DESC) latest ORDER BY rule_key`, tenantID, siteID, occurredAt)
|
||||
if err != nil {
|
||||
return false, errors.New("read effective Bell rules")
|
||||
}
|
||||
defer rows.Close()
|
||||
type effectiveRule struct {
|
||||
id, key, name, kind, minimum string
|
||||
version int
|
||||
enabled bool
|
||||
}
|
||||
rules := make([]effectiveRule, 0)
|
||||
for rows.Next() {
|
||||
var rule effectiveRule
|
||||
if err := rows.Scan(&rule.id, &rule.key, &rule.version, &rule.name, &rule.kind, &rule.minimum, &rule.enabled); err != nil {
|
||||
return false, errors.New("scan effective Bell rule")
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return false, errors.New("iterate effective Bell rules")
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return false, errors.New("close effective Bell rules")
|
||||
}
|
||||
eventRank, _ := alert.SeverityRank(severity)
|
||||
for _, rule := range rules {
|
||||
matched, reason := true, "matched"
|
||||
minimumRank, _ := alert.SeverityRank(rule.minimum)
|
||||
switch {
|
||||
case !rule.enabled:
|
||||
matched, reason = false, "disabled"
|
||||
case rule.kind != kind:
|
||||
matched, reason = false, "event_kind"
|
||||
case eventRank < minimumRank:
|
||||
matched, reason = false, "severity"
|
||||
}
|
||||
value := "no_match"
|
||||
if matched {
|
||||
value = "matched"
|
||||
}
|
||||
evaluationID := newPrefixedID("eva_")
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.rule_evaluations(id,event_id,rule_version_id,result,reason) VALUES($1,$2,$3,$4,$5)`, evaluationID, eventID, rule.id, value, reason); err != nil {
|
||||
return false, fmt.Errorf("append Bell rule evaluation: %w", err)
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
alertID := newPrefixedID("alt_")
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.alerts(id,tenant_id,site_id,rule_evaluation_id,rule_version_id,severity,title) VALUES($1,$2,$3,$4,$5,$6,$7)`, alertID, tenantID, siteID, evaluationID, rule.id, severity, rule.name); err != nil {
|
||||
return false, errors.New("create Bell alert")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.alert_events(alert_id,event_id) VALUES($1,$2)`, alertID, eventID); err != nil {
|
||||
return false, errors.New("link Bell alert event")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.alert_transitions(id,alert_id,sequence,from_state,to_state,actor_ref) VALUES($1,$2,1,NULL,'open','system:rule-worker')`, newPrefixedID("trn_"), alertID); err != nil {
|
||||
return false, errors.New("open Bell alert")
|
||||
}
|
||||
_ = deviceID
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, errors.New("commit Bell rule evaluation")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) ListAlerts(ctx context.Context, tenantID, siteID int64, state string, limit int, cursor string) (alert.Page, error) {
|
||||
if limit < 1 || limit > alert.MaxPageSize {
|
||||
return alert.Page{}, errors.New("invalid alert page size")
|
||||
}
|
||||
if state != "" && state != "open" && state != "acknowledged" && state != "closed" {
|
||||
return alert.Page{}, errors.New("invalid alert state")
|
||||
}
|
||||
rows, err := p.db.QueryContext(ctx, `SELECT a.id,a.severity,a.title,t.to_state,r.rule_key,r.version,a.created_at
|
||||
FROM bell.alerts a JOIN bell.rule_versions r ON r.id=a.rule_version_id
|
||||
JOIN LATERAL(SELECT to_state FROM bell.alert_transitions WHERE alert_id=a.id ORDER BY sequence DESC LIMIT 1)t ON true
|
||||
WHERE a.tenant_id=$1 AND a.site_id=$2 AND ($3='' OR t.to_state=$3)
|
||||
AND ($4='' OR (a.created_at,a.id) < (SELECT c.created_at,c.id FROM bell.alerts c WHERE c.id=$4 AND c.tenant_id=$1 AND c.site_id=$2))
|
||||
ORDER BY a.created_at DESC,a.id DESC LIMIT $5`, tenantID, siteID, state, cursor, limit+1)
|
||||
if err != nil {
|
||||
return alert.Page{}, errors.New("list Bell alerts")
|
||||
}
|
||||
defer rows.Close()
|
||||
page := alert.Page{Items: make([]alert.Summary, 0, limit)}
|
||||
for rows.Next() {
|
||||
var item alert.Summary
|
||||
if err := rows.Scan(&item.ID, &item.Severity, &item.Title, &item.State, &item.RuleKey, &item.RuleVersion, &item.CreatedAt); err != nil {
|
||||
return alert.Page{}, errors.New("scan Bell alert list")
|
||||
}
|
||||
page.Items = append(page.Items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return alert.Page{}, errors.New("iterate Bell alert list")
|
||||
}
|
||||
if len(page.Items) > limit {
|
||||
cursor := page.Items[limit-1].ID
|
||||
page.NextCursor = &cursor
|
||||
page.Items = page.Items[:limit]
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) GetAlert(ctx context.Context, tenantID, siteID int64, alertID string) (alert.Detail, error) {
|
||||
var detail alert.Detail
|
||||
err := p.db.QueryRowContext(ctx, `SELECT a.id,a.severity,a.title,t.to_state,r.rule_key,r.version,a.created_at
|
||||
FROM bell.alerts a JOIN bell.rule_versions r ON r.id=a.rule_version_id
|
||||
JOIN LATERAL(SELECT to_state FROM bell.alert_transitions WHERE alert_id=a.id ORDER BY sequence DESC LIMIT 1)t ON true
|
||||
WHERE a.tenant_id=$1 AND a.site_id=$2 AND a.id=$3`, tenantID, siteID, alertID).Scan(
|
||||
&detail.ID, &detail.Severity, &detail.Title, &detail.State, &detail.RuleKey, &detail.RuleVersion, &detail.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return alert.Detail{}, alert.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return alert.Detail{}, errors.New("read Bell alert")
|
||||
}
|
||||
detail.EvidenceStatus = "not_enabled"
|
||||
detail.DeliveryStatus = "not_enabled"
|
||||
detail.Events = []alert.EventRef{}
|
||||
detail.Transitions = []alert.Transition{}
|
||||
rows, err := p.db.QueryContext(ctx, `SELECT e.id,e.device_id,e.kind,e.severity,e.occurred_at 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`, alertID)
|
||||
if err != nil {
|
||||
return alert.Detail{}, errors.New("read Bell alert events")
|
||||
}
|
||||
for rows.Next() {
|
||||
var value alert.EventRef
|
||||
if err := rows.Scan(&value.ID, &value.DeviceID, &value.Kind, &value.Severity, &value.OccurredAt); err != nil {
|
||||
rows.Close()
|
||||
return alert.Detail{}, errors.New("scan Bell alert event")
|
||||
}
|
||||
detail.Events = append(detail.Events, value)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return alert.Detail{}, errors.New("close Bell alert events")
|
||||
}
|
||||
rows, err = p.db.QueryContext(ctx, `SELECT sequence,from_state,to_state,actor_ref,note,occurred_at FROM bell.alert_transitions WHERE alert_id=$1 ORDER BY sequence`, alertID)
|
||||
if err != nil {
|
||||
return alert.Detail{}, errors.New("read Bell alert transitions")
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var value alert.Transition
|
||||
if err := rows.Scan(&value.Sequence, &value.FromState, &value.ToState, &value.ActorRef, &value.Note, &value.OccurredAt); err != nil {
|
||||
return alert.Detail{}, errors.New("scan Bell alert transition")
|
||||
}
|
||||
detail.Transitions = append(detail.Transitions, value)
|
||||
}
|
||||
return detail, rows.Err()
|
||||
}
|
||||
|
||||
func (p *Postgres) Command(ctx context.Context, tenantID, siteID int64, alertID, command, key, actorRef string, note *string) (int, alert.CommandResponse, error) {
|
||||
if (command != "ack" && command != "close") || tenantID < 1 || siteID < 1 || !alert.ValidIdempotencyKey(key) || !alert.ValidActorRef(actorRef) {
|
||||
return 0, alert.CommandResponse{}, errors.New("invalid Bell alert command")
|
||||
}
|
||||
if note != nil && len([]rune(*note)) > 500 {
|
||||
return 0, alert.CommandResponse{}, errors.New("Bell alert command note is too long")
|
||||
}
|
||||
digest := sha256.Sum256([]byte(strings.Join([]string{alertID, command, actorRef, valueOrEmpty(note)}, "\x00")))
|
||||
tx, err := p.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, alert.CommandResponse{}, errors.New("begin Bell alert command")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, fmt.Sprintf("alert-key:%d:%s", tenantID, key)); err != nil {
|
||||
return 0, alert.CommandResponse{}, errors.New("lock Bell alert command key")
|
||||
}
|
||||
var storedHash, storedBody []byte
|
||||
var storedStatus int
|
||||
err = tx.QueryRowContext(ctx, `SELECT command_hash,response_status,response_body::text FROM bell.alert_command_receipts WHERE tenant_id=$1 AND idempotency_key=$2`, tenantID, key).Scan(&storedHash, &storedStatus, &storedBody)
|
||||
if err == nil {
|
||||
if !bytes.Equal(storedHash, digest[:]) {
|
||||
return 0, alert.CommandResponse{}, alert.ErrIdempotencyConflict
|
||||
}
|
||||
var response alert.CommandResponse
|
||||
if err := json.Unmarshal(storedBody, &response); err != nil {
|
||||
return 0, alert.CommandResponse{}, errors.New("decode Bell alert command receipt")
|
||||
}
|
||||
return storedStatus, response, tx.Commit()
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, alert.CommandResponse{}, errors.New("read Bell alert command receipt")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "alert:"+alertID); err != nil {
|
||||
return 0, alert.CommandResponse{}, errors.New("lock Bell alert")
|
||||
}
|
||||
var current, currentActor string
|
||||
var currentTime time.Time
|
||||
var sequence int
|
||||
err = tx.QueryRowContext(ctx, `SELECT t.to_state,t.actor_ref,t.occurred_at,t.sequence FROM bell.alerts a
|
||||
JOIN LATERAL(SELECT to_state,actor_ref,occurred_at,sequence FROM bell.alert_transitions WHERE alert_id=a.id ORDER BY sequence DESC LIMIT 1)t ON true
|
||||
WHERE a.id=$1 AND a.tenant_id=$2 AND a.site_id=$3`, alertID, tenantID, siteID).Scan(¤t, ¤tActor, ¤tTime, &sequence)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, alert.CommandResponse{}, alert.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return 0, alert.CommandResponse{}, errors.New("read Bell alert state")
|
||||
}
|
||||
status := 200
|
||||
response := alert.CommandResponse{AlertID: alertID, State: current, ActorRef: currentActor, OccurredAt: currentTime}
|
||||
allowed := (command == "ack" && current == "open") || (command == "close" && current == "acknowledged")
|
||||
if !allowed {
|
||||
status = 409
|
||||
switch {
|
||||
case command == "ack" && (current == "acknowledged" || current == "closed"):
|
||||
response.Code = "already_acknowledged"
|
||||
_ = tx.QueryRowContext(ctx, `SELECT actor_ref,occurred_at FROM bell.alert_transitions WHERE alert_id=$1 AND to_state='acknowledged' ORDER BY sequence LIMIT 1`, alertID).Scan(&response.ActorRef, &response.OccurredAt)
|
||||
case command == "close" && current == "open":
|
||||
response.Code = "acknowledgement_required"
|
||||
case command == "close" && current == "closed":
|
||||
response.Code = "already_closed"
|
||||
default:
|
||||
response.Code = "invalid_state"
|
||||
}
|
||||
} else {
|
||||
next := "acknowledged"
|
||||
if command == "close" {
|
||||
next = "closed"
|
||||
}
|
||||
response.State = next
|
||||
response.ActorRef = ""
|
||||
response.OccurredAt = time.Time{}
|
||||
if err := tx.QueryRowContext(ctx, `INSERT INTO bell.alert_transitions(id,alert_id,sequence,from_state,to_state,actor_ref,note)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING actor_ref,occurred_at`,
|
||||
newPrefixedID("trn_"), alertID, sequence+1, current, next, actorRef, note).Scan(&response.ActorRef, &response.OccurredAt); err != nil {
|
||||
return 0, alert.CommandResponse{}, errors.New("append Bell alert transition")
|
||||
}
|
||||
}
|
||||
body, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return 0, alert.CommandResponse{}, errors.New("encode Bell alert command response")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.alert_command_receipts(tenant_id,idempotency_key,command_hash,response_status,response_body) VALUES($1,$2,$3,$4,$5::jsonb)`, tenantID, key, digest[:], status, body); err != nil {
|
||||
return 0, alert.CommandResponse{}, errors.New("append Bell alert command receipt")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, alert.CommandResponse{}, errors.New("commit Bell alert command")
|
||||
}
|
||||
return status, response, nil
|
||||
}
|
||||
|
||||
func newPrefixedID(prefix string) string { return prefix + ulid.Make().String() }
|
||||
|
||||
func valueOrEmpty(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
|
||||
"yovision/bell/internal/alert"
|
||||
)
|
||||
|
||||
func TestPostgresAlertRuleEvaluationAndFirstAckWins(t *testing.T) {
|
||||
dsn := os.Getenv("YOVISION_TEST_BELL_POSTGRES_DSN")
|
||||
adminDSN := os.Getenv("YOVISION_TEST_POSTGRES_ADMIN_DSN")
|
||||
if dsn == "" || adminDSN == "" {
|
||||
t.Skip("Bell runtime and admin PostgreSQL DSNs are not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
admin, err := sql.Open("pgx", adminDSN)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer admin.Close()
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
repository, err := OpenPostgres(ctx, db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.AlertReady(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
siteID := int64(902)
|
||||
rule := alert.RuleSpec{TenantID: 901, SiteID: &siteID, RuleKey: "zone-entry", DisplayName: "区域闯入", EventKind: "zone_entry", MinimumSeverity: "medium", Enabled: true, EffectiveFrom: time.Now().Add(-time.Hour).UTC()}
|
||||
created, err := repository.PublishRule(ctx, rule)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("publish rule: created=%v err=%v", created, err)
|
||||
}
|
||||
created, err = repository.PublishRule(ctx, rule)
|
||||
if err != nil || created {
|
||||
t.Fatalf("idempotent rule publish: created=%v err=%v", created, err)
|
||||
}
|
||||
eventID := "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2Q"
|
||||
payload := fmt.Sprintf(`{"id":%q,"tenant_id":901,"site_id":902,"device_id":903,"source_event_id":"T020-EVENT-1","kind":"zone_entry","severity":"high"}`, eventID)
|
||||
payloadHash := sha256.Sum256([]byte(payload))
|
||||
if _, err := admin.ExecContext(ctx, `INSERT INTO bell.events(id,tenant_id,site_id,device_id,source_event_id,kind,severity,occurred_at,detected_at,payload_hash,payload)
|
||||
VALUES($1,901,902,903,'T020-EVENT-1','zone_entry','high',clock_timestamp(),clock_timestamp(),$3,$2::jsonb)`, eventID, payload, payloadHash[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
noMatchID := "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2R"
|
||||
noMatchPayload := fmt.Sprintf(`{"id":%q,"tenant_id":901,"site_id":902,"device_id":903,"source_event_id":"T020-EVENT-2","kind":"crowd","severity":"high"}`, noMatchID)
|
||||
noMatchHash := sha256.Sum256([]byte(noMatchPayload))
|
||||
if _, err := admin.ExecContext(ctx, `INSERT INTO bell.events(id,tenant_id,site_id,device_id,source_event_id,kind,severity,occurred_at,detected_at,payload_hash,payload)
|
||||
VALUES($1,901,902,903,'T020-EVENT-2','crowd','high',clock_timestamp(),clock_timestamp(),$3,$2::jsonb)`, noMatchID, noMatchPayload, noMatchHash[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var evaluators sync.WaitGroup
|
||||
errorsSeen := make(chan error, 8)
|
||||
for index := 0; index < 8; index++ {
|
||||
evaluators.Add(1)
|
||||
go func() {
|
||||
defer evaluators.Done()
|
||||
for attempts := 0; attempts < 32; attempts++ {
|
||||
worked, err := repository.EvaluateNext(ctx)
|
||||
if err != nil {
|
||||
errorsSeen <- err
|
||||
return
|
||||
}
|
||||
if !worked {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
evaluators.Wait()
|
||||
close(errorsSeen)
|
||||
for err := range errorsSeen {
|
||||
t.Error(err)
|
||||
}
|
||||
var alertID string
|
||||
var alertCount, evaluationCount, sweepCount int
|
||||
if err := db.QueryRowContext(ctx, `SELECT
|
||||
(SELECT count(*) FROM bell.alerts WHERE tenant_id=901 AND site_id=902),
|
||||
(SELECT count(*) FROM bell.rule_evaluations e JOIN bell.events v ON v.id=e.event_id WHERE v.tenant_id=901),
|
||||
(SELECT count(*) FROM bell.event_rule_sweeps s JOIN bell.events v ON v.id=s.event_id WHERE v.tenant_id=901),
|
||||
(SELECT id FROM bell.alerts WHERE tenant_id=901 AND site_id=902)`).Scan(&alertCount, &evaluationCount, &sweepCount, &alertID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if alertCount != 1 || evaluationCount != 2 || sweepCount != 2 {
|
||||
t.Fatalf("evaluation did not converge: alerts=%d evaluations=%d sweeps=%d", alertCount, evaluationCount, sweepCount)
|
||||
}
|
||||
preStatus, preResponse, err := repository.Command(ctx, 901, 902, alertID, "close", "close-before-ack", "operator:closer", nil)
|
||||
if err != nil || preStatus != 409 || preResponse.Code != "acknowledgement_required" || preResponse.State != "open" {
|
||||
t.Fatalf("close-before-ack: status=%d response=%+v err=%v", preStatus, preResponse, err)
|
||||
}
|
||||
|
||||
type outcome struct {
|
||||
status int
|
||||
response alert.CommandResponse
|
||||
err error
|
||||
}
|
||||
results := make(chan outcome, 8)
|
||||
var acknowledgers sync.WaitGroup
|
||||
for index := 0; index < 8; index++ {
|
||||
acknowledgers.Add(1)
|
||||
go func(index int) {
|
||||
defer acknowledgers.Done()
|
||||
status, response, err := repository.Command(ctx, 901, 902, alertID, "ack", fmt.Sprintf("ack-key-%02d", index), fmt.Sprintf("operator:%d", index), nil)
|
||||
results <- outcome{status, response, err}
|
||||
}(index)
|
||||
}
|
||||
acknowledgers.Wait()
|
||||
close(results)
|
||||
winners := 0
|
||||
var winner alert.CommandResponse
|
||||
losers := make([]alert.CommandResponse, 0, 7)
|
||||
for result := range results {
|
||||
if result.err != nil {
|
||||
t.Fatal(result.err)
|
||||
}
|
||||
if result.status == 200 {
|
||||
winners++
|
||||
winner = result.response
|
||||
} else if result.status == 409 {
|
||||
losers = append(losers, result.response)
|
||||
}
|
||||
}
|
||||
if winners != 1 || len(losers) != 7 {
|
||||
t.Fatalf("ack winners=%d losers=%d", winners, len(losers))
|
||||
}
|
||||
for _, loser := range losers {
|
||||
if loser.ActorRef != winner.ActorRef || !loser.OccurredAt.Equal(winner.OccurredAt) || loser.Code != "already_acknowledged" {
|
||||
t.Fatalf("late ack did not expose first winner: winner=%+v loser=%+v", winner, loser)
|
||||
}
|
||||
}
|
||||
status, replayed, err := repository.Command(ctx, 901, 902, alertID, "ack", "ack-replay-key", "operator:replay", nil)
|
||||
if err != nil || status != 409 || replayed.ActorRef != winner.ActorRef {
|
||||
t.Fatalf("first replay receipt: status=%d response=%+v err=%v", status, replayed, err)
|
||||
}
|
||||
status2, replayed2, err := repository.Command(ctx, 901, 902, alertID, "ack", "ack-replay-key", "operator:replay", nil)
|
||||
if err != nil || status2 != status || replayed2 != replayed {
|
||||
t.Fatalf("stable receipt replay: status=%d response=%+v err=%v", status2, replayed2, err)
|
||||
}
|
||||
note := "different"
|
||||
if _, _, err := repository.Command(ctx, 901, 902, alertID, "ack", "ack-replay-key", "operator:replay", ¬e); !errors.Is(err, alert.ErrIdempotencyConflict) {
|
||||
t.Fatalf("expected idempotency conflict, got %v", err)
|
||||
}
|
||||
closeStatus, closeResponse, err := repository.Command(ctx, 901, 902, alertID, "close", "close-after-ack", "operator:closer", nil)
|
||||
if err != nil || closeStatus != 200 || closeResponse.State != "closed" || closeResponse.ActorRef != "operator:closer" {
|
||||
t.Fatalf("close-after-ack: status=%d response=%+v err=%v", closeStatus, closeResponse, err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `UPDATE bell.alert_transitions SET actor_ref=actor_ref WHERE alert_id=$1`, alertID); err == nil {
|
||||
t.Fatal("runtime updated immutable alert transition")
|
||||
}
|
||||
reopened, err := OpenPostgres(ctx, db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail, err := reopened.GetAlert(ctx, 901, 902, alertID)
|
||||
if err != nil || detail.State != "closed" || len(detail.Transitions) != 3 || detail.EvidenceStatus != "not_enabled" {
|
||||
t.Fatalf("restart detail: %+v %v", detail, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use strict";
|
||||
let token="",state="",cursor=null,selected=null,busy=false;
|
||||
const $=id=>document.getElementById(id);
|
||||
const escTime=value=>new Intl.DateTimeFormat("zh-CN",{dateStyle:"medium",timeStyle:"medium",hour12:false}).format(new Date(value));
|
||||
function announce(message,error=false){const box=$(error?"errors":"status");box.textContent=message;box.hidden=false;window.setTimeout(()=>box.hidden=true,5000)}
|
||||
async function api(path,options={}){const response=await fetch(`/bell-console/api/v1${path}`,{...options,headers:{"Authorization":`Bearer ${token}`,...options.headers}});let body={};try{body=await response.json()}catch{}if(!response.ok){const error=new Error(body.message||`请求失败(${response.status})`);error.status=response.status;error.body=body;throw error}return body}
|
||||
function setBusy(value){busy=value;$("alerts").setAttribute("aria-busy",String(value));for(const id of ["refresh","more","ack","close"])$(id).disabled=value}
|
||||
function row(item){const button=document.createElement("button");button.type="button";button.className="alert-row"+(selected===item.id?" selected":"");button.dataset.id=item.id;const left=document.createElement("span"),right=document.createElement("span"),title=document.createElement("strong"),meta=document.createElement("small"),badge=document.createElement("span");title.textContent=item.title;meta.textContent=`${item.rule_key} · v${item.rule_version} · ${escTime(item.created_at)}`;badge.className="state";badge.dataset.state=item.state;badge.textContent={open:"待确认",acknowledged:"处理中",closed:"已关闭"}[item.state]||item.state;left.append(title,meta);right.append(badge);button.append(left,right);button.addEventListener("click",()=>loadDetail(item.id));return button}
|
||||
async function loadAlerts(append=false){if(busy)return;setBusy(true);if(!append){cursor=null;$("alerts").replaceChildren(...[1,2,3].map(()=>Object.assign(document.createElement("div"),{className:"skeleton"})))}try{const query=new URLSearchParams({limit:"16"});if(state)query.set("state",state);if(append&&cursor)query.set("cursor",cursor);const data=await api(`/alerts?${query}`);if(!append)$("alerts").replaceChildren();for(const item of data.items)$("alerts").append(row(item));if(!append&&!data.items.length){const empty=document.createElement("p");empty.className="offline";empty.textContent="当前筛选条件下没有预警。";$("alerts").append(empty)}cursor=data.next_cursor;$("more").hidden=!cursor;$("connectionText").textContent="已连接 · 显示真实状态";document.querySelector(".connection").classList.add("connected")}catch(error){if(!append){const retry=document.createElement("button");retry.className="secondary full";retry.textContent="加载失败,重试";retry.addEventListener("click",()=>loadAlerts());$("alerts").replaceChildren(retry)}announce(error.message,true);if(error.status===401)disconnect()}finally{setBusy(false)}}
|
||||
function fact(label,value){const box=document.createElement("div"),dt=document.createElement("dt"),dd=document.createElement("dd");dt.textContent=label;dd.textContent=value;box.append(dt,dd);return box}
|
||||
async function loadDetail(id){if(busy)return;selected=id;document.querySelectorAll(".alert-row").forEach(el=>el.classList.toggle("selected",el.dataset.id===id));setBusy(true);try{const data=await api(`/alerts/${encodeURIComponent(id)}`);$("detailEmpty").hidden=true;$("detailContent").hidden=false;$("detailSeverity").textContent=data.severity.toUpperCase();$("detailName").textContent=data.title;$("detailId").textContent=data.id;$("detailState").textContent={open:"待确认",acknowledged:"处理中",closed:"已关闭"}[data.state];$("detailState").dataset.state=data.state;$("facts").replaceChildren(fact("规则",data.rule_key),fact("规则版本",`v${data.rule_version}`),fact("创建时间",escTime(data.created_at)));$("events").replaceChildren(...data.events.map(event=>{const box=document.createElement("div");box.className="event";const a=document.createElement("span"),b=document.createElement("span");a.textContent=`${event.kind} · 设备 ${event.device_id}`;b.textContent=`${event.severity} · ${escTime(event.occurred_at)}`;box.append(a,b);return box}));$("timeline").replaceChildren(...data.transitions.map(item=>{const li=document.createElement("li"),strong=document.createElement("strong"),small=document.createElement("small");strong.textContent=`${item.to_state} · ${item.actor_ref}`;small.textContent=escTime(item.occurred_at)+(item.note?` · ${item.note}`:"");li.append(strong,small);return li}));$("ack").hidden=data.state!=="open";$("close").hidden=data.state!=="acknowledged"}catch(error){announce(error.message,true)}finally{setBusy(false)}}
|
||||
async function command(name){if(!selected||busy)return;setBusy(true);const button=$(name);button.textContent=name==="ack"?"确认中…":"关闭中…";try{const result=await api(`/alerts/${selected}:${name}`,{method:"POST",headers:{"Content-Type":"application/json","Idempotency-Key":crypto.randomUUID()},body:JSON.stringify({note:$("note").value||null})});announce(name==="ack"?`已由 ${result.actor_ref} 接手`:"预警已关闭");$("note").value="";setBusy(false);await loadDetail(selected);await loadAlerts()}catch(error){if(error.status===409&&error.body?.code==="already_acknowledged")announce("该预警已被其他值班员确认,正在刷新实际处置人",true);else announce(error.message,true);setBusy(false);await loadDetail(selected)}finally{button.textContent=name==="ack"?"确认接手":"关闭预警";setBusy(false)}}
|
||||
function disconnect(){$("workspace").hidden=true;$("authPanel").hidden=false;token="";$("token").value="";$("connectionText").textContent="等待授权";document.querySelector(".connection").classList.remove("connected")}
|
||||
$("connect").addEventListener("click",()=>{const value=$("token").value;if(value.length<32){announce("令牌长度不足,请检查外部 token 文件",true);return}token=value;$("token").value="";$("authPanel").hidden=true;$("workspace").hidden=false;loadAlerts()});
|
||||
$("token").addEventListener("keydown",event=>{if(event.key==="Enter")$("connect").click()});
|
||||
$("refresh").addEventListener("click",()=>loadAlerts());$("more").addEventListener("click",()=>loadAlerts(true));$("ack").addEventListener("click",()=>command("ack"));$("close").addEventListener("click",()=>command("close"));
|
||||
document.querySelectorAll(".filter").forEach(button=>button.addEventListener("click",()=>{state=button.dataset.state;document.querySelectorAll(".filter").forEach(other=>{other.classList.toggle("active",other===button);other.setAttribute("aria-pressed",String(other===button))});loadAlerts()}));
|
||||
@@ -0,0 +1,54 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>YoVision Bell 值班台</title>
|
||||
<link rel="stylesheet" href="/bell-console/assets/style.css">
|
||||
<script defer src="/bell-console/assets/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip" href="#alerts">跳到预警列表</a>
|
||||
<header>
|
||||
<div><p class="eyebrow">YOVISION · BELL</p><h1>预警处置值班台</h1></div>
|
||||
<div class="connection"><span class="dot" aria-hidden="true"></span><span id="connectionText">等待授权</span></div>
|
||||
</header>
|
||||
<main>
|
||||
<section id="authPanel" class="auth card" aria-labelledby="authTitle">
|
||||
<div><h2 id="authTitle">连接本机工程控制台</h2><p>令牌仅保存在当前页面内存,刷新后需重新输入。</p></div>
|
||||
<label>控制台令牌<input id="token" type="password" autocomplete="off" spellcheck="false"></label>
|
||||
<button id="connect" type="button">连接并加载</button>
|
||||
</section>
|
||||
|
||||
<div id="workspace" class="workspace" hidden>
|
||||
<section class="queue card" aria-labelledby="queueTitle">
|
||||
<div class="section-head"><div><p class="eyebrow">真实 PostgreSQL 状态</p><h2 id="queueTitle">预警队列</h2></div><button id="refresh" class="secondary" type="button">刷新</button></div>
|
||||
<div class="filters" role="group" aria-label="按状态筛选">
|
||||
<button class="filter active" data-state="" aria-pressed="true">全部</button>
|
||||
<button class="filter" data-state="open" aria-pressed="false">待确认</button>
|
||||
<button class="filter" data-state="acknowledged" aria-pressed="false">处理中</button>
|
||||
<button class="filter" data-state="closed" aria-pressed="false">已关闭</button>
|
||||
</div>
|
||||
<div id="alerts" tabindex="-1" aria-busy="false"></div>
|
||||
<button id="more" class="secondary full" type="button" hidden>加载更多</button>
|
||||
</section>
|
||||
|
||||
<section class="detail card" aria-labelledby="detailTitle">
|
||||
<div id="detailEmpty" class="empty"><span aria-hidden="true">◎</span><h2 id="detailTitle">选择一条预警</h2><p>查看关联事件、规则版本与不可变处置时间线。</p></div>
|
||||
<div id="detailContent" hidden>
|
||||
<div class="section-head"><div><p id="detailSeverity" class="badge"></p><h2 id="detailName"></h2><code id="detailId"></code></div><span id="detailState" class="state"></span></div>
|
||||
<dl id="facts" class="facts"></dl>
|
||||
<div class="notice"><strong>证据切片尚未启用</strong><span>当前只展示事件事实,不虚构截图或录像。</span></div>
|
||||
<div class="notice muted"><strong>升级与通知尚未启用</strong><span>当前没有倒计时、短信、语音或送达状态。</span></div>
|
||||
<h3>关联事件</h3><div id="events"></div>
|
||||
<h3>处置时间线</h3><ol id="timeline" class="timeline"></ol>
|
||||
<label for="note">处置备注(可选,最多 500 字)</label><textarea id="note" maxlength="500" rows="3"></textarea>
|
||||
<div class="actions"><button id="ack" type="button">确认接手</button><button id="close" type="button">关闭预警</button></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<div id="status" class="toast" role="status" aria-live="polite" hidden></div>
|
||||
<div id="errors" class="toast error" role="alert" aria-live="assertive" hidden></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
:root{color-scheme:light;--ink:#17202a;--muted:#607080;--line:#d8e0e7;--paper:#fff;--canvas:#eef3f7;--blue:#075ea8;--blue-soft:#e8f2fb;--red:#b42318;--red-soft:#fff0ee;--amber:#8a4b08;--green:#167348;--shadow:0 8px 24px rgba(23,32,42,.08);font-family:"Segoe UI","Microsoft YaHei",sans-serif}
|
||||
*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;background:var(--canvas);color:var(--ink)}button,input,textarea{font:inherit}button{min-height:44px;border:0;border-radius:8px;padding:0 16px;background:var(--blue);color:#fff;font-weight:650;cursor:pointer}button:hover{filter:brightness(.94)}button:focus-visible,input:focus-visible,textarea:focus-visible,[tabindex]:focus-visible{outline:3px solid #67b7ff;outline-offset:2px}button:disabled{cursor:not-allowed;opacity:.55}.secondary{background:#fff;color:var(--blue);border:1px solid #9bb9d3}.skip{position:fixed;left:12px;top:-60px;z-index:10;background:#fff;padding:10px}.skip:focus{top:12px}header{height:82px;padding:12px clamp(16px,4vw,48px);background:#12283b;color:#fff;display:flex;align-items:center;justify-content:space-between;box-shadow:var(--shadow)}h1,h2,h3,p{margin-top:0}h1{font-size:22px;margin-bottom:0}h2{font-size:19px;margin-bottom:7px}h3{font-size:15px;margin:24px 0 10px}.eyebrow{font-size:11px;letter-spacing:.13em;color:#79b9ed;margin-bottom:5px;font-weight:700}.connection{display:flex;gap:8px;align-items:center;font-size:13px}.dot{width:9px;height:9px;border-radius:50%;background:#f2b84b}.connected .dot{background:#45c58a}main{padding:24px clamp(16px,4vw,48px)}.card{background:var(--paper);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)}.auth{max-width:720px;margin:7vh auto;padding:24px;display:grid;grid-template-columns:1fr minmax(220px,300px) auto;gap:18px;align-items:end}.auth p,.empty p{color:var(--muted);margin-bottom:0}label{font-size:13px;font-weight:650;display:grid;gap:7px}input,textarea{width:100%;border:1px solid #aebcc8;border-radius:7px;padding:10px 11px;background:#fff;color:var(--ink)}.workspace{display:grid;grid-template-columns:minmax(330px,.82fr) minmax(420px,1.18fr);gap:20px;max-width:1440px;margin:auto}.queue,.detail{min-height:calc(100vh - 130px);padding:20px}.section-head{display:flex;justify-content:space-between;gap:14px;align-items:flex-start}.filters{display:flex;gap:7px;overflow:auto;padding:12px 0}.filter{background:#fff;color:var(--muted);border:1px solid var(--line);white-space:nowrap}.filter.active{background:var(--blue-soft);border-color:#74a9d3;color:#084e87}.alert-row{width:100%;height:auto;min-height:78px;text-align:left;background:#fff;color:var(--ink);border:1px solid var(--line);padding:13px;margin:0 0 8px;display:grid;grid-template-columns:1fr auto;gap:7px}.alert-row.selected{border-color:var(--blue);box-shadow:0 0 0 2px #d5ebff}.alert-row strong{display:block}.alert-row small{color:var(--muted)}.badge,.state{display:inline-block;width:max-content;border-radius:999px;padding:4px 9px;background:var(--red-soft);color:var(--red);font-size:12px;font-weight:700}.state[data-state=acknowledged]{background:#fff4db;color:var(--amber)}.state[data-state=closed]{background:#e9f7ef;color:var(--green)}.full{width:100%;margin-top:5px}.empty{text-align:center;color:var(--muted);padding:18vh 10px}.empty span{font-size:40px}.facts{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:20px 0}.facts div{background:#f5f8fa;padding:10px;border-radius:7px}.facts dt{font-size:11px;color:var(--muted)}.facts dd{margin:4px 0 0;font-weight:650;overflow-wrap:anywhere}.notice{display:grid;gap:3px;border-left:4px solid var(--red);background:var(--red-soft);padding:11px 13px;margin:10px 0;font-size:13px}.notice span{color:var(--muted)}.notice.muted{border-color:#82909d;background:#f3f5f6}.event{padding:10px;border:1px solid var(--line);border-radius:7px;margin-bottom:7px;display:flex;justify-content:space-between;gap:10px;font-size:13px}.timeline{padding-left:22px}.timeline li{padding:0 0 14px 5px}.timeline small{display:block;color:var(--muted);margin-top:3px}.actions{display:flex;gap:10px;margin-top:12px}.toast{position:fixed;right:22px;bottom:22px;max-width:430px;background:#173b2c;color:#fff;padding:13px 16px;border-radius:8px;box-shadow:var(--shadow);z-index:5}.toast.error{background:#7d201a}.skeleton{height:76px;background:linear-gradient(90deg,#edf1f4,#f7f9fa,#edf1f4);border-radius:7px;margin-bottom:8px;background-size:200% 100%;animation:pulse 1.4s infinite}.offline{padding:22px;text-align:center;color:var(--muted)}code{font-size:12px;overflow-wrap:anywhere}
|
||||
@keyframes pulse{to{background-position:-200% 0}}
|
||||
@media(max-width:850px){.auth{grid-template-columns:1fr}.workspace{grid-template-columns:1fr}.queue,.detail{min-height:auto}.detail{min-height:520px}.facts{grid-template-columns:1fr 1fr}}
|
||||
@media(max-width:480px){header{height:auto;min-height:82px;align-items:flex-start;gap:12px}.connection{padding-top:5px}main{padding:12px}.queue,.detail{padding:14px}.facts{grid-template-columns:1fr}.actions{display:grid}.toast{left:12px;right:12px;bottom:12px}.event{display:grid}}
|
||||
@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important}}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
// Package web exposes the loopback-only Bell engineering console.
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"yovision/bell/internal/alert"
|
||||
)
|
||||
|
||||
//go:embed assets/*
|
||||
var assets embed.FS
|
||||
|
||||
type Config struct {
|
||||
Token string
|
||||
TenantID int64
|
||||
SiteID int64
|
||||
ActorRef string
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
repository alert.Repository
|
||||
config Config
|
||||
tokenHash [sha256.Size]byte
|
||||
}
|
||||
|
||||
var alertIDPattern = regexp.MustCompile(`^alt_[0-9A-HJKMNP-TV-Z]{26}$`)
|
||||
|
||||
func NewHandler(repository alert.Repository, config Config) (*Handler, error) {
|
||||
if repository == nil || len(config.Token) < 32 || len(config.Token) > 256 || config.TenantID < 1 || config.SiteID < 1 || !alert.ValidActorRef(config.ActorRef) {
|
||||
return nil, errors.New("invalid Bell alert console configuration")
|
||||
}
|
||||
return &Handler{repository: repository, config: config, tokenHash: sha256.Sum256([]byte(config.Token))}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /bell-console/", h.page)
|
||||
mux.HandleFunc("GET /bell-console/assets/{name}", h.asset)
|
||||
mux.HandleFunc("GET /bell-console/api/v1/alerts", h.list)
|
||||
mux.HandleFunc("GET /bell-console/api/v1/alerts/{id}", h.detail)
|
||||
mux.HandleFunc("POST /bell-console/api/v1/alerts/{action}", h.command)
|
||||
}
|
||||
|
||||
func secureHeaders(writer http.ResponseWriter) {
|
||||
writer.Header().Set("Cache-Control", "no-store")
|
||||
writer.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
writer.Header().Set("X-Frame-Options", "DENY")
|
||||
writer.Header().Set("Referrer-Policy", "no-referrer")
|
||||
writer.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'")
|
||||
}
|
||||
|
||||
func (h *Handler) page(writer http.ResponseWriter, _ *http.Request) {
|
||||
secureHeaders(writer)
|
||||
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
value, _ := assets.ReadFile("assets/index.html")
|
||||
_, _ = writer.Write(value)
|
||||
}
|
||||
|
||||
func (h *Handler) asset(writer http.ResponseWriter, request *http.Request) {
|
||||
secureHeaders(writer)
|
||||
name := request.PathValue("name")
|
||||
if name != "app.js" && name != "style.css" {
|
||||
http.NotFound(writer, request)
|
||||
return
|
||||
}
|
||||
value, err := assets.ReadFile("assets/" + name)
|
||||
if err != nil {
|
||||
http.NotFound(writer, request)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(name, ".js") {
|
||||
writer.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
||||
} else {
|
||||
writer.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
}
|
||||
_, _ = writer.Write(value)
|
||||
}
|
||||
|
||||
func (h *Handler) authorize(writer http.ResponseWriter, request *http.Request) bool {
|
||||
secureHeaders(writer)
|
||||
prefix := "Bearer "
|
||||
value := request.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(value, prefix) {
|
||||
writeError(writer, http.StatusUnauthorized, "unauthorized", "需要控制台令牌")
|
||||
return false
|
||||
}
|
||||
digest := sha256.Sum256([]byte(strings.TrimPrefix(value, prefix)))
|
||||
if subtle.ConstantTimeCompare(digest[:], h.tokenHash[:]) != 1 {
|
||||
writeError(writer, http.StatusUnauthorized, "unauthorized", "控制台令牌无效")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Handler) list(writer http.ResponseWriter, request *http.Request) {
|
||||
if !h.authorize(writer, request) {
|
||||
return
|
||||
}
|
||||
limit := alert.DefaultPageSize
|
||||
state := request.URL.Query().Get("state")
|
||||
if state != "" && state != "open" && state != "acknowledged" && state != "closed" {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_state", "state 筛选无效")
|
||||
return
|
||||
}
|
||||
cursor := request.URL.Query().Get("cursor")
|
||||
if cursor != "" && !alertIDPattern.MatchString(cursor) {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_cursor", "cursor 无效")
|
||||
return
|
||||
}
|
||||
if raw := request.URL.Query().Get("limit"); raw != "" {
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 1 || value > alert.MaxPageSize {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_limit", "limit 必须在 1 到 100 之间")
|
||||
return
|
||||
}
|
||||
limit = value
|
||||
}
|
||||
page, err := h.repository.ListAlerts(request.Context(), h.config.TenantID, h.config.SiteID, state, limit, cursor)
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "读取 Alert 列表失败,可重试")
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, page)
|
||||
}
|
||||
|
||||
func (h *Handler) detail(writer http.ResponseWriter, request *http.Request) {
|
||||
if !h.authorize(writer, request) {
|
||||
return
|
||||
}
|
||||
id := request.PathValue("id")
|
||||
if !alertIDPattern.MatchString(id) {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_alert_id", "Alert ID 无效")
|
||||
return
|
||||
}
|
||||
value, err := h.repository.GetAlert(request.Context(), h.config.TenantID, h.config.SiteID, id)
|
||||
if errors.Is(err, alert.ErrNotFound) {
|
||||
writeError(writer, http.StatusNotFound, "not_found", "Alert 不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "读取 Alert 失败,可重试")
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, value)
|
||||
}
|
||||
|
||||
func (h *Handler) command(writer http.ResponseWriter, request *http.Request) {
|
||||
if !h.authorize(writer, request) {
|
||||
return
|
||||
}
|
||||
action := request.PathValue("action")
|
||||
id, command, found := strings.Cut(action, ":")
|
||||
if !found || !alertIDPattern.MatchString(id) || (command != "ack" && command != "close") {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_command", "Alert 命令无效")
|
||||
return
|
||||
}
|
||||
key := request.Header.Get("Idempotency-Key")
|
||||
if !alert.ValidIdempotencyKey(key) {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_idempotency_key", "Idempotency-Key 必须为 8 到 128 个安全字符")
|
||||
return
|
||||
}
|
||||
request.Body = http.MaxBytesReader(writer, request.Body, 2048)
|
||||
decoder := json.NewDecoder(request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
var body struct {
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
if err := decoder.Decode(&body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_json", "请求体必须是 JSON 对象")
|
||||
return
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_json", "请求体只能包含一个 JSON 对象")
|
||||
return
|
||||
}
|
||||
status, response, err := h.repository.Command(request.Context(), h.config.TenantID, h.config.SiteID, id, command, key, h.config.ActorRef, body.Note)
|
||||
switch {
|
||||
case errors.Is(err, alert.ErrNotFound):
|
||||
writeError(writer, http.StatusNotFound, "not_found", "Alert 不存在")
|
||||
case errors.Is(err, alert.ErrIdempotencyConflict):
|
||||
writeError(writer, http.StatusConflict, "idempotency_conflict", "该幂等键已用于另一条命令")
|
||||
case err != nil:
|
||||
writeError(writer, http.StatusInternalServerError, "internal_error", "写入处置状态失败,可使用同一幂等键重试")
|
||||
default:
|
||||
writeJSON(writer, status, response)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(writer http.ResponseWriter, status int, value any) {
|
||||
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
writer.WriteHeader(status)
|
||||
_ = json.NewEncoder(writer).Encode(value)
|
||||
}
|
||||
|
||||
func writeError(writer http.ResponseWriter, status int, code, message string) {
|
||||
writeJSON(writer, status, map[string]string{"code": code, "message": message})
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"yovision/bell/internal/alert"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
limit int
|
||||
actor string
|
||||
}
|
||||
|
||||
func (*fakeRepository) AlertReady(context.Context) error { return nil }
|
||||
func (*fakeRepository) PublishRule(context.Context, alert.RuleSpec) (bool, error) { return true, nil }
|
||||
func (*fakeRepository) EvaluateNext(context.Context) (bool, error) { return false, nil }
|
||||
func (f *fakeRepository) ListAlerts(_ context.Context, _, _ int64, _ string, limit int, _ string) (alert.Page, error) {
|
||||
f.limit = limit
|
||||
return alert.Page{Items: []alert.Summary{}}, nil
|
||||
}
|
||||
func (*fakeRepository) GetAlert(context.Context, int64, int64, string) (alert.Detail, error) {
|
||||
return alert.Detail{}, alert.ErrNotFound
|
||||
}
|
||||
func (f *fakeRepository) Command(_ context.Context, _, _ int64, id, command, key, actor string, _ *string) (int, alert.CommandResponse, error) {
|
||||
f.actor = actor
|
||||
return 200, alert.CommandResponse{AlertID: id, State: "acknowledged", ActorRef: actor}, nil
|
||||
}
|
||||
|
||||
func testMux(t *testing.T) (*http.ServeMux, *fakeRepository) {
|
||||
t.Helper()
|
||||
repository := &fakeRepository{}
|
||||
handler, err := NewHandler(repository, Config{Token: "12345678901234567890123456789012", TenantID: 1, SiteID: 2, ActorRef: "operator:local"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
handler.Register(mux)
|
||||
return mux, repository
|
||||
}
|
||||
|
||||
func TestPageIsNoStoreAndDoesNotEmbedToken(t *testing.T) {
|
||||
mux, _ := testMux(t)
|
||||
request := httptest.NewRequest(http.MethodGet, "/bell-console/", nil)
|
||||
response := httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != 200 || response.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("page response: %d %#v", response.Code, response.Header())
|
||||
}
|
||||
if body := response.Body.String(); body == "" || strings.Contains(body, "12345678901234567890123456789012") {
|
||||
t.Fatal("page missing or leaked console token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIRequiresBearerAndDefaultsToSixteen(t *testing.T) {
|
||||
mux, repository := testMux(t)
|
||||
request := httptest.NewRequest(http.MethodGet, "/bell-console/api/v1/alerts", nil)
|
||||
response := httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthorized status %d", response.Code)
|
||||
}
|
||||
request = httptest.NewRequest(http.MethodGet, "/bell-console/api/v1/alerts", nil)
|
||||
request.Header.Set("Authorization", "Bearer 12345678901234567890123456789012")
|
||||
response = httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || repository.limit != alert.DefaultPageSize {
|
||||
t.Fatalf("authorized list: status=%d limit=%d", response.Code, repository.limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandUsesServerActorAndRequiresIdempotencyKey(t *testing.T) {
|
||||
mux, repository := testMux(t)
|
||||
path := "/bell-console/api/v1/alerts/alt_01J8XQ2K7M3P5R9T0V4W6Y8Z2C:ack"
|
||||
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"note":null}`))
|
||||
request.Header.Set("Authorization", "Bearer 12345678901234567890123456789012")
|
||||
response := httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing idempotency status %d", response.Code)
|
||||
}
|
||||
request = httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"note":null}`))
|
||||
request.Header.Set("Authorization", "Bearer 12345678901234567890123456789012")
|
||||
request.Header.Set("Idempotency-Key", "command-0001")
|
||||
response = httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || repository.actor != "operator:local" {
|
||||
t.Fatalf("command status=%d actor=%q", response.Code, repository.actor)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user