// 系统 → 通知:多 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 = '还没有配置 webhook。点「+ 新建 Webhook」加一条——' + '比如把「任务失败」推到企业微信群。'; 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 '' + '' + esc(h.name) + (h.secret_set ? ' 🔑' : '') + '' + '' + esc(fmt) + '' + '' + esc(h.url || '') + '' + '' + evs + ' 个事件' + '' + (h.enabled ? '启用' : '停用') + '' + '' + ' ' + ' ' + ' ' + '' + ''; }).join(''); } function renderNotifyLogs(logs){ const tb = document.getElementById('tb-notify-logs'); if(!tb) return; if(!logs.length){ tb.innerHTML = '本次运行还没有发送记录(重启后清空,历史见 logs/notify.log)'; 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 ? '成功' : '失败'; const detail = r.ok ? ('HTTP ' + (r.http_status == null ? '' : r.http_status) + (r.errcode ? ' errcode ' + r.errcode : '')) : esc(r.error || ''); return '' + esc(t) + '' + '' + esc(r.hook_name || '') + '' + '' + esc(r.event || '') + '' + '' + st + '' + '' + detail + (r.elapsed_ms != null ? ' · ' + r.elapsed_ms + 'ms' : '') + ''; }).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 ''; }).join(''); const box = document.getElementById('notify-modal-box'); if(!box) return; box.innerHTML = '
' + '
' + (id ? '编辑 Webhook' : '新建 Webhook') + '
' + '' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '' + '
' + (h.url ? '已配置:' + esc(h.url) + '(留空=不修改)' : '企业微信:群机器人 → 复制 Webhook 地址') + '
' + '
' + '
' + '
' + '
' + '' + '
0=不聚合
' + '
' + '' + '
企微硬限 20
' + '
' + '
' + '
按类别分的完整事件目录;' + '带 ★ 的是建议开启的。也可以直接写通配(如 task.*)——在下面的芯片框里加。
' + '
' + '
' + '
' + '
' + '
' + '
' + '' + '
' + '
点「预览请求体」看真实会发出去的内容与字节数。
' + '
' // 底部操作栏(缺了它就只能靠刷新页面逃出去) + '
' + '' + '' + '' + 'Esc 或点空白处也能关' + '
'; 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 ''; }).join(''); return '
' + esc(cat) + '
' + body + '
'; }).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 '' + esc(p) + ' ×'; }).join('') || '(无)'; } 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 = '预览失败:' + esc((r && r.error) || '') + ''; return; } let html = '
UTF-8 字节数:' + (r.bytes == null ? '-' : r.bytes) + (r.byte_limit ? ' / 上限 ' + r.byte_limit : '') + (r.truncated ? ' (会截断)' : '') + '
'; html += '
'
      + esc(r.request_body || '') + '
'; 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'); }); }