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:
@@ -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