用户报:新建 webhook 弹窗**没有关闭按钮**——点进去不想建就出不来了(只能刷新页面)。 顺着查发现更严重的一处:**底部操作栏整个漏了**——「创建/保存」「预览」「取消」全都没有, 只是靠"以为有保存按钮所以先点开看看"才没被立刻发现:其实填完也提交不了。 修(static/admin/notify.js + monitor.html): - 弹窗顶部加「✕ 关闭」,底部加「创建/保存 · 预览请求体 · 取消」操作栏 - 四条关闭路径都可用:✕ / 取消 / **Esc** / **点空白处**(overlay 的 onclick) 同批复核出的其它问题: - 通配订阅区被我上一版写成永远隐藏(`display:none` 且没人改回来)→ 恢复可见 (任何格式都适用,标签补成「通配订阅(可选,任何格式都适用)」) - `_notifyFormatChanged` 里残留 `(f === 'json' || true)` 的无意义判断 → 清掉 - 工具条上一个 input 写了**两个 style 属性**(后者会被浏览器忽略)→ 合并成一个 复核方式(这次全部**走页面按钮**,不再只测接口): - UI 全流程 13 项:四条关闭路径 + 底部按钮存在 + 页面创建(列出/打码 URL/事件数) + 编辑回填与改名 + 发送测试(假接收端真收到)+ 页面删除,无 JS 报错 - 通配订阅 6 项:区可见 / 非法通配被拒 / 芯片渲染 / 保存后列出 / 编辑回填 / 清理 - 接口边界 10 项:提交打码 URL 不覆盖真实值、只传 enabled 的局部更新、非法模板被拒、 预览字节数、limit 非法值兜底、404 分支、未登录被挡 教训:交付物是界面时,自测必须走界面点一遍——上一轮我用接口建的 webhook, 把「按钮不存在」整类问题漏掉了。
344 lines
18 KiB
JavaScript
344 lines
18 KiB
JavaScript
// 系统 → 通知:多 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="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>'
|
||
+ '<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"><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();
|
||
_notifyFormatChanged();
|
||
document.getElementById('notify-modal-overlay').style.display = 'flex';
|
||
}
|
||
|
||
function _notifyFormatChanged(){
|
||
const f = (document.getElementById('nf-format') || {}).value;
|
||
// 只有「通用 JSON」才需要自定义请求体模板
|
||
const bodyWrap = document.getElementById('nf-body-wrap');
|
||
if(bodyWrap) bodyWrap.style.display = (f === 'json') ? 'block' : 'none';
|
||
}
|
||
|
||
// 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');
|
||
});
|
||
}
|