Files
RustProxyAdmin/static/js/alerts.js
T
2026-09-30 16:11:51 +08:00

238 lines
14 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.
// P12 —— 告警中心前端(渠道 / 规则 / 事件历史 三个标签页)
let ALERT_EVENT_TYPES = []; // [{event_type, name, default_params}]
let ALERT_CHANNELS = []; // [{id, name, kind}]
let evPage = 1;
function initAlerts(){
switchTab('channels');
loadEventTypes();
}
function switchTab(t){
document.querySelectorAll('#tab-channels,#tab-rules,#tab-events').forEach(d => d.style.display = 'none');
document.getElementById('tab-'+t).style.display = '';
document.querySelectorAll('.tabs button').forEach(b => b.classList.toggle('active', b.dataset.tab === t));
if (t === 'channels') loadChannels();
if (t === 'rules') loadRules();
if (t === 'events') loadEvents();
}
async function loadEventTypes(){
try {
const r = await api('GET', '/api/alerts/rules');
ALERT_EVENT_TYPES = r.data.event_types || [];
const sel = document.getElementById('ev-type');
sel.innerHTML = '<option value="">全部类型</option>' + ALERT_EVENT_TYPES.map(e =>
`<option value="${e.event_type}">${esc(e.name)} (${e.event_type})</option>`).join('');
} catch(e){}
}
// ============ 渠道 ============
function channelFormHtml(kind, cfg){
cfg = cfg || {};
if (kind === 'telegram') return `
<div class="form-row"><label>Bot Token</label><input id="f-bot_token" value="${esc(cfg.bot_token||'')}" placeholder="123456:ABC..."></div>
<div class="form-row"><label>Chat ID</label><input id="f-chat_id" value="${esc(cfg.chat_id||'')}" placeholder="-1001234567890"></div>
<div class="form-row"><label>代理(可选)</label><input id="f-proxy" value="${esc(cfg.proxy||'')}" placeholder="socks5://127.0.0.1:1080"></div>`;
if (kind === 'webhook') return `
<div class="form-row"><label>URL</label><input id="f-url" value="${esc(cfg.url||'')}" placeholder="https://example.com/hook"></div>
<div class="form-row"><label>签名密钥(可选,用于 X-RPA-Signature 验签)</label><input id="f-secret" value="${esc(cfg.secret||'')}" placeholder="留空不签名"></div>
<div class="form-row"><label>自定义请求头(可选,JSON)</label><input id="f-headers" value="${esc(cfg.headers ? JSON.stringify(cfg.headers) : '')}" placeholder='{"X-Token":"abc"}'></div>`;
if (kind === 'email') return `
<div class="form-row"><label>SMTP 服务器</label><input id="f-host" value="${esc(cfg.host||'')}" placeholder="smtp.example.com"></div>
<div class="form-row"><label>端口 / 加密</label><div style="display:flex;gap:8px"><input id="f-port" type="number" value="${esc(cfg.port||587)}"><select id="f-tls"><option value="starttls">STARTTLS</option><option value="plain">明文</option></select></div></div>
<div class="form-row"><label>用户名(可选)</label><input id="f-username" value="${esc(cfg.username||'')}" placeholder="user"></div>
<div class="form-row"><label>密码(可选)</label><input id="f-password" type="password" value="${esc(cfg.password||'')}" placeholder="留空则回传 ****** 表示不改"></div>
<div class="form-row"><label>发件人</label><input id="f-from" value="${esc(cfg.from||'')}" placeholder="rpa@example.com"></div>
<div class="form-row"><label>收件人(每行一个)</label><textarea id="f-to" rows="3">${esc((cfg.to||[]).join('\n'))}</textarea></div>`;
return '';
}
function collectChannelCfg(kind){
const v = id => document.getElementById(id).value.trim();
if (kind === 'telegram') return { bot_token: v('f-bot_token'), chat_id: v('f-chat_id'), proxy: v('f-proxy') };
if (kind === 'webhook') {
let headers = {};
try { if (v('f-headers')) headers = JSON.parse(v('f-headers')); } catch(e){ toast('请求头 JSON 格式错误', true); return null; }
return { url: v('f-url'), secret: v('f-secret'), headers };
}
if (kind === 'email') return {
host: v('f-host'), port: parseInt(v('f-port'))||587, tls: document.getElementById('f-tls').value,
username: v('f-username'), password: v('f-password'), from: v('f-from'),
to: v('f-to').split('\n').map(s=>s.trim()).filter(Boolean),
};
return null;
}
async function loadChannels(){
try {
const r = await api('GET', '/api/alerts/channels');
const items = r.data || [];
ALERT_CHANNELS = items;
document.getElementById('ch-rows').innerHTML = items.map(c => {
let cfg = '';
const cf = c.config || {};
if (c.kind === 'telegram') cfg = `chat_id=${esc(cf.chat_id||'-')}`;
else if (c.kind === 'webhook') cfg = esc(cf.url||'-');
else if (c.kind === 'email') cfg = `${esc(cf.host||'-')}:${esc(cf.port||'')} → ${esc((cf.to||[]).join(', '))}`;
return `<tr><td>${esc(c.name)}</td><td>${esc(c.kind)}</td><td class="muted" style="max-width:320px;overflow:hidden;text-overflow:ellipsis">${cfg}</td>
<td>${c.enabled ? '<span class="badge ok">启用</span>' : '<span class="badge">停用</span>'}</td>
<td>
${IS_VIEWER ? '' : `
<button class="btn btn-sm" onclick="testChannel(${c.id})">发送测试</button>
<button class="btn btn-sm" onclick='editChannel(${c.id})'>编辑</button>
<button class="btn btn-sm btn-danger" onclick="delChannel(${c.id})">删除</button>`}
</td></tr>`;
}).join('') || '<tr><td colspan="5" class="muted">暂无渠道,点击"新建渠道"添加</td></tr>';
} catch(e){ toast('加载渠道失败:'+e.message, true); }
}
async function editChannel(id){
const ch = id ? ALERT_CHANNELS.find(c => c.id === id) : null;
const kind = ch ? ch.kind : 'telegram';
const kinds = ['telegram','webhook','email'];
openModal(id ? '编辑渠道' : '新建渠道', `
<div class="form-row"><label>名称</label><input id="f-name" value="${esc(ch?ch.name:'')}"></div>
<div class="form-row"><label>类型</label><select id="f-kind" ${id?'disabled':''}>${kinds.map(k=>`<option value="${k}" ${k===kind?'selected':''}>${k}</option>`).join('')}</select></div>
<div id="ch-form">${channelFormHtml(kind, ch ? ch.config : {})}</div>
<div class="form-row"><label>状态</label><select id="f-enabled"><option value="1" ${(ch?ch.enabled:1)?'selected':''}>启用</option><option value="0" ${ch&&!ch.enabled?'selected':''}>停用</option></select></div>
`, async () => {
const name = document.getElementById('f-name').value.trim();
if (!name) { toast('名称不能为空', true); return false; }
const cfg = collectChannelCfg(document.getElementById('f-kind').value);
if (!cfg) return false;
try {
if (id) await api('PUT', '/api/alerts/channels/'+id, { name, kind: ch.kind, config: cfg, enabled: parseInt(document.getElementById('f-enabled').value) });
else await api('POST', '/api/alerts/channels', { name, kind: document.getElementById('f-kind').value, config: cfg, enabled: parseInt(document.getElementById('f-enabled').value) });
toast(id ? '已更新' : '已创建'); loadChannels();
} catch(e){ toast('保存失败:'+e.message, true); return false; }
});
document.getElementById('f-kind').onchange = e => {
document.getElementById('ch-form').innerHTML = channelFormHtml(e.target.value, {});
};
}
async function testChannel(id){
try {
await api('POST', `/api/alerts/channels/${id}/test`);
toast('测试消息已发送');
} catch(e){ toast('测试发送失败:'+e.message, true); }
}
function delChannel(id){
confirmDo('删除该渠道?已绑定到规则的会被自动移除。', async () => {
try { await api('DELETE', '/api/alerts/channels/'+id); toast('已删除'); loadChannels(); }
catch(e){ toast('删除失败:'+e.message, true); }
}, '删除');
}
// ============ 规则 ============
async function loadRules(){
try {
const r = await api('GET', '/api/alerts/rules');
const items = r.data.items || [];
const nameOf = id => { const c = ALERT_CHANNELS.find(x => x.id === id); return c ? esc(c.name) : '#'+id; };
document.getElementById('ru-rows').innerHTML = items.map(ru => {
const et = ALERT_EVENT_TYPES.find(e => e.event_type === ru.event_type);
const params = JSON.stringify(ru.params);
const chs = (ru.channel_ids||[]).map(nameOf).join('、') || '<span class="muted">未绑定渠道</span>';
return `<tr><td>${esc(ru.name)}</td>
<td>${esc(et?et.name:ru.event_type)} <span class="muted">${esc(ru.event_type)}</span></td>
<td class="muted" style="max-width:200px;overflow:hidden;text-overflow:ellipsis">${esc(params)}</td>
<td>${chs}</td>
<td>${ru.cooldown_secs}s</td>
<td>${ru.enabled ? '<span class="badge ok">启用</span>' : '<span class="badge">停用</span>'}</td>
<td>
${IS_VIEWER ? '' : `
<button class="btn btn-sm" onclick="toggleRule(${ru.id})">${ru.enabled?'停用':'启用'}</button>
<button class="btn btn-sm" onclick='editRule(${ru.id})'>编辑</button>
<button class="btn btn-sm btn-danger" onclick="delRule(${ru.id})">删除</button>`}
</td></tr>`;
}).join('') || '<tr><td colspan="7" class="muted">暂无规则</td></tr>';
} catch(e){ toast('加载规则失败:'+e.message, true); }
}
let RULE_CACHE = [];
async function editRule(id){
const r = id ? (await api('GET', '/api/alerts/rules')).data.items.find(x => x.id === id) : null;
if (id && !r) return;
const et = ALERT_EVENT_TYPES.find(e => e.event_type === (r ? r.event_type : 'node_offline'));
openModal(id ? '编辑规则' : '新建规则', `
<div class="form-row"><label>名称</label><input id="f-name" value="${esc(r?r.name:'')}"></div>
<div class="form-row"><label>事件类型</label><select id="f-event_type">${ALERT_EVENT_TYPES.map(e=>`<option value="${e.event_type}" ${e.event_type===(r?r.event_type:'node_offline')?'selected':''}>${esc(e.name)} (${e.event_type})</option>`).join('')}</select></div>
<div class="form-row"><label>阈值参数(JSON)</label><input id="f-params" value='${esc(r ? JSON.stringify(r.params) : et.default_params)}'></div>
<div class="form-row"><label>绑定渠道(多选)</label><div>${ALERT_CHANNELS.map(c=>`<label style="margin-right:12px"><input type="checkbox" class="f-ch" value="${c.id}" ${r&&r.channel_ids.includes(c.id)?'checked':''} style="width:auto"> ${esc(c.name)}</label>`).join('') || '<span class="muted">暂无渠道</span>'}</div></div>
<div class="form-row"><label>冷却(秒,同一规则同一事件窗口内去重)</label><input id="f-cooldown" type="number" value="${r?r.cooldown_secs:3600}"></div>
<div class="form-row"><label>状态</label><select id="f-enabled"><option value="1" ${(r?r.enabled:1)?'selected':''}>启用</option><option value="0" ${r&&!r.enabled?'selected':''}>停用</option></select></div>
`, async () => {
const name = document.getElementById('f-name').value.trim();
if (!name) { toast('名称不能为空', true); return false; }
let params = {};
try { params = JSON.parse(document.getElementById('f-params').value || '{}'); }
catch(e){ toast('参数 JSON 格式错误', true); return false; }
const channel_ids = [...document.querySelectorAll('.f-ch:checked')].map(el => parseInt(el.value));
const payload = { name, event_type: document.getElementById('f-event_type').value, params, channel_ids,
cooldown_secs: parseInt(document.getElementById('f-cooldown').value)||0, enabled: parseInt(document.getElementById('f-enabled').value) };
try {
if (id) await api('PUT', '/api/alerts/rules/'+id, payload);
else await api('POST', '/api/alerts/rules', payload);
toast(id ? '已更新' : '已创建'); loadRules();
} catch(e){ toast('保存失败:'+e.message, true); return false; }
});
document.getElementById('f-event_type').onchange = e => {
const t = ALERT_EVENT_TYPES.find(x => x.event_type === e.target.value);
if (t) document.getElementById('f-params').value = t.default_params;
};
}
async function toggleRule(id){
try { await api('POST', `/api/alerts/rules/${id}/toggle`); loadRules(); }
catch(e){ toast('操作失败:'+e.message, true); }
}
function delRule(id){
confirmDo('删除该规则?', async () => {
try { await api('DELETE', '/api/alerts/rules/'+id); toast('已删除'); loadRules(); }
catch(e){ toast('删除失败:'+e.message, true); }
}, '删除');
}
// ============ 事件历史 ============
const SEV = { info: '<span class="badge">ℹ info</span>', warning: '<span class="badge warn">⚠ warning</span>', critical: '<span class="badge danger">🔴 critical</span>' };
const EST = { pending: '<span class="badge warn">待发送</span>', sent: '<span class="badge ok">已发送</span>', failed: '<span class="badge danger">失败</span>' };
async function loadEvents(){
try {
const qs = `page=${evPage}&page_size=20&status=${document.getElementById('ev-status').value}&event_type=${document.getElementById('ev-type').value}`;
const r = await api('GET', '/api/alerts/events?'+qs);
const d = r.data;
document.getElementById('ev-rows').innerHTML = d.items.map(e =>
`<tr><td class="muted">${esc(e.created_at)}</td><td>${esc(e.event_type)}</td>
<td style="max-width:280px"><b>${esc(e.title)}</b><div class="muted">${esc(e.body).slice(0,120)}</div></td>
<td>${SEV[e.severity]||esc(e.severity)}</td><td>${EST[e.status]||esc(e.status)}</td>
<td>${e.attempts}</td><td class="muted" style="max-width:200px">${esc(e.last_error||'-')}</td>
<td>${(e.status==='failed' && !IS_VIEWER) ? `<button class="btn btn-sm" onclick="retryEvent(${e.id})">重试</button>` : ''}</td></tr>`
).join('') || '<tr><td colspan="8" class="muted">暂无事件</td></tr>';
const pages = Math.max(1, Math.ceil(d.total / d.page_size));
document.getElementById('ev-pager').innerHTML =
`共 ${d.total} 条 <button class="btn btn-sm" ${evPage<=1?'disabled':''} onclick="evPage--;loadEvents()">上一页</button> ${evPage}/${pages} <button class="btn btn-sm" ${evPage>=pages?'disabled':''} onclick="evPage++;loadEvents()">下一页</button>`;
} catch(e){ toast('加载事件失败:'+e.message, true); }
}
async function retryEvent(id){
try { await api('POST', `/api/alerts/events/${id}/retry`); toast('已重新投递'); loadEvents(); }
catch(e){ toast('重试失败:'+e.message, true); }
}
function cleanupEvents(){
openModal('清理历史事件', `<div class="form-row"><label>删除多少天前的事件</label><input id="f-days" type="number" value="30"></div>`, async () => {
try {
const r = await api('POST', '/api/alerts/events/cleanup', { days: parseInt(document.getElementById('f-days').value)||30 });
toast(`已清理 ${r.data.deleted} 条`); loadEvents();
} catch(e){ toast('清理失败:'+e.message, true); return false; }
}, '清理');
}