b676e320de
- index.html: WebSocket告警文本esc()转义 - files.html: escAttr()函数 + 路径拼接转义 - layout.js: 移动端侧边栏overlay + 全局搜索 + 用户信息 - servers.html: 点击展开详情面板(系统信息/同步/SSH) - settings.html: TOTP QR码设置 + 修改密码 + API Key复制 - api.js: JWT refresh token自动刷新 + Toast通知 - terminal.html: WebSSH xterm.js集成 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
362 lines
30 KiB
HTML
362 lines
30 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="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/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>
|
|
</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>
|
|
</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)}</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">${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'].forEach(t=>{
|
|
document.getElementById('tabContent'+t).classList.toggle('hidden',t.toLowerCase()!==tab);
|
|
const btn=document.getElementById('tab'+t);
|
|
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();
|
|
}
|
|
|
|
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()}
|
|
function showAddServer(){document.getElementById('editServerId').value='';document.getElementById('modalTitle').textContent='添加服务器';['srvName','srvDomain','srvPassword','srvCategory','srvTarget','srvDesc','srvKeyPath','srvAgentKey'].forEach(id=>document.getElementById(id).value='');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=s.agent_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=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','添加失败')}
|
|
hideServerModal();loadServers();
|
|
if(selectedServerId&&id==selectedServerId)selectServer(parseInt(id));
|
|
}
|
|
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();
|
|
document.getElementById('srvAgentKey').value=data.agent_api_key||'';
|
|
toast('success','Agent API Key已生成,请保存服务器以应用');
|
|
}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>
|