Files
Nexus/web/app/servers.html
T
Your Name 9bba58a529 feat: Telegram test+ChatID, Agent IP allowlist/upgrade, alert history page
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>
2026-05-23 18:27:12 +08:00

567 lines
42 KiB
HTML
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.
<!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><link rel="stylesheet" href="/app/vendor/xterm.css"/><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>
<style>
.detail-panel{max-height:0;overflow:hidden;transition:max-height .3s ease}
.detail-panel.open{max-height:800px}
</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-3">
<select id="categoryFilter" onchange="this.dataset.loaded='';loadServers()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<option value="">全部分类</option>
</select>
<input id="searchInput" type="text" placeholder="搜索服务器..." class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48">
<button onclick="loadServers()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
<button onclick="showAddServer()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 添加</button>
</div>
</header>
<main class="flex-1 overflow-y-auto p-6">
<div id="serversTable" class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<table class="w-full text-sm"><thead class="bg-slate-800/50 text-slate-400 text-xs uppercase"><tr><th class="px-4 py-3 w-10"><input type="checkbox" id="selectAll" onchange="toggleSelectAll(this.checked)" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand"></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-left px-4 py-3">分类</th><th class="text-left px-4 py-3">Agent</th><th class="text-left px-4 py-3">最后心跳</th><th class="text-right px-4 py-3">操作</th></tr></thead><tbody id="serversTbody" class="divide-y divide-slate-800"><tr><td colspan="8" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr></tbody></table>
</div>
<div class="mt-4 flex items-center justify-between text-xs text-slate-500"><span id="serverCount">共 -- 台</span><button onclick="loadServers()" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 rounded-lg transition">刷新</button></div>
<!-- Batch Action Bar -->
<div id="batchBar" class="hidden mt-3 bg-brand/10 border border-brand/30 rounded-xl px-5 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-brand-light text-sm font-medium">已选择 <span id="selectedCount">0</span> 台服务器</span>
<button onclick="clearSelection()" class="text-slate-400 hover:text-white text-xs underline">取消选择</button>
</div>
<div class="flex items-center gap-2">
<button onclick="batchPush()" class="px-4 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">批量推送</button>
<button onclick="batchDelete()" class="px-4 py-1.5 bg-red-600/80 hover:bg-red-600 text-white text-sm rounded-lg transition">批量删除</button>
</div>
</div>
<!-- Server Detail Panel -->
<div id="detailPanel" class="hidden mt-4 bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-800 bg-slate-800/30">
<div class="flex items-center gap-3">
<span id="detailStatus" class="inline-block w-3 h-3 rounded-full"></span>
<h2 id="detailName" class="text-white font-semibold text-lg"></h2>
<span id="detailAddr" class="text-slate-400 text-sm"></span>
</div>
<div class="flex items-center gap-2">
<a id="detailSSHLink" href="#" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">SSH终端</a>
<button onclick="showEditServer(selectedServerId)" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">编辑</button>
<button onclick="hideDetail()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">关闭</button>
</div>
</div>
<!-- Tabs -->
<div class="flex border-b border-slate-800 px-6">
<button onclick="showDetailTab('info')" id="tabInfo" class="px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition">系统信息</button>
<button onclick="showDetailTab('sync')" id="tabSync" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">同步日志</button>
<button onclick="showDetailTab('ssh')" id="tabSSH" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">SSH会话</button>
<button onclick="showDetailTab('agent')" id="tabAgent" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">Agent 安装</button>
</div>
<!-- Tab Content: System Info -->
<div id="tabContentInfo" class="p-6">
<div id="sysInfoGrid" class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">CPU使用率</div><div id="siCPU" class="text-2xl font-bold text-white">--</div></div>
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">内存使用率</div><div id="siMem" class="text-2xl font-bold text-white">--</div></div>
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">磁盘使用率</div><div id="siDisk" class="text-2xl font-bold text-white">--</div></div>
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">系统版本</div><div id="siOS" class="text-lg font-medium text-white truncate">--</div></div>
</div>
<div id="sysInfoMeta" class="mt-4 grid grid-cols-3 gap-4 text-sm">
<div><span class="text-slate-500">描述:</span><span id="siDesc" class="text-slate-300">--</span></div>
<div><span class="text-slate-500">目标路径:</span><span id="siTarget" class="text-slate-300">--</span></div>
<div><span class="text-slate-500">认证方式:</span><span id="siAuth" class="text-slate-300">--</span></div>
</div>
</div>
<!-- Tab Content: Sync Logs -->
<div id="tabContentSync" class="hidden p-6">
<table class="w-full text-sm"><thead class="text-slate-500 text-xs uppercase"><tr><th class="text-left py-2">模式</th><th class="text-left py-2">状态</th><th class="text-left py-2">源路径</th><th class="text-left py-2">耗时</th><th class="text-left py-2">时间</th></tr></thead><tbody id="syncLogTbody"><tr><td colspan="5" class="py-4 text-center text-slate-500">加载中...</td></tr></tbody></table>
</div>
<!-- Tab Content: SSH Sessions -->
<div id="tabContentSSH" class="hidden p-6">
<table class="w-full text-sm"><thead class="text-slate-500 text-xs uppercase"><tr><th class="text-left py-2">来源IP</th><th class="text-left py-2">状态</th><th class="text-left py-2">开始</th><th class="text-left py-2">结束</th></tr></thead><tbody id="sshSessTbody"><tr><td colspan="4" class="py-4 text-center text-slate-500">加载中...</td></tr></tbody></table>
</div>
<!-- Tab Content: Agent Install -->
<div id="tabContentAgent" class="hidden p-6 space-y-4">
<div id="agentInstallContent"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
</div>
</div>
<!-- Add/Edit Server Modal -->
<div id="serverModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50 overflow-y-auto py-8">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3 my-auto">
<h2 id="modalTitle" class="text-white font-semibold text-lg">添加服务器</h2>
<input type="hidden" id="editServerId">
<!-- Basic -->
<div class="text-slate-500 text-xs uppercase tracking-wider mb-1">基本</div>
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">名称 *</label><input id="srvName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="web-server-01"></div>
<div><label class="block text-slate-400 text-xs mb-1">地址 *</label><input id="srvDomain" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="192.168.1.10"></div>
</div>
<div class="grid grid-cols-3 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">SSH端口</label><input id="srvPort" type="number" value="22" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
<div><label class="block text-slate-400 text-xs mb-1">用户名</label><input id="srvUser" value="root" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
<div><label class="block text-slate-400 text-xs mb-1">认证方式</label><select id="srvAuth" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="key">密钥</option><option value="password">密码</option></select></div>
</div>
<div id="srvPasswordRow"><label class="block text-slate-400 text-xs mb-1">密码</label><input id="srvPassword" type="password" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="SSH密码"></div>
<div id="srvKeyPathRow"><label class="block text-slate-400 text-xs mb-1">密钥路径</label><input id="srvKeyPath" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="/root/.ssh/id_rsa"></div>
<!-- Agent -->
<div class="text-slate-500 text-xs uppercase tracking-wider mt-3 mb-1">Agent</div>
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">Agent端口</label><input id="srvAgentPort" type="number" value="8601" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
<div><label class="block text-slate-400 text-xs mb-1">Agent API Key</label><div class="flex items-center gap-2"><input id="srvAgentKey" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm text-xs" placeholder="留空则使用全局API Key"><button type="button" onclick="generateAgentKey()" class="px-2 py-2 bg-slate-700 hover:bg-slate-600 text-white text-xs rounded-lg shrink-0 transition" title="自动生成密钥">🔑</button></div></div>
</div>
<!-- Organization -->
<div class="text-slate-500 text-xs uppercase tracking-wider mt-3 mb-1">组织</div>
<div class="grid grid-cols-3 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">分类</label><input id="srvCategory" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="production"></div>
<div><label class="block text-slate-400 text-xs mb-1">平台</label><select id="srvPlatform" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="">— 无 —</option></select></div>
<div><label class="block text-slate-400 text-xs mb-1">节点</label><select id="srvNode" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="">— 无 —</option></select></div>
</div>
<!-- Other -->
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">目标路径</label><input id="srvTarget" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="/home/deploy/target"></div>
<div><label class="block text-slate-400 text-xs mb-1">备注</label><input id="srvDesc" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="可选"></div>
</div>
<div class="flex gap-2 pt-2"><button onclick="saveServer()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideServerModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
</div>
</div>
</main>
</div>
<script src="/app/api.js"></script>
<script src="/app/layout.js"></script>
<script>
initLayout('servers');
let selectedServerId=null;
let _serversCache={};
let _currentDetailTab='info';
let _selectedIds=new Set();
async function loadServers(){
try{
// Clear selection on reload (server list may have changed)
_selectedIds.clear();
const category=document.getElementById('categoryFilter').value;
const url=API+'/servers/'+(category?'?category='+encodeURIComponent(category):'');
const res=await apiFetch(url);
if(!res) return;
const data=await res.json();
const servers=data.items||data;
const total=data.total||servers.length;
_serversCache={};servers.forEach(s=>_serversCache[s.id]=s);
document.getElementById('serverCount').textContent=`共 ${total} 台`;
// Populate category filter from server data (only on first load)
if(!document.getElementById('categoryFilter').dataset.loaded){
const categories=[...new Set(servers.map(s=>s.category).filter(Boolean))].sort();
const sel=document.getElementById('categoryFilter');
const currentVal=sel.value;
sel.innerHTML='<option value="">全部分类</option>'+categories.map(c=>`<option value="${esc(c)}" ${c===currentVal?'selected':''}>${esc(c)}</option>`).join('');
sel.dataset.loaded='1';
}
const tbody=document.getElementById('serversTbody');
if(!servers.length){tbody.innerHTML='<tr><td colspan="8" class="px-4 py-8 text-center text-slate-500">暂无服务器</td></tr>';return}
tbody.innerHTML=servers.map(s=>`<tr class="hover:bg-slate-800/30 transition cursor-pointer ${selectedServerId===s.id?'bg-brand/5':''}" onclick="selectServer(${s.id})" id="row-${s.id}">
<td class="px-4 py-3" onclick="event.stopPropagation()"><input type="checkbox" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand srv-chk" data-id="${s.id}" onchange="toggleSelect(${s.id},this.checked)" ${_selectedIds.has(s.id)?'checked':''}></td>
<td class="px-4 py-3"><span class="inline-block w-2.5 h-2.5 rounded-full ${s.is_online?'bg-green-400':'bg-red-400'} ${s.is_online?'':'animate-pulse'}"></span></td>
<td class="px-4 py-3 text-white font-medium">${esc(s.name)}${driftBadge(s)}</td>
<td class="px-4 py-3 text-slate-400">${esc(s.domain)}:${s.port||22}</td>
<td class="px-4 py-3 text-slate-400">${esc(s.category||'--')}</td>
<td class="px-4 py-3 text-slate-400">${s.agent_version||'--'}</td>
<td class="px-4 py-3 text-slate-500 text-xs">${fmtTime(s.last_heartbeat)}</td>
<td class="px-4 py-3 text-right"><a href="/app/terminal.html?server_id=${s.id}" onclick="event.stopPropagation()" class="text-brand-light hover:underline text-xs mr-2">终端</a><button onclick="event.stopPropagation();showEditServer(${s.id})" class="text-slate-400 hover:underline text-xs mr-2">编辑</button><button onclick="event.stopPropagation();deleteServer(${s.id})" class="text-red-400 hover:underline text-xs">删除</button></td>
</tr>`).join('');
document.getElementById('selectAll').checked=false;
updateBatchBar();
}catch(e){document.getElementById('serversTbody').innerHTML='<tr><td colspan="8" class="px-4 py-8 text-center text-red-400">加载失败</td></tr>'}
}
async function selectServer(id){
selectedServerId=id;
// Highlight row
document.querySelectorAll('#serversTbody tr').forEach(r=>r.classList.remove('bg-brand/5'));
document.getElementById('row-'+id)?.classList.add('bg-brand/5');
// Show detail panel
const s=_serversCache[id];if(!s)return;
document.getElementById('detailName').textContent=s.name;
document.getElementById('detailAddr').textContent=s.domain+':'+(s.port||22);
document.getElementById('detailStatus').className='inline-block w-3 h-3 rounded-full '+(s.is_online?'bg-green-400':'bg-red-400');
document.getElementById('detailSSHLink').href='/app/terminal.html?server_id='+id;
document.getElementById('detailPanel').classList.remove('hidden');
// Load detail data
loadSystemInfo(s);
showDetailTab('info');
}
function hideDetail(){
document.getElementById('detailPanel').classList.add('hidden');
selectedServerId=null;
document.querySelectorAll('#serversTbody tr').forEach(r=>r.classList.remove('bg-brand/5'));
}
function loadSystemInfo(s){
// System info from Redis (via server object or direct read)
// Agent may send cpu_percent or cpu_usage — normalize both
const sys=s.system_info;
if(sys&&typeof sys==='object'){
const cpu=sys.cpu_percent??sys.cpu_usage??null;
const mem=sys.memory_percent??sys.mem_usage??sys.mem_percent??null;
const disk=sys.disk_percent??sys.disk_usage??null;
document.getElementById('siCPU').textContent=(cpu!=null?cpu.toFixed(1)+'%':'--');
document.getElementById('siCPU').className='text-2xl font-bold '+(cpu>80?'text-red-400':cpu>60?'text-yellow-400':'text-green-400');
document.getElementById('siMem').textContent=(mem!=null?mem.toFixed(1)+'%':'--');
document.getElementById('siMem').className='text-2xl font-bold '+(mem>80?'text-red-400':mem>60?'text-yellow-400':'text-green-400');
document.getElementById('siDisk').textContent=(disk!=null?disk.toFixed(1)+'%':'--');
document.getElementById('siDisk').className='text-2xl font-bold '+(disk>80?'text-red-400':disk>60?'text-yellow-400':'text-green-400');
document.getElementById('siOS').textContent=sys.os||sys.platform||'--';
}else{
document.getElementById('siCPU').textContent='--';
document.getElementById('siMem').textContent='--';
document.getElementById('siDisk').textContent='--';
document.getElementById('siOS').textContent='--';
}
document.getElementById('siDesc').textContent=s.description||'--';
document.getElementById('siTarget').textContent=s.target_path||'--';
document.getElementById('siAuth').textContent=s.auth_method==='password'?'密码':'SSH密钥';
}
async function loadSyncLogs(){
if(!selectedServerId)return;
try{
const r=await apiFetch(API+'/servers/'+selectedServerId+'/logs?limit=20');
if(!r)return;const logs=await r.json();
const tbody=document.getElementById('syncLogTbody');
if(!logs.length){tbody.innerHTML='<tr><td colspan="5" class="py-4 text-center text-slate-500">暂无同步记录</td></tr>';return}
tbody.innerHTML=logs.map(l=>{
const sc=l.status==='success'?'text-green-400':l.status==='failed'?'text-red-400':'text-yellow-400';
return `<tr class="border-b border-slate-800/50"><td class="py-2 text-slate-300">${esc(l.sync_mode||'--')}</td><td class="py-2 ${sc}">${esc(l.status)}</td><td class="py-2 text-slate-500 text-xs max-w-xs truncate">${esc(l.source_path||'--')}</td><td class="py-2 text-slate-500">${l.duration_seconds!=null?l.duration_seconds+'s':'--'}</td><td class="py-2 text-slate-600 text-xs">${fmtTime(l.started_at)}</td></tr>`}).join('');
}catch(e){document.getElementById('syncLogTbody').innerHTML='<tr><td colspan="5" class="py-4 text-center text-red-400">加载失败</td></tr>'}
}
async function loadSSHSessions(){
if(!selectedServerId)return;
try{
const r=await apiFetch(API+'/assets/ssh-sessions?server_id='+selectedServerId+'&limit=20');
if(!r)return;const sessions=await r.json();
const tbody=document.getElementById('sshSessTbody');
if(!sessions.length){tbody.innerHTML='<tr><td colspan="4" class="py-4 text-center text-slate-500">暂无SSH会话</td></tr>';return}
tbody.innerHTML=sessions.map(s=>{
const sc=s.status==='active'?'text-green-400':s.status==='closed'?'text-slate-500':'text-yellow-400';
return `<tr class="border-b border-slate-800/50"><td class="py-2 text-slate-300">${esc(s.remote_addr||'--')}</td><td class="py-2 ${sc}">${esc(s.status)}</td><td class="py-2 text-slate-500 text-xs">${fmtTime(s.started_at)}</td><td class="py-2 text-slate-600 text-xs">${fmtTime(s.closed_at)}</td></tr>`}).join('');
}catch(e){document.getElementById('sshSessTbody').innerHTML='<tr><td colspan="4" class="py-4 text-center text-red-400">加载失败</td></tr>'}
}
function showDetailTab(tab){
_currentDetailTab=tab;
['Info','Sync','SSH','Agent'].forEach(t=>{
document.getElementById('tabContent'+t).classList.toggle('hidden',t.toLowerCase()!==tab);
const btn=document.getElementById('tab'+t);
if(!btn)return;
if(t.toLowerCase()===tab){btn.className='px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition'}
else{btn.className='px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition'}
});
if(tab==='sync')loadSyncLogs();
if(tab==='ssh')loadSSHSessions();
if(tab==='agent')loadAgentInstall();
}
// ── Agent Install Tab ──
async function loadAgentInstall(){
if(!selectedServerId)return;
const el=document.getElementById('agentInstallContent');
el.innerHTML='<div class="text-slate-500 text-center py-4 text-sm">加载中...</div>';
try{
const r=await apiFetch(API+'/servers/'+selectedServerId+'/agent-install-cmd');
if(!r){el.innerHTML='<div class="text-red-400 text-sm">加载失败</div>';return}
const d=await r.json();
const s=_serversCache[selectedServerId]||{};
const statusBadge=d.is_online
?'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-green-900/40 text-green-400 border border-green-500/30">● Agent 在线</span>'
:'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500 border border-slate-700">○ Agent 离线 / 未安装</span>';
const noUrl=!d.base_url;
const noKey=!d.has_agent_key;
let warnings='';
if(noUrl)warnings+=`<div class="text-amber-400 text-xs">⚠ NEXUS_API_BASE_URL 未配置,请在系统设置中填写主站对外 URL,才能生成安装命令</div>`;
if(noKey)warnings+=`<div class="text-amber-400 text-xs">⚠ 尚未生成 Agent API Key,请点击「编辑」→「🔑 生成」后再安装</div>`;
const cmdBlock=d.install_cmd
?`<div class="space-y-2">
<label class="block text-slate-400 text-xs">在子机上执行(需 root 权限)</label>
<div class="flex items-start gap-2">
<code id="agentCmdText" class="flex-1 block px-3 py-3 bg-slate-950 border border-slate-700 rounded-lg text-green-400 text-xs font-mono break-all leading-relaxed">${esc(d.install_cmd)}</code>
<button onclick="copyAgentCmd()" class="shrink-0 px-3 py-3 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition" title="复制命令">📋</button>
</div>
<p class="text-slate-600 text-xs">脚本将自动:安装 Python 依赖 → 下载 agent.py → 写配置 → 设置 systemd 服务 → 开放端口 ${esc(String(d.agent_port))}</p>
</div>`
:`<div class="text-slate-500 text-sm">(请先配置 URL 和 API Key</div>`;
const remoteBtn=d.install_cmd&&!d.is_online
?`<button onclick="remoteInstallAgent()" id="remoteInstallBtn"
class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">
⚡ 一键远程安装(通过 SSH)
</button>`
:(d.is_online
?`<button disabled class="px-4 py-2 bg-slate-800 text-slate-500 text-sm rounded-lg cursor-not-allowed" title="Agent 已在线">⚡ 远程安装(已在线)</button>`
:`<button disabled class="px-4 py-2 bg-slate-800 text-slate-500 text-sm rounded-lg cursor-not-allowed" title="请先配置 URL 和 API Key">⚡ 远程安装</button>`);
el.innerHTML=`
<div class="flex items-center justify-between">
<h3 class="text-white font-medium">Nexus Agent 安装</h3>
${statusBadge}
</div>
${warnings}
<div class="rounded-xl border border-slate-700 bg-slate-800/30 p-4 space-y-4">
${cmdBlock}
</div>
<div class="flex items-center gap-3">
${remoteBtn}
${d.is_online?`<button onclick="upgradeAgent()" id="upgradeAgentBtn"
class="px-3 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="下载最新 agent.py 并重启服务">
⬆ 升级 Agent
</button>`:''}
<span class="text-slate-500 text-xs">安装约需 30-60 秒;安装完成后 Agent 发送首次心跳约需再等 60 秒,状态徽章将自动更新为 <span class="text-green-400">● Agent 在线</span></span>
</div>
<div id="remoteInstallLog" class="hidden rounded-lg border border-slate-700 bg-slate-950 p-3 max-h-56 overflow-y-auto">
<pre id="remoteInstallLogText" class="text-xs text-slate-400 whitespace-pre-wrap"></pre>
</div>
${!d.is_online?`<div class="flex items-center gap-2 text-xs text-slate-500">
<span>手动安装后,</span>
<button onclick="_startAgentOnlinePoller(${d.server_id});this.textContent='等待中...';this.disabled=true"
class="text-brand-light hover:underline">点击等待 Agent 上线</button>
<span>(最多 2 分钟,自动更新状态)</span>
</div>`:''}`;
}catch(e){el.innerHTML=`<div class="text-red-400 text-sm">加载失败: ${esc(e.message)}</div>`}
}
async function upgradeAgent(){
if(!selectedServerId)return;
const btn=document.getElementById('upgradeAgentBtn');
if(btn){btn.disabled=true;btn.textContent='升级中...'}
const logEl=document.getElementById('remoteInstallLog');
const logText=document.getElementById('remoteInstallLogText');
logEl?.classList.remove('hidden');
logText.textContent='正在下载最新 agent.py 并重启服务...\n';
try{
const r=await apiFetch(API+'/servers/'+selectedServerId+'/upgrade-agent',{method:'POST',headers:apiHeadersJSON()});
if(!r){logText.textContent+='请求失败\n';return}
const d=await r.json();
if(r.ok&&d.success){
logText.textContent+=d.stdout||'';
logText.textContent+='\n✓ Agent 升级成功!';
toast('success','Agent 升级成功');
setTimeout(loadServers,3000);
}else{
logText.textContent+=(d.detail||'升级失败')+'\n';
toast('error','升级失败');
}
}catch(e){logText.textContent+='错误: '+e.message;toast('error','升级请求失败')}
finally{if(btn){btn.disabled=false;btn.textContent='⬆ 升级 Agent'}}
}
function copyAgentCmd(){
const cmd=document.getElementById('agentCmdText')?.textContent;
if(cmd){
if(navigator.clipboard)navigator.clipboard.writeText(cmd).then(()=>toast('success','命令已复制')).catch(()=>toast('error','复制失败'));
else toast('info','请手动选择并复制命令');
}
}
let _agentOnlinePoller = null;
function _startAgentOnlinePoller(serverId){
if(_agentOnlinePoller)clearInterval(_agentOnlinePoller);
let attempts=0;
const MAX=24; // 24 × 5s = 2 minutes
_agentOnlinePoller=setInterval(async()=>{
attempts++;
try{
const r=await apiFetch(API+'/servers/'+serverId);
if(!r)return;
const s=await r.json();
if(s.is_online){
clearInterval(_agentOnlinePoller);_agentOnlinePoller=null;
// Update server cache and badge
if(_serversCache[serverId])_serversCache[serverId].is_online=true;
_updateAgentStatusBadge(true);
const logText=document.getElementById('remoteInstallLogText');
if(logText)logText.textContent+='\n✅ Agent 心跳已收到,服务器在线!';
toast('success','Agent 已上线!');
loadServers(); // refresh list row
}else if(attempts>=MAX){
clearInterval(_agentOnlinePoller);_agentOnlinePoller=null;
const logText=document.getElementById('remoteInstallLogText');
if(logText)logText.textContent+='\n⚠ 等待超时(2分钟)。Agent 已安装,请稍后手动刷新确认是否在线。';
}
}catch(e){}
},5000);
}
function _updateAgentStatusBadge(isOnline){
// Find the status badge element inside agentInstallContent and update it
const el=document.getElementById('agentInstallContent');
if(!el)return;
const badges=el.querySelectorAll('span[class*="rounded-full"]');
if(!badges.length)return;
const badge=badges[0];
if(isOnline){
badge.className='inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-green-900/40 text-green-400 border border-green-500/30';
badge.textContent='● Agent 在线';
// Disable remote install button (now online)
const btn=document.getElementById('remoteInstallBtn');
if(btn){btn.disabled=true;btn.textContent='⚡ 远程安装(已在线)';btn.className='px-4 py-2 bg-slate-800 text-slate-500 text-sm rounded-lg cursor-not-allowed';}
}
}
async function remoteInstallAgent(){
if(!selectedServerId)return;
const btn=document.getElementById('remoteInstallBtn');
const logEl=document.getElementById('remoteInstallLog');
const logText=document.getElementById('remoteInstallLogText');
if(btn){btn.disabled=true;btn.textContent='安装中... (约30-60秒)'}
logEl?.classList.remove('hidden');
logText.textContent='正在通过 SSH 连接并执行安装脚本...\n';
try{
const r=await apiFetch(API+'/servers/'+selectedServerId+'/install-agent',{method:'POST',headers:apiHeadersJSON()});
if(!r){logText.textContent+='请求失败\n';return}
const d=await r.json();
if(r.ok&&d.success){
logText.textContent+=d.stdout||'';
logText.textContent+='\n✓ 安装成功!Agent 服务已启动,等待第一次心跳上线(最多 2 分钟)...\n';
toast('success','Agent 安装成功,等待上线...');
// Start polling for Agent to come online
_startAgentOnlinePoller(selectedServerId);
}else{
logText.textContent+=(d.detail||d.stdout||'安装失败')+'\n';
toast('error','安装失败,查看日志');
}
}catch(e){
logText.textContent+='错误: '+e.message;
toast('error','安装请求失败');
}finally{
if(btn){btn.disabled=false;btn.textContent='⚡ 一键远程安装(通过 SSH'}
}
}
async function deleteServer(id){if(!confirm('确定删除服务器 #'+id+'?'))return;const r=await apiFetch(API+'/servers/'+id,{method:'DELETE'});if(r&&r.ok)toast('success','服务器已删除');else toast('error','删除失败');if(selectedServerId===id)hideDetail();loadServers()}
let _pendingAgentKey='';
function showAddServer(){_pendingAgentKey='';document.getElementById('editServerId').value='';document.getElementById('modalTitle').textContent='添加服务器';['srvName','srvDomain','srvPassword','srvCategory','srvTarget','srvDesc','srvKeyPath','srvAgentKey'].forEach(id=>document.getElementById(id).value='');document.getElementById('srvAgentKey').placeholder='留空则使用全局 API Key';document.getElementById('srvPort').value='22';document.getElementById('srvUser').value='root';document.getElementById('srvAuth').value='key';document.getElementById('srvAgentPort').value='8601';document.getElementById('srvPlatform').value='';document.getElementById('srvNode').value='';toggleAuthFields();loadOrgSelects();document.getElementById('serverModal').classList.remove('hidden')}
function showEditServer(id){const s=_serversCache[id];if(!s)return;document.getElementById('editServerId').value=s.id;document.getElementById('modalTitle').textContent='编辑服务器';document.getElementById('srvName').value=s.name||'';document.getElementById('srvDomain').value=s.domain||'';document.getElementById('srvPort').value=s.port||22;document.getElementById('srvUser').value=s.username||'root';document.getElementById('srvAuth').value=s.auth_method||'key';document.getElementById('srvPassword').value='';document.getElementById('srvKeyPath').value=s.ssh_key_path||'';document.getElementById('srvAgentPort').value=s.agent_port||8601;document.getElementById('srvAgentKey').value=_pendingAgentKey||'';document.getElementById('srvAgentKey').placeholder=s.agent_api_key_set&&!_pendingAgentKey?'已设置 — 留空不修改,点🔑重新生成':'留空则使用全局 API Key';document.getElementById('srvCategory').value=s.category||'';document.getElementById('srvTarget').value=s.target_path||'';document.getElementById('srvDesc').value=s.description||'';toggleAuthFields();loadOrgSelects().then(()=>{document.getElementById('srvPlatform').value=s.platform_id||'';document.getElementById('srvNode').value=s.node_id||''});document.getElementById('serverModal').classList.remove('hidden')}
function hideServerModal(){document.getElementById('serverModal').classList.add('hidden')}
function toggleAuthFields(){const isKey=document.getElementById('srvAuth').value==='key';document.getElementById('srvPasswordRow').style.display=isKey?'none':'';document.getElementById('srvKeyPathRow').style.display=isKey?'':'none'}
document.getElementById('srvAuth').addEventListener('change',toggleAuthFields);
async function loadOrgSelects(){
try{
const [pr,nr]=await Promise.all([apiFetch(API+'/assets/platforms'),apiFetch(API+'/assets/nodes')]);
if(pr){const plats=await pr.json();document.getElementById('srvPlatform').innerHTML='<option value="">— 无 —</option>'+plats.map(p=>`<option value="${p.id}">${esc(p.name)}</option>`).join('')}
if(nr){const nodes=await nr.json();document.getElementById('srvNode').innerHTML='<option value="">— 无 —</option>'+nodes.map(n=>`<option value="${n.id}">${esc(n.name)}</option>`).join('')}
}catch(e){}
}
async function saveServer(){
const id=document.getElementById('editServerId').value;
const body={name:document.getElementById('srvName').value,domain:document.getElementById('srvDomain').value,port:parseInt(document.getElementById('srvPort').value)||22,username:document.getElementById('srvUser').value||'root',auth_method:document.getElementById('srvAuth').value,agent_port:parseInt(document.getElementById('srvAgentPort').value)||8601,category:document.getElementById('srvCategory').value||null,target_path:document.getElementById('srvTarget').value||null,description:document.getElementById('srvDesc').value||null,platform_id:document.getElementById('srvPlatform').value?parseInt(document.getElementById('srvPlatform').value):null,node_id:document.getElementById('srvNode').value?parseInt(document.getElementById('srvNode').value):null};
if(body.auth_method==='password'){body.password=document.getElementById('srvPassword').value}
else{body.ssh_key_path=document.getElementById('srvKeyPath').value||null}
const ak=_pendingAgentKey||document.getElementById('srvAgentKey').value;if(ak)body.agent_api_key=ak;
if(!body.name||!body.domain){toast('warning','名称和地址必填');return}
if(id){const r=await apiFetch(API+'/servers/'+id,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify(body)});if(r&&r.ok)toast('success','服务器已更新');else toast('error','更新失败')}else{const r=await apiFetch(API+'/servers/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});if(r&&r.ok)toast('success','服务器已添加');else toast('error','添加失败')}
_pendingAgentKey='';document.getElementById('srvAgentKey').value='';
hideServerModal();loadServers();
if(selectedServerId&&id==selectedServerId)selectServer(parseInt(id));
}
function driftBadge(s){
const level=s.drift_level;
const drift=s.time_drift_seconds||0;
if(!level||level==='ok'||!drift)return'';
const isCrit=level==='crit';
const color=isCrit?'bg-red-500/20 text-red-400 border-red-500/40':'bg-amber-500/20 text-amber-400 border-amber-500/40';
const icon='🕐';
const tip=`时钟偏差 ${drift}s${isCrit?' — 影响 TOTP/cron,请检查 NTP':' — 建议检查 NTP'}`;
return ` <span class="ml-1.5 inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded border ${color} text-[10px] font-medium align-middle cursor-default" title="${tip}">${icon} ${drift}s</span>`;
}
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
// ── Generate Agent API Key ──
async function generateAgentKey(){
const id=document.getElementById('editServerId').value;
if(!id){toast('warning','请先保存服务器,再生成Agent密钥');return}
if(!confirm('将为该服务器生成新的Agent API Key,旧密钥将失效。继续?'))return;
try{
const r=await apiFetch(API+'/servers/'+id+'/agent-key',{method:'POST',headers:apiHeadersJSON()});
if(!r)return;
const data=await r.json();
_pendingAgentKey=data.agent_api_key||'';
document.getElementById('srvAgentKey').value=_pendingAgentKey;
if(_pendingAgentKey&&navigator.clipboard?.writeText){
try{await navigator.clipboard.writeText(_pendingAgentKey)}catch(e){}
}
toast('success',data.message||'密钥已生成并复制到剪贴板,请立即保存服务器');
}catch(e){toast('error','生成密钥失败')}
}
function fmtTime(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
document.getElementById('searchInput').addEventListener('input',()=>{
const q=document.getElementById('searchInput').value.toLowerCase();
document.querySelectorAll('#serversTbody tr').forEach(r=>{
const name=r.querySelector('td:nth-child(3)')?.textContent.toLowerCase()||'';
const addr=r.querySelector('td:nth-child(4)')?.textContent.toLowerCase()||'';
const cat=r.querySelector('td:nth-child(5)')?.textContent.toLowerCase()||'';
r.style.display=(!q||name.includes(q)||addr.includes(q)||cat.includes(q))?'':'none';
});
});
// ── Batch Operations ──
function toggleSelect(id,checked){
if(checked) _selectedIds.add(id); else _selectedIds.delete(id);
updateBatchBar();
}
function toggleSelectAll(checked){
const checkboxes=document.querySelectorAll('.srv-chk');
checkboxes.forEach(cb=>{
const id=parseInt(cb.dataset.id);
cb.checked=checked;
if(checked) _selectedIds.add(id); else _selectedIds.delete(id);
});
updateBatchBar();
}
function clearSelection(){
_selectedIds.clear();
document.querySelectorAll('.srv-chk').forEach(cb=>cb.checked=false);
document.getElementById('selectAll').checked=false;
updateBatchBar();
}
function updateBatchBar(){
const bar=document.getElementById('batchBar');
const count=_selectedIds.size;
document.getElementById('selectedCount').textContent=count;
bar.classList.toggle('hidden',count===0);
// Update select-all state
const total=document.querySelectorAll('.srv-chk').length;
document.getElementById('selectAll').checked=total>0&&count===total;
}
function batchPush(){
if(!_selectedIds.size){toast('warning','请先选择服务器');return}
// Store selected IDs to sessionStorage and redirect to push page
sessionStorage.setItem('batchPushIds',JSON.stringify([..._selectedIds]));
window.location.href='/app/push.html';
}
async function batchDelete(){
if(!_selectedIds.size){toast('warning','请先选择服务器');return}
const ids=[..._selectedIds];
if(!confirm(`确定删除 ${ids.length} 台服务器?此操作不可撤销。`))return;
let ok=0,fail=0;
for(const id of ids){
const r=await apiFetch(API+'/servers/'+id,{method:'DELETE'});
if(r&&r.ok)ok++;else fail++;
}
toast(ok?'success':'error',`删除完成: ${ok} 成功${fail?', '+fail+' 失败':''}`);
clearSelection();
loadServers();
if(selectedServerId&&ids.includes(selectedServerId))hideDetail();
}
loadServers();
</script>
</body></html>