9bba58a529
Telegram:
- POST /api/settings/telegram/test: send test message (checks token+chat_id)
- GET /api/settings/telegram/chats: call getUpdates, return up to 5 chats
- settings.html: test button + detect chat IDs with click-to-fill
Agent IP allowlist (web/agent/agent.py):
- allowed_ips config: list of trusted IPs/CIDRs from config.json
- _ip_allowed(): checks exact IP, CIDR (ipaddress module), hostname
- verify_api_key(): now checks IP allowlist before API key
- /health: also checks IP allowlist
- install.sh: auto-extracts central server IP from --url, adds to allowed_ips
Agent auto-upgrade (server/api/servers.py):
- POST /api/servers/{id}/upgrade-agent: SSH → curl new agent.py → systemctl restart
- servers.html: 升级 Agent button in Agent tab (only when online)
Alert history (new page):
- domain/models: AlertLog table (server_id, type, value, is_recovery, created_at)
- migrations.py: CREATE TABLE IF NOT EXISTS alert_logs
- websocket.py: _save_alert_log() called from broadcast_alert/recovery
- settings.py: GET /api/alert-history/ (paginated, filters), GET /stats
- main.py: register alert_history_router
- alerts.html: new page with stats cards, top-servers bar chart, alert list
- layout.js: 🔔 告警中心 added to sidebar nav
Co-authored-by: Cursor <cursoragent@cursor.com>
187 lines
11 KiB
HTML
187 lines
11 KiB
HTML
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 告警中心</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
|
|
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
|
|
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
|
|
<div class="flex-1 flex flex-col min-w-0">
|
|
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
|
|
<div class="flex items-center gap-4">
|
|
<button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button>
|
|
<h1 class="text-white font-semibold">告警中心</h1>
|
|
</div>
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<select id="serverFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
|
<option value="">全部服务器</option>
|
|
</select>
|
|
<select id="typeFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
|
<option value="">全部类型</option>
|
|
<option value="cpu">CPU</option>
|
|
<option value="mem">内存</option>
|
|
<option value="disk">磁盘</option>
|
|
<option value="time_drift">时钟偏差</option>
|
|
<option value="system">系统</option>
|
|
</select>
|
|
<select id="kindFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
|
<option value="">告警+恢复</option>
|
|
<option value="alert">仅告警</option>
|
|
<option value="recovery">仅恢复</option>
|
|
</select>
|
|
<input id="dateFrom" type="date" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
|
<span class="text-slate-600 text-xs">至</span>
|
|
<input id="dateTo" type="date" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
|
<button onclick="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
|
</div>
|
|
</header>
|
|
<main class="flex-1 overflow-y-auto p-6 space-y-6">
|
|
|
|
<!-- Stats cards -->
|
|
<div id="statsSection" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"></div>
|
|
|
|
<!-- Top alerting servers -->
|
|
<div id="topServersSection" class="hidden bg-slate-900 rounded-xl border border-slate-800 p-4">
|
|
<h2 class="text-white font-medium text-sm mb-3">告警最多的服务器(近 7 天)</h2>
|
|
<div id="topServersList" class="space-y-2"></div>
|
|
</div>
|
|
|
|
<!-- Alert list -->
|
|
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-x-auto">
|
|
<table class="w-full text-sm min-w-[700px]">
|
|
<thead class="bg-slate-800/50 text-slate-400 text-xs uppercase">
|
|
<tr>
|
|
<th class="text-left px-4 py-3">状态</th>
|
|
<th class="text-left px-4 py-3">类型</th>
|
|
<th class="text-left px-4 py-3">服务器</th>
|
|
<th class="text-left px-4 py-3">数值</th>
|
|
<th class="text-right px-4 py-3">时间</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="alertsTbody" class="divide-y divide-slate-800">
|
|
<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div id="pagination" class="hidden flex items-center justify-between text-sm">
|
|
<span id="pageInfo" class="text-slate-500 text-xs"></span>
|
|
<div class="flex gap-2">
|
|
<button onclick="prevPage()" id="prevBtn" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">← 上一页</button>
|
|
<button onclick="nextPage()" id="nextBtn" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">下一页 →</button>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
<script src="/app/api.js"></script>
|
|
<script src="/app/layout.js"></script>
|
|
<script>
|
|
initLayout('alerts');
|
|
const PAGE_SIZE = 50;
|
|
let _offset = 0, _total = 0;
|
|
|
|
const TYPE_LABEL = {cpu:'CPU',mem:'内存',disk:'磁盘',time_drift:'时钟偏差',system:'系统'};
|
|
const TYPE_COLOR = {cpu:'text-orange-400',mem:'text-blue-400',disk:'text-yellow-400',time_drift:'text-purple-400',system:'text-slate-400'};
|
|
|
|
async function loadServers(){
|
|
const r=await apiFetch(API+'/servers/?per_page=200');if(!r)return;
|
|
const data=await r.json();
|
|
const sel=document.getElementById('serverFilter');
|
|
(data.items||[]).forEach(s=>{const o=document.createElement('option');o.value=s.id;o.textContent=s.name;sel.appendChild(o)});
|
|
}
|
|
|
|
async function loadStats(){
|
|
const r=await apiFetch(API+'/alert-history/stats?days=7');if(!r)return;
|
|
const d=await r.json();
|
|
// Top servers
|
|
if(d.top_servers&&d.top_servers.length){
|
|
document.getElementById('topServersSection').classList.remove('hidden');
|
|
document.getElementById('topServersList').innerHTML=d.top_servers.map((s,i)=>`
|
|
<div class="flex items-center gap-3">
|
|
<span class="w-5 text-slate-600 text-xs text-right">${i+1}</span>
|
|
<div class="flex-1 bg-slate-800 rounded-full h-2 overflow-hidden">
|
|
<div class="h-full bg-red-500/70 rounded-full transition-all" style="width:${Math.round(s.count/d.top_servers[0].count*100)}%"></div>
|
|
</div>
|
|
<span class="text-slate-300 text-xs w-32 truncate">${esc(s.server_name)}</span>
|
|
<span class="text-red-400 text-xs font-medium w-12 text-right">${s.count} 次</span>
|
|
</div>`).join('');
|
|
}
|
|
// Stats cards
|
|
const totalAlerts=d.daily.reduce((s,r)=>s+r.alerts,0);
|
|
const totalRecoveries=d.daily.reduce((s,r)=>s+r.recoveries,0);
|
|
document.getElementById('statsSection').innerHTML=`
|
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
|
<div class="text-slate-400 text-xs mb-1">近 7 天告警次数</div>
|
|
<div class="text-red-400 text-3xl font-bold">${totalAlerts}</div>
|
|
</div>
|
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
|
<div class="text-slate-400 text-xs mb-1">近 7 天恢复次数</div>
|
|
<div class="text-green-400 text-3xl font-bold">${totalRecoveries}</div>
|
|
</div>
|
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
|
<div class="text-slate-400 text-xs mb-1">告警最多的类型</div>
|
|
<div class="text-white text-xl font-bold">${getTopType(d)}</div>
|
|
</div>
|
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
|
<div class="text-slate-400 text-xs mb-1">告警最多的服务器</div>
|
|
<div class="text-white text-base font-bold truncate">${d.top_servers[0]?.server_name||'--'}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function getTopType(d){
|
|
// Just show the top server's alert type label — we'd need per-type stats for this
|
|
return '见详情';
|
|
}
|
|
|
|
async function loadAlerts(){
|
|
const serverId=document.getElementById('serverFilter').value;
|
|
const alertType=document.getElementById('typeFilter').value;
|
|
const kind=document.getElementById('kindFilter').value;
|
|
const dateFrom=document.getElementById('dateFrom').value;
|
|
const dateTo=document.getElementById('dateTo').value;
|
|
|
|
let url=`${API}/alert-history/?limit=${PAGE_SIZE}&offset=${_offset}`;
|
|
if(serverId)url+='&server_id='+serverId;
|
|
if(alertType)url+='&alert_type='+alertType;
|
|
if(kind==='alert')url+='&is_recovery=false';
|
|
if(kind==='recovery')url+='&is_recovery=true';
|
|
if(dateFrom)url+='&date_from='+encodeURIComponent(dateFrom);
|
|
if(dateTo)url+='&date_to='+encodeURIComponent(dateTo+'T23:59:59');
|
|
|
|
const r=await apiFetch(url);if(!r)return;
|
|
const data=await r.json();
|
|
_total=data.total||0;
|
|
const logs=data.items||[];
|
|
const tbody=document.getElementById('alertsTbody');
|
|
|
|
if(!logs.length){tbody.innerHTML='<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">暂无告警记录</td></tr>';document.getElementById('pagination').classList.add('hidden');return}
|
|
|
|
tbody.innerHTML=logs.map(l=>{
|
|
const isRec=l.is_recovery;
|
|
const statusBadge=isRec
|
|
?'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-green-900/30 text-green-400 border border-green-500/30">🟢 恢复</span>'
|
|
:'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-red-900/30 text-red-400 border border-red-500/30">🔴 告警</span>';
|
|
const typeColor=TYPE_COLOR[l.alert_type]||'text-slate-400';
|
|
return `<tr class="hover:bg-slate-800/30 transition">
|
|
<td class="px-4 py-2.5">${statusBadge}</td>
|
|
<td class="px-4 py-2.5 ${typeColor} text-xs font-medium">${esc(TYPE_LABEL[l.alert_type]||l.alert_type)}</td>
|
|
<td class="px-4 py-2.5 text-slate-300 text-xs">${esc(l.server_name||'#'+l.server_id)}</td>
|
|
<td class="px-4 py-2.5 text-slate-400 text-xs font-mono">${esc(l.value||'--')}</td>
|
|
<td class="px-4 py-2.5 text-slate-500 text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
const pg=document.getElementById('pagination');
|
|
pg.classList.remove('hidden');
|
|
const curPage=Math.floor(_offset/PAGE_SIZE)+1;
|
|
const totalPages=Math.max(1,Math.ceil(_total/PAGE_SIZE));
|
|
document.getElementById('pageInfo').textContent=`共 ${_total} 条 · 第 ${curPage}/${totalPages} 页`;
|
|
document.getElementById('prevBtn').disabled=_offset===0;
|
|
document.getElementById('nextBtn').disabled=_offset+PAGE_SIZE>=_total;
|
|
}
|
|
|
|
function prevPage(){_offset=Math.max(0,_offset-PAGE_SIZE);loadAlerts()}
|
|
function nextPage(){_offset+=PAGE_SIZE;loadAlerts()}
|
|
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
|
function fmt(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit'})}
|
|
|
|
Promise.all([loadServers(),loadStats()]).then(()=>loadAlerts());
|
|
</script>
|
|
</body></html>
|