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}}
|
||||
Reference in New Issue
Block a user