feat(通知): 系统级 Webhook 通知子系统(事件目录 + 可插拔适配器 + 防刷屏)
平台此前出了问题只能靠人盯页面。现在各组件统一走 `notifier.notify(事件, **字段)`, 推到企业微信 / 自建服务;**所有可通知点都登记进事件目录,默认全关,用户按 webhook 勾选**。 ## 架构(`core/notifier.py` + `core/notify_events.py`) 业务线程调 `notify()` → 只做内存操作(读配置快照/匹配订阅/入队)→ 返回; 后台 1 个 dispatcher(聚合 + 每 hook 限流 + 折叠摘要)+ 3 个 sender(真实 HTTP、退避重试) 负责真正发出去。硬约束:**notify 零 DB、零 HTTP、零阻塞、异常不冒泡**——所以任务线程里 可以直接调(不用 app_context、不用 try/except),但**必须放在所有 `with self._lock` 之外**。 - **事件目录 36 条**(任务批次/单设备/设备/Worker/业务/安装/系统/AI),支持 `task.*` 通配订阅; 语义分工避免重复告警:`worker.*` 是单次尝试级,`task.device.success/failed` 是唯一权威结论。 - **适配器可插拔**:`wecom`(markdown,按 4096 **字节**截断、超限不截半个汉字)+ `json`(模板占位符,替换值按 JSON 转义,保存前干跑校验);钉钉/飞书留了插槽(前端置灰)。 - **防打爆四层**:聚合窗口(默认 30s,同批次合并成一条并带样本)→ 令牌桶限流(默认 18/分, 对齐企微硬限 20)→ 被限流的**折叠成摘要不丢弃** → 有界队列背压。取舍:失败通知最多延迟 一个窗口,换来群不被刷屏。 ## 安全与存储 - 配置只落 `app_meta.notify_webhooks` 一个键(**不建表** → 不涉及备份覆盖红线)。 - URL 本身就是凭据(企微 `?key=`)→ 接口回显/发送记录/日志一律 `mask_url()/scrub()`; 编辑时留空即不修改;secret 永不回显。DATA_MODEL 的明文凭据告警补上了这一条。 - 发送记录:内存环形缓冲 200 条(重启清空)+ 独立 `logs/notify.log`。 ## 接入点(每个都放在锁外、不改 return 顺序) task_manager(批次开始/结束用新增的 `_BatchTracker` 统一在 finally 计数、单设备成功/失败/ 离线/重试/停止/抢占/归还/cron 停止)、device_worker 心跳看门狗、generic 任务选择器连续失效、 apk 安装开始/完成、设备上下线(**状态沿检测**,只报新变化)、备份导出/恢复、经验巡检、 用户登录、服务启停。 ## 前端 系统 Tab 新增「通知 / Webhook」子分栏:多条 webhook 列表(URL 打码)+ 编辑弹窗(格式/URL/ 密钥/事件勾选树带 ★建议/聚合/限流/自定义模板/预览)+ 发送测试 + 发送记录。 ## 自测 - 进程内逻辑 10 组断言全绿:聚合合并、限流+折叠、无配置/全局关静默丢弃、未知事件、 内部异常不外泄、JSON 转义(标题含引号换行仍合法)、URL/异常消息脱敏、配置校验。 - 端到端(假 webhook 接收端)17 项断言全绿:真实事件投递(user.login / task.batch.no_device)、 企微请求体形状、**HTTP 200 + errcode 93000 判为失败**、500 重试 3 次、记录里 URL 打码。 - 韧性:webhook 指向黑洞地址时登录耗时 100~114ms(基线 107~133ms,**异步隔离生效**); 配置写成坏 JSON 服务照常启动、通知静默不发、日志有 error(服务端实测后已复原)。 - 页面:系统 → 通知 面板/弹窗/36 个事件复选框/预览全部正常,无 JS 报错。 - 自测数据已清理(webhook、自建任务、写坏又复原的配置键)。 文档:新增 doc/NOTIFY.md(事件表/配置/格式约束/防刷屏/加事件三步骤/排障)并登记进 doc/README; API.md §2.11;DATA_MODEL 的 app_meta 键表与明文凭据告警;ARCHITECTURE 线程表/分层/扩展点; 根 README 功能索引与日志表。
This commit is contained in:
@@ -126,6 +126,7 @@ function showSubTab(tabId, name){
|
||||
tab.querySelectorAll('.sub-tab').forEach(b=>b.classList.toggle('active', b.dataset.sub===name));
|
||||
tab.querySelectorAll('.sub-panel').forEach(p=>p.classList.toggle('active', p.id===tabId+'-sub-'+name));
|
||||
if(tabId==='agent' && name==='taskgen' && typeof initTaskGen==='function') initTaskGen();
|
||||
if(tabId==='system' && name==='notify' && typeof loadNotifyPanel==='function') loadNotifyPanel();
|
||||
if(name==='groups' && typeof loadGroups==='function') loadGroups();
|
||||
if(name==='apks' && typeof loadAgentStore==='function') loadAgentStore();
|
||||
if(name==='devpool' && typeof loadDevPool==='function'){
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
// 系统 → 通知:多 webhook 配置(每条自己订阅事件)+ 测试发送 + 发送记录
|
||||
// 后端:web/notify_api.py(全部 admin_required);配置落 app_meta.notify_webhooks 单键。
|
||||
// 安全口径:URL 里有凭据(企微 ?key=)→ 列表/记录里一律打码;编辑时**留空即不修改**。
|
||||
let _notifyHooks = [];
|
||||
let _notifyEvents = [];
|
||||
let _notifyFormats = [];
|
||||
let _notifySettings = {};
|
||||
let _notifyEditingId = null; // null=新建
|
||||
let _notifyPatterns = []; // 编辑中的通配订阅(如 task.*)
|
||||
|
||||
// ================== 加载与渲染 ==================
|
||||
function loadNotifyPanel(){
|
||||
Promise.all([apiGet('/api/notify/webhooks'),
|
||||
apiGet('/api/notify/events'),
|
||||
apiGet('/api/notify/logs?limit=50')]).then(function(res){
|
||||
const [hooks, events, logs] = res;
|
||||
if(hooks && hooks.ok){
|
||||
_notifyHooks = hooks.webhooks || [];
|
||||
_notifyFormats = hooks.formats || [];
|
||||
_notifySettings = hooks.settings || {};
|
||||
}
|
||||
if(events && events.ok) _notifyEvents = events.events || [];
|
||||
renderNotifySettings();
|
||||
renderNotifyHooks();
|
||||
renderNotifyLogs(logs && logs.ok ? (logs.logs || []) : []);
|
||||
});
|
||||
}
|
||||
|
||||
function renderNotifySettings(){
|
||||
const on = document.getElementById('notify-global');
|
||||
if(on) on.checked = _notifySettings.global_enabled !== false;
|
||||
const agg = document.getElementById('notify-agg');
|
||||
if(agg) agg.value = _notifySettings.default_agg_window != null ? _notifySettings.default_agg_window : 30;
|
||||
const rate = document.getElementById('notify-rate');
|
||||
if(rate) rate.value = _notifySettings.default_rate_limit != null ? _notifySettings.default_rate_limit : 18;
|
||||
}
|
||||
|
||||
function renderNotifyHooks(){
|
||||
const tb = document.getElementById('tb-notify-hooks');
|
||||
if(!tb) return;
|
||||
if(!_notifyHooks.length){
|
||||
tb.innerHTML = '<tr><td colspan="6" class="empty">还没有配置 webhook。点「+ 新建 Webhook」加一条——'
|
||||
+ '比如把「任务失败」推到企业微信群。</td></tr>';
|
||||
return;
|
||||
}
|
||||
tb.innerHTML = _notifyHooks.map(function(h){
|
||||
const fmt = (_notifyFormats.find(f => f.name === h.format) || {}).label || h.format;
|
||||
const evs = (h.events || []).length;
|
||||
return '<tr>'
|
||||
+ '<td>' + esc(h.name) + (h.secret_set ? ' <span class="label label-info" title="已配置密钥">🔑</span>' : '') + '</td>'
|
||||
+ '<td>' + esc(fmt) + '</td>'
|
||||
+ '<td style="max-width:280px;word-break:break-all;font-family:var(--mono);font-size:11px">'
|
||||
+ esc(h.url || '') + '</td>'
|
||||
+ '<td>' + evs + ' 个事件</td>'
|
||||
+ '<td>' + (h.enabled ? '<span class="label label-success">启用</span>'
|
||||
: '<span class="label">停用</span>') + '</td>'
|
||||
+ '<td style="white-space:nowrap">'
|
||||
+ '<button class="btn btn-xs" onclick="testNotifyHook(\'' + h.id + '\')" title="立刻发一条测试消息,看群里收不收得到">发送测试</button> '
|
||||
+ '<button class="btn btn-xs" onclick="toggleNotifyHook(\'' + h.id + '\',' + (h.enabled ? 'false' : 'true') + ')">'
|
||||
+ (h.enabled ? '停用' : '启用') + '</button> '
|
||||
+ '<button class="btn btn-xs" onclick="openNotifyModal(\'' + h.id + '\')">编辑</button> '
|
||||
+ '<button class="btn btn-xs btn-danger" onclick="deleteNotifyHook(\'' + h.id + '\')">删除</button>'
|
||||
+ '</td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderNotifyLogs(logs){
|
||||
const tb = document.getElementById('tb-notify-logs');
|
||||
if(!tb) return;
|
||||
if(!logs.length){
|
||||
tb.innerHTML = '<tr><td colspan="5" class="empty">本次运行还没有发送记录(重启后清空,历史见 logs/notify.log)</td></tr>';
|
||||
return;
|
||||
}
|
||||
tb.innerHTML = logs.map(function(r){
|
||||
const t = r.ts ? new Date(r.ts * 1000).toLocaleString('zh-CN', {hour12:false}) : '';
|
||||
const st = r.ok ? '<span class="label label-success">成功</span>'
|
||||
: '<span class="label label-warning">失败</span>';
|
||||
const detail = r.ok ? ('HTTP ' + (r.http_status == null ? '' : r.http_status)
|
||||
+ (r.errcode ? ' errcode ' + r.errcode : ''))
|
||||
: esc(r.error || '');
|
||||
return '<tr><td style="white-space:nowrap">' + esc(t) + '</td>'
|
||||
+ '<td>' + esc(r.hook_name || '') + '</td>'
|
||||
+ '<td style="font-family:var(--mono);font-size:11px">' + esc(r.event || '') + '</td>'
|
||||
+ '<td>' + st + '</td>'
|
||||
+ '<td style="font-size:11px">' + detail + (r.elapsed_ms != null ? ' · ' + r.elapsed_ms + 'ms' : '') + '</td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ================== 编辑弹窗 ==================
|
||||
function notifyEventMatches(pattern, key){
|
||||
if(!pattern) return false;
|
||||
if(pattern === '*' || pattern === key) return true;
|
||||
if(pattern.slice(-2) === '.*') return key.indexOf(pattern.slice(0, -1)) === 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
function openNotifyModal(id){
|
||||
_notifyEditingId = id || null;
|
||||
const h = id ? (_notifyHooks.find(x => x.id === id) || {}) : {};
|
||||
_notifyPatterns = (h.events || []).filter(p => p.indexOf('*') >= 0);
|
||||
const checked = (h.events || []).filter(p => p.indexOf('*') < 0);
|
||||
const fmtOpts = _notifyFormats.map(function(f){
|
||||
const sel = (h.format || 'wecom') === f.name ? ' selected' : '';
|
||||
return '<option value="' + f.name + '"' + sel + (f.implemented ? '' : ' disabled') + '>'
|
||||
+ esc(f.label) + '</option>';
|
||||
}).join('');
|
||||
const box = document.getElementById('notify-modal-box');
|
||||
if(!box) return;
|
||||
box.innerHTML =
|
||||
'<div style="font-size:15px;font-weight:700;margin-bottom:12px">'
|
||||
+ (id ? '编辑 Webhook' : '新建 Webhook') + '</div>'
|
||||
+ '<div class="form-row">'
|
||||
+ '<div class="form-group"><label>名称</label>'
|
||||
+ '<input id="nf-name" class="form-control" value="' + esc(h.name || '') + '" placeholder="如:运维群"></div>'
|
||||
+ '<div class="form-group"><label>格式</label>'
|
||||
+ '<select id="nf-format" class="form-control" onchange="_notifyFormatChanged()">' + fmtOpts + '</select></div>'
|
||||
+ '</div>'
|
||||
+ '<div class="form-group"><label>Webhook URL</label>'
|
||||
+ '<input id="nf-url" class="form-control" placeholder="' + (h.url ? esc(h.url) : 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=…') + '" '
|
||||
+ 'value="' + (id ? '' : '') + '">'
|
||||
+ '<div class="help">' + (h.url ? '已配置:' + esc(h.url) + '(留空=不修改)' : '企业微信:群机器人 → 复制 Webhook 地址') + '</div></div>'
|
||||
+ '<div class="form-row">'
|
||||
+ '<div class="form-group"><label>签名密钥(可选)</label>'
|
||||
+ '<input id="nf-secret" type="password" class="form-control" placeholder="' + (h.secret_set ? '已配置,留空不修改' : '钉钉/飞书加签用,可留空') + '"></div>'
|
||||
+ '<div class="form-group" style="max-width:150px"><label>聚合窗口(秒)</label>'
|
||||
+ '<input id="nf-agg" type="number" class="form-control" min="0" max="600" value="'
|
||||
+ (h.agg_window != null ? h.agg_window : (_notifySettings.default_agg_window || 30)) + '">'
|
||||
+ '<div class="help">0=不聚合</div></div>'
|
||||
+ '<div class="form-group" style="max-width:150px"><label>限流(条/分)</label>'
|
||||
+ '<input id="nf-rate" type="number" class="form-control" min="0" max="60" value="'
|
||||
+ (h.rate_limit_per_min != null ? h.rate_limit_per_min : (_notifySettings.default_rate_limit || 18)) + '">'
|
||||
+ '<div class="help">企微硬限 20</div></div>'
|
||||
+ '</div>'
|
||||
+ '<div class="form-group"><label>订阅事件(不勾就不推)</label>'
|
||||
+ '<div class="help" style="margin-bottom:6px">按类别分的完整事件目录;'
|
||||
+ '带 <b>★</b> 的是建议开启的。也可以直接写通配(如 <code>task.*</code>)——在下面的芯片框里加。</div>'
|
||||
+ '<div id="nf-events" style="max-height:260px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:10px"></div>'
|
||||
+ '</div>'
|
||||
+ '<div class="form-group" id="nf-pattern-wrap" style="display:none"><label>通配订阅</label>'
|
||||
+ '<div id="nf-patterns" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px"></div>'
|
||||
+ '<div style="display:flex;gap:6px"><input id="nf-pattern-input" class="form-control" placeholder="task.* 或 device.* 或 *">'
|
||||
+ '<button class="btn" onclick="addNotifyPattern()">添加</button></div></div>'
|
||||
+ '<div class="form-group" id="nf-body-wrap" style="display:none"><label>请求体模板(JSON)</label>'
|
||||
+ '<textarea id="nf-body" class="form-control" rows="5" placeholder=\'{"text":"{{title}}","desc":"{{summary}}"}\'></textarea>'
|
||||
+ '<div class="help">占位符:{{event}} {{title}} {{summary}} {{ts}} {{level}} {{markdown}} {{fields}} {{fields_json}} {{hook_name}} {{field.名称}}。'
|
||||
+ '保存前会做干跑校验(渲染后必须是合法 JSON)。Slack 可直接用:'
|
||||
+ '<code>{"text":"{{markdown}}"}</code></div></div>'
|
||||
+ '<div class="form-group">'
|
||||
+ '<label>预览</label><div id="nf-preview" class="help">点「预览请求体」看真实会发出去的内容与字节数。</div>'
|
||||
+ '</div>';
|
||||
box.querySelector('#nf-body').value = h.body_template || '';
|
||||
renderEventTree(checked);
|
||||
renderNotifyPatterns();
|
||||
_notifyFormatChanged();
|
||||
document.getElementById('notify-modal-overlay').style.display = 'flex';
|
||||
}
|
||||
|
||||
function _notifyFormatChanged(){
|
||||
const f = (document.getElementById('nf-format') || {}).value;
|
||||
const bodyWrap = document.getElementById('nf-body-wrap');
|
||||
if(bodyWrap) bodyWrap.style.display = (f === 'json') ? 'block' : 'none';
|
||||
const patWrap = document.getElementById('nf-pattern-wrap');
|
||||
if(patWrap) patWrap.style.display = (f === 'json' || true) ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function renderEventTree(checked){
|
||||
const box = document.getElementById('nf-events');
|
||||
if(!box) return;
|
||||
const cats = [];
|
||||
_notifyEvents.forEach(function(e){ if(cats.indexOf(e.category) < 0) cats.push(e.category); });
|
||||
box.innerHTML = cats.map(function(cat){
|
||||
const rows = _notifyEvents.filter(e => e.category === cat);
|
||||
const body = rows.map(function(e){
|
||||
const id = 'nfe-' + e.key.replace(/\./g, '-');
|
||||
return '<label style="display:flex;gap:6px;align-items:flex-start;padding:3px 0;cursor:pointer" title="'
|
||||
+ esc(e.desc || '') + '">'
|
||||
+ '<input type="checkbox" class="nf-ev" value="' + esc(e.key) + '" id="' + id + '"'
|
||||
+ (checked.indexOf(e.key) >= 0 ? ' checked' : '') + '>'
|
||||
+ '<span style="flex:1"><b>' + esc(e.label) + '</b>'
|
||||
+ (e.recommend ? ' <span class="label label-success">★建议</span>' : '')
|
||||
+ ' <code style="font-size:10.5px;opacity:.7">' + esc(e.key) + '</code></span></label>';
|
||||
}).join('');
|
||||
return '<div style="margin-bottom:8px"><div style="font-weight:600;font-size:12px;margin-bottom:2px">'
|
||||
+ esc(cat) + ' <button class="btn btn-xs" onclick="_notifyToggleCat(\'' + esc(cat) + '\')">全选/取消</button></div>'
|
||||
+ body + '</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function _notifyToggleCat(cat){
|
||||
const keys = _notifyEvents.filter(e => e.category === cat).map(e => e.key);
|
||||
const boxes = [...document.querySelectorAll('#nf-events .nf-ev')].filter(b => keys.indexOf(b.value) >= 0);
|
||||
const allOn = boxes.every(b => b.checked);
|
||||
boxes.forEach(b => { b.checked = !allOn; });
|
||||
}
|
||||
|
||||
function addNotifyPattern(){
|
||||
const inp = document.getElementById('nf-pattern-input');
|
||||
const v = (inp.value || '').trim();
|
||||
if(!v) return;
|
||||
if(v !== '*' && v.slice(-2) !== '.*'){ showToast('通配请写成 task.* 或 device.* 或 *', 'error'); return; }
|
||||
if(_notifyPatterns.indexOf(v) < 0) _notifyPatterns.push(v);
|
||||
inp.value = '';
|
||||
renderNotifyPatterns();
|
||||
}
|
||||
|
||||
function renderNotifyPatterns(){
|
||||
const box = document.getElementById('nf-patterns');
|
||||
if(!box) return;
|
||||
box.innerHTML = _notifyPatterns.map(function(p, i){
|
||||
return '<span class="label label-info" style="cursor:pointer" title="点一下移除" onclick="removeNotifyPattern(' + i + ')">'
|
||||
+ esc(p) + ' ×</span>';
|
||||
}).join('') || '<span class="help">(无)</span>';
|
||||
}
|
||||
|
||||
function removeNotifyPattern(i){
|
||||
_notifyPatterns.splice(i, 1);
|
||||
renderNotifyPatterns();
|
||||
}
|
||||
|
||||
function closeNotifyModal(){
|
||||
const ov = document.getElementById('notify-modal-overlay');
|
||||
if(ov) ov.style.display = 'none';
|
||||
}
|
||||
|
||||
// ================== 保存 / 删除 / 启停 / 测试 ==================
|
||||
function collectNotifyForm(){
|
||||
const events = [...document.querySelectorAll('#nf-events .nf-ev')]
|
||||
.filter(b => b.checked).map(b => b.value);
|
||||
_notifyPatterns.forEach(function(p){ if(events.indexOf(p) < 0) events.push(p); });
|
||||
const body = {name: (document.getElementById('nf-name').value || '').trim(),
|
||||
format: document.getElementById('nf-format').value,
|
||||
events: events,
|
||||
agg_window: parseInt(document.getElementById('nf-agg').value, 10) || 0,
|
||||
rate_limit_per_min: parseInt(document.getElementById('nf-rate').value, 10) || 0,
|
||||
body_template: (document.getElementById('nf-body').value || '')};
|
||||
const url = (document.getElementById('nf-url').value || '').trim();
|
||||
if(url) body.url = url; // 留空 = 不修改(后端保留原值)
|
||||
const sec = (document.getElementById('nf-secret').value || '').trim();
|
||||
if(sec) body.secret = sec;
|
||||
return body;
|
||||
}
|
||||
|
||||
function saveNotifyHook(){
|
||||
const body = collectNotifyForm();
|
||||
if(!body.name){ showToast('请填名称', 'error'); return; }
|
||||
if(!body.events.length){ showToast('至少要勾一个事件', 'error'); return; }
|
||||
const id = _notifyEditingId;
|
||||
const req = id ? apiPut('/api/notify/webhooks/' + id, body)
|
||||
: apiPost('/api/notify/webhooks', body);
|
||||
req.then(function(r){
|
||||
if(!r || !r.ok){
|
||||
let msg = (r && r.error) || '保存失败';
|
||||
if(r && r.errors && r.errors.length) msg = r.errors.join(';');
|
||||
showToast(msg, 'error');
|
||||
return;
|
||||
}
|
||||
showToast('已保存', 'success');
|
||||
closeNotifyModal();
|
||||
loadNotifyPanel();
|
||||
});
|
||||
}
|
||||
|
||||
function deleteNotifyHook(id){
|
||||
const h = _notifyHooks.find(x => x.id === id) || {};
|
||||
if(!confirm('删除 webhook「' + (h.name || '') + '」?\n(只是不再推送,不影响任何任务/设备数据)')) return;
|
||||
apiDelete('/api/notify/webhooks/' + id).then(function(r){
|
||||
if(!r || !r.ok){ showToast((r && r.error) || '删除失败', 'error'); return; }
|
||||
showToast('已删除', 'success');
|
||||
loadNotifyPanel();
|
||||
});
|
||||
}
|
||||
|
||||
function toggleNotifyHook(id, on){
|
||||
apiPut('/api/notify/webhooks/' + id, {enabled: on}).then(function(r){
|
||||
if(!r || !r.ok){ showToast((r && r.error) || '操作失败', 'error'); return; }
|
||||
loadNotifyPanel();
|
||||
});
|
||||
}
|
||||
|
||||
function testNotifyHook(id){
|
||||
showToast('正在发送测试消息…', 'success');
|
||||
apiPost('/api/notify/webhooks/' + id + '/test', {}).then(function(r){
|
||||
if(!r){ showToast('请求失败', 'error'); return; }
|
||||
const detail = 'HTTP ' + (r.http_status == null ? '-' : r.http_status)
|
||||
+ (r.errcode ? ' · errcode ' + r.errcode : '')
|
||||
+ (r.elapsed_ms != null ? ' · ' + r.elapsed_ms + 'ms' : '')
|
||||
+ (r.attempts > 1 ? ' · 重试 ' + r.attempts + ' 次' : '')
|
||||
+ (r.error ? '\n' + r.error : '');
|
||||
showToast(r.ok ? ('测试消息已发出(' + detail + ')') : ('发送失败(' + detail + ')'),
|
||||
r.ok ? 'success' : 'error');
|
||||
loadNotifyPanel();
|
||||
});
|
||||
}
|
||||
|
||||
function previewNotifyHook(){
|
||||
const body = collectNotifyForm();
|
||||
apiPost('/api/notify/preview', {hook: body, event: 'notify.test'}).then(function(r){
|
||||
const box = document.getElementById('nf-preview');
|
||||
if(!box) return;
|
||||
if(!r || !r.ok){ box.innerHTML = '<span style="color:#dc2626">预览失败:' + esc((r && r.error) || '') + '</span>'; return; }
|
||||
let html = '<div class="help">UTF-8 字节数:' + (r.bytes == null ? '-' : r.bytes)
|
||||
+ (r.byte_limit ? ' / 上限 ' + r.byte_limit : '')
|
||||
+ (r.truncated ? ' <b style="color:#b45309">(会截断)</b>' : '') + '</div>';
|
||||
html += '<pre style="max-height:220px;overflow:auto;font-size:11px;background:#f6f7f9;padding:8px;border-radius:6px">'
|
||||
+ esc(r.request_body || '') + '</pre>';
|
||||
box.innerHTML = html;
|
||||
});
|
||||
}
|
||||
|
||||
function saveNotifyGlobal(){
|
||||
const on = document.getElementById('notify-global').checked;
|
||||
const body = {global_enabled: on,
|
||||
default_agg_window: parseInt(document.getElementById('notify-agg').value, 10) || 0,
|
||||
default_rate_limit: parseInt(document.getElementById('notify-rate').value, 10) || 18};
|
||||
apiPost('/api/notify/settings', body).then(function(r){
|
||||
if(!r || !r.ok){ showToast((r && r.error) || '保存失败', 'error'); return; }
|
||||
showToast('已保存(新配置对之后的事件生效)', 'success');
|
||||
loadNotifyPanel();
|
||||
});
|
||||
}
|
||||
|
||||
function toggleNotifyGlobal(el){
|
||||
apiPost('/api/notify/settings', {global_enabled: el.checked}).then(function(r){
|
||||
if(!r || !r.ok){ el.checked = !el.checked; showToast((r && r.error) || '操作失败', 'error'); return; }
|
||||
showToast(el.checked ? '通知总开关:已开启' : '通知总开关:已关闭(所有 webhook 暂停推送)', 'success');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user