Files
auto_control/static/admin/notify.js
T
butubb 24ea289c15 feat(通知): 加 Bark 格式 + 格式切换时界面提示全部跟着变
## 用户报的第二件事:格式改了,提示没跟着改
弹窗里的 URL 示例/说明、密钥标签、限流说明都写死成企业微信了——选「通用 JSON」时
占位还是 `qyapi.weixin.qq.com`,限流还写着「企微硬限 20」。现在这些文案挂在**适配器**上
(`url_hint/url_help/secret_label/secret_help/limit_help`),由接口随格式下发,切格式即时更新;
限流值在用户没手动改过时也跟着格式的推荐值走(企微 20 / 通用 JSON 60 / Bark 60)。

## 新格式:Bark(iOS 推送)
- `POST {url}`,body `{title, body, markdown, group, level[, device_key]}`
  —— `markdown` 传富文本、`body` 传纯文本兜底(老版本 App 不认 markdown 字段时也能看清)
- URL 两种填法都支持:直接粘 Bark 复制的那串(`https://api.day.app/<key>`,key 在路径里),
  或填 `https://api.day.app/push` + 把 key 填到「设备 Key」(作为 device_key 发送)
- **成功判定按格式**:Bark 是 `code==200`(企业微信是 `errcode==0`)→ 新增
  `BaseAdapter.ok_codes`,`_post_once` 用它判定。少了这一步 Bark 的每次成功都会被误判成失败
- 正文按 2048 字节截断(走 APNs,体量有限)

## 顺带
- 通用 JSON 的 `secret` 现在会作为 `X-Webhook-Secret` 请求头发出(原先填了没用)
- `_formats()` 改为序列化适配器元信息,新增格式只改一处

## 验证
- 格式切换 14 项断言全绿:三种格式的 URL 示例/密钥标签/密钥说明/限流说明/模板区/限流默认值
  全部跟着切换;下拉里 Bark 出现在已实现区
- Bark 端到端 7 项:`code=200` 判成功、`code=400` 判失败(含重试)、请求体带
  device_key/title/body/markdown/group、URL 里的 key 在接口回显里被打码
- 文档:NOTIFY.md §5 的格式表补 Bark 列(URL 怎么填 / 成功码 / 约束),并说明
  "提示文案挂在适配器上,别写死在页面里"
2026-09-15 14:05:33 +08:00

385 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 系统 → 通知:多 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.*)
let _notifyPrevFormat = ''; // 上一个选中格式(切换时判断限流值要不要跟着走)
let _notifyRateTouched = false; // 用户是否手动改过限流值(没改过就跟随格式的推荐值)
// ================== 加载与渲染 ==================
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="display:flex;align-items:center;margin-bottom:12px">'
+ '<div style="font-size:15px;font-weight:700;flex:1">'
+ (id ? '编辑 Webhook' : '新建 Webhook') + '</div>'
+ '<button class="btn btn-xs" title="关闭(不保存)" onclick="closeNotifyModal()">✕ 关闭</button>'
+ '</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>'
// 占位与说明都由**当前格式**决定(见 _notifyFormatChanged),切换格式会跟着变
+ '<input id="nf-url" class="form-control" value="">'
+ '<div class="help" id="nf-url-help"></div></div>'
+ '<div class="form-row">'
+ '<div class="form-group"><label id="nf-secret-label">签名密钥(可选)</label>'
+ '<input id="nf-secret" type="password" class="form-control" placeholder="'
+ (h.secret_set ? '已配置,留空不修改' : '留空即可') + '">'
+ '<div class="help" id="nf-secret-help"></div></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" '
+ 'oninput="_notifyRateTouched=true" value="'
+ (h.rate_limit_per_min != null ? h.rate_limit_per_min
: ((_notifyFormats.find(x => x.name === (h.format || 'wecom')) || {}).limit_default
|| _notifySettings.default_rate_limit || 18)) + '">'
+ '<div class="help" id="nf-limit-help"></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"><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>'
// 底部操作栏(缺了它就只能靠刷新页面逃出去)
+ '<div style="display:flex;gap:8px;align-items:center;margin-top:16px;padding-top:12px;border-top:1px solid var(--border)">'
+ '<button class="btn btn-primary" onclick="saveNotifyHook()">' + (id ? '保存' : '创建') + '</button>'
+ '<button class="btn" onclick="previewNotifyHook()">预览请求体</button>'
+ '<button class="btn" onclick="closeNotifyModal()">取消</button>'
+ '<span class="help" style="margin-left:auto">Esc 或点空白处也能关</span>'
+ '</div>';
box.querySelector('#nf-body').value = h.body_template || '';
renderEventTree(checked);
renderNotifyPatterns();
_notifyPrevFormat = (h.format || 'wecom');
_notifyRateTouched = (h.rate_limit_per_min != null); // 编辑已有配置时视为"已定"
_notifyFormatChanged();
document.getElementById('notify-modal-overlay').style.display = 'flex';
}
function _notifyFormatChanged(){
const name = (document.getElementById('nf-format') || {}).value;
const meta = _notifyFormats.find(x => x.name === name) || {};
const h = _notifyEditingId
? (_notifyHooks.find(x => x.id === _notifyEditingId) || {}) : {};
// URL:编辑时占位显示现有(打码)地址,新建时显示该格式的示例
const url = document.getElementById('nf-url');
if(url){
url.placeholder = (h.url && h.url !== undefined) ? h.url : (meta.url_hint || 'https://…');
}
const urlHelp = document.getElementById('nf-url-help');
if(urlHelp){
urlHelp.innerHTML = (h.url ? ('已配置:' + esc(h.url) + '(留空=不修改)<br>') : '')
+ esc(meta.url_help || '');
}
// 密钥:不同格式叫法/用途不同(企业微信不需要、Bark 是设备 Key、通用 JSON 是自定义头)
const sl = document.getElementById('nf-secret-label');
if(sl) sl.textContent = (h.secret_set ? '🔑 ' : '') + (meta.secret_label || '密钥(可选)');
const sh = document.getElementById('nf-secret-help');
if(sh) sh.innerHTML = esc(meta.secret_help || '')
+ (h.secret_set ? '<br>已配置过:留空=不修改,想清除请点上面的 🔑 后填写新值' : '');
// 限流:上限因格式而异(企微 20/分、Bark 无硬限)
const lh = document.getElementById('nf-limit-help');
if(lh) lh.textContent = meta.limit_help || '';
// 用户没手动改过限流值时,跟着格式的推荐值走(企微 20 / 通用 JSON 60 / Bark 60)
const rate = document.getElementById('nf-rate');
if(rate && !_notifyRateTouched && meta.limit_default){
rate.value = meta.limit_default;
}
// 请求体模板:只有需要模板的格式(通用 JSON)才显示
const bodyWrap = document.getElementById('nf-body-wrap');
if(bodyWrap) bodyWrap.style.display = meta.needs_template ? 'block' : 'none';
_notifyPrevFormat = name;
}
// Esc 关闭弹窗(点空白处关闭在 overlay 的 onclick 上)
document.addEventListener('keydown', function(ev){
if(ev.key !== 'Escape') return;
const ov = document.getElementById('notify-modal-overlay');
if(ov && ov.style.display !== 'none') closeNotifyModal();
});
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');
});
}