Files
Nexus/web/app/servers.html
T

1201 lines
78 KiB
HTML
Raw Normal View History

<!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>
2026-05-22 09:03:18 +08:00
<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">
2026-05-22 09:03:18 +08:00
<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="categoryFilter" onchange="loadServers(true)" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
2026-05-22 10:11:15 +08:00
<option value="">全部分类</option>
</select>
<select id="statusFilter" onchange="_statusFilter=this.value;loadServers(true)" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<option value="">全部状态</option>
<option value="online">在线</option>
<option value="offline">离线</option>
</select>
<input id="searchInput" type="text" placeholder="搜索名称/地址..." autocomplete="off" 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 id="refreshToggle" onclick="toggleAutoRefresh()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 text-sm rounded-lg transition" title="自动刷新">自动刷新</button>
2026-05-22 10:11:15 +08:00
<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="exportServersCSV()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">↑ 导出</button>
<button onclick="showImportModal()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">↓ 导入</button>
2026-05-22 09:03:18 +08:00
<button onclick="showAddServer()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 添加</button>
<button onclick="document.getElementById('shortcutHelp').classList.toggle('hidden')" class="px-2 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 text-sm rounded-lg transition" title="快捷键">?</button>
2026-05-22 09:03:18 +08:00
</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 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('status')">Agent 状态 <span id="sort-status" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('name')">名称 <span id="sort-name" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('domain')">地址 <span id="sort-domain" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('category')">分类 <span id="sort-category" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('agent_version')">Agent <span id="sort-agent_version" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('heartbeat')">最后心跳 <span id="sort-heartbeat" class="text-[10px]"></span></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 id="pagination" class="mt-4 flex items-center justify-between text-xs text-slate-500">
<span id="serverCount">共 -- 台</span>
<div class="flex items-center gap-2">
<button onclick="goToPage(1)" id="firstBtn" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">首页</button>
<button onclick="goToPage(_currentPage-1)" id="prevBtn" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40"></button>
<span id="pageNumbers" class="flex gap-1"></span>
<button onclick="goToPage(_currentPage+1)" id="nextBtn" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40"></button>
<button onclick="goToPage(_totalPages)" id="lastBtn" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">末页</button>
<select id="perPageSelect" onchange="changePerPage(this.value)" class="px-2 py-1 bg-slate-800 border border-slate-700 rounded-lg text-white text-xs">
<option value="50">50条/页</option>
<option value="100">100条/页</option>
<option value="200">200条/页</option>
</select>
</div>
</div>
<!-- Keyboard shortcut help -->
<div id="shortcutHelp" class="hidden fixed bottom-4 right-4 bg-slate-800 border border-slate-700 rounded-xl p-4 text-xs text-slate-300 space-y-1 z-50">
<div class="text-white font-medium mb-2">快捷键</div>
<div><kbd class="px-1.5 py-0.5 bg-slate-700 rounded text-[10px]">↑↓</kbd> / <kbd class="px-1.5 py-0.5 bg-slate-700 rounded text-[10px]">j k</kbd> 上下移动</div>
<div><kbd class="px-1.5 py-0.5 bg-slate-700 rounded text-[10px]">Esc</kbd> 关闭面板/弹窗</div>
<div><kbd class="px-1.5 py-0.5 bg-slate-700 rounded text-[10px]">/</kbd> 搜索</div>
<div><kbd class="px-1.5 py-0.5 bg-slate-700 rounded text-[10px]">r</kbd> 刷新列表</div>
2026-05-22 09:03:18 +08:00
</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="batchCheck()" class="px-4 py-1.5 bg-emerald-600/80 hover:bg-emerald-600 text-white text-sm rounded-lg transition">健康检查</button>
<button id="btnBatchInstall" onclick="batchInstallAgent()" class="px-4 py-1.5 bg-amber-600/80 hover:bg-amber-600 text-white text-sm rounded-lg transition disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-amber-600/80">安装 Agent</button>
<button id="btnBatchUpgrade" onclick="batchUpgradeAgent()" class="px-4 py-1.5 bg-cyan-600/80 hover:bg-cyan-600 text-white text-sm rounded-lg transition disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-cyan-600/80">升级 Agent</button>
<button id="btnBatchDetect" onclick="batchDetectPath()" class="px-4 py-1.5 bg-violet-600/80 hover:bg-violet-600 text-white text-sm rounded-lg transition disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-violet-600/80">检测路径</button>
<button id="btnBatchUninstall" onclick="batchUninstallAgent()" class="px-4 py-1.5 bg-red-700/80 hover:bg-red-700 text-white text-sm rounded-lg transition disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-red-700/80">卸载 Agent</button>
<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>
2026-05-22 09:03:18 +08:00
<!-- 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>
2026-05-22 09:03:18 +08:00
</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="siOSRelease" class="text-lg font-medium text-white truncate">--</div></div>
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">内核平台</div><div id="siPlatform" class="text-sm font-medium text-slate-300 truncate">--</div></div>
2026-05-22 09:03:18 +08:00
</div>
<div id="sysInfoMeta" class="mt-4 flex flex-wrap gap-x-6 gap-y-2 text-sm">
<div id="siDescRow"><span class="text-slate-500">描述:</span><span id="siDesc" class="text-slate-300"></span></div>
<div id="siTargetRow"><span class="text-slate-500">目标路径:</span><code id="siTarget" class="text-slate-300 text-xs"></code></div>
<div id="siAuthRow"><span class="text-slate-500">认证方式:</span><span id="siAuth" class="text-slate-300"></span></div>
2026-05-22 09:03:18 +08:00
</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="password">密码</option><option value="key">密钥</option></select></div>
</div>
<!-- Password auth section -->
<div id="srvPasswordSection">
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">密码预设</label><select id="srvPreset" 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 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>
</div>
<!-- Key auth section -->
<div id="srvKeySection" class="hidden space-y-3">
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">密钥预设</label><select id="srvKeyPreset" 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 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>
</div>
<div id="srvKeyPrivateRow"><label class="block text-slate-400 text-xs mb-1">私钥内容</label><textarea id="srvKeyPrivate" rows="4" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono" placeholder="粘贴 PEM 格式私钥内容(可选,优先于路径)"></textarea><p class="text-slate-600 text-[10px] mt-1">填入私钥内容后无需指定路径,系统自动加密存储</p></div>
</div>
<!-- Agent -->
<div class="text-slate-500 text-xs uppercase tracking-wider mt-3 mb-1">Agent</div>
<div class="grid grid-cols-1 gap-3">
<input id="srvAgentPort" type="hidden" value="8601">
<div>
<label class="block text-slate-400 text-xs mb-1">Agent API Key</label>
<!-- Add mode: auto-generate notice -->
<div id="agentKeyAddMode" class="px-3 py-2 bg-slate-800/50 border border-slate-700 rounded-lg text-slate-500 text-xs">创建时自动生成</div>
<!-- Edit mode: existing key info + regenerate button -->
<div id="agentKeyEditMode" class="hidden">
<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" readonly>
<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>
<p class="text-slate-600 text-[10px] mt-1">点击🔄重新生成(旧密钥将失效)</p>
</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>
<select id="srvCategorySelect" onchange="_onCategoryChange()" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<option value="">— 无分类 —</option>
</select>
<input id="srvCategory" class="hidden w-full mt-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="输入自定义分类">
</div>
<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="/www/wwwroot"></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>
<!-- CSV Import Modal -->
<div id="importModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3">
<h2 class="text-white font-semibold text-lg">批量导入服务器</h2>
<div class="bg-slate-800/50 rounded-lg p-3 text-xs text-slate-400 space-y-1">
<p>1. <a href="/app/servers_import_template.csv" download="servers_import_template.csv" class="text-brand-light underline">下载 CSV 模板</a></p>
<p>2. 按模板格式填写(<span class="text-slate-200">名称</span><span class="text-slate-200">地址</span>必填,其余选填)</p>
<p>3. 认证方式填 <span class="text-slate-200">password</span><span class="text-slate-200">key</span>,默认 password</p>
<p>4. 上传 CSV 文件,最多 500 行</p>
</div>
<div>
<label class="block text-slate-400 text-xs mb-1">选择 CSV 文件</label>
<input id="importFile" type="file" accept=".csv" class="w-full text-sm text-slate-300 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-brand file:text-white hover:file:bg-brand-dark">
</div>
<div id="importProgress" class="hidden"><div class="bg-slate-800 rounded-full h-2 overflow-hidden"><div id="importProgressBar" class="bg-brand h-full transition-all" style="width:0%"></div></div><p id="importStatus" class="text-slate-400 text-xs mt-1 text-center">上传中...</p></div>
<div id="importResult" class="hidden space-y-2">
<div class="grid grid-cols-3 gap-2 text-center">
<div class="bg-emerald-500/10 border border-emerald-500/30 rounded-lg p-3"><div id="importCreated" class="text-2xl font-bold text-emerald-400">0</div><div class="text-xs text-emerald-400/70">成功</div></div>
<div class="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3"><div id="importSkipped" class="text-2xl font-bold text-amber-400">0</div><div class="text-xs text-amber-400/70">跳过</div></div>
<div class="bg-red-500/10 border border-red-500/30 rounded-lg p-3"><div id="importFailed" class="text-2xl font-bold text-red-400">0</div><div class="text-xs text-red-400/70">失败</div></div>
</div>
<div id="importErrors" class="hidden max-h-40 overflow-y-auto bg-slate-800 rounded-lg p-3 text-xs text-red-400 space-y-1"></div>
</div>
<div class="flex gap-2 pt-2">
<button id="importBtn" onclick="doImport()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">开始导入</button>
<button onclick="hideImportModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button>
</div>
</div>
</div>
<!-- Batch Agent Result Modal -->
<div id="batchAgentModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3">
<h2 id="batchAgentTitle" class="text-white font-semibold text-lg">批量操作结果</h2>
<div id="batchAgentProgress" class="hidden text-center py-4"><div class="animate-spin inline-block w-8 h-8 border-4 border-brand border-t-transparent rounded-full"></div><p class="text-slate-400 text-sm mt-2">执行中,请等待...</p></div>
<div id="batchAgentResult" class="hidden space-y-2">
<div class="grid grid-cols-2 gap-2 text-center">
<div class="bg-emerald-500/10 border border-emerald-500/30 rounded-lg p-3"><div id="batchAgentOk" class="text-2xl font-bold text-emerald-400">0</div><div class="text-xs text-emerald-400/70">成功</div></div>
<div class="bg-red-500/10 border border-red-500/30 rounded-lg p-3"><div id="batchAgentFail" class="text-2xl font-bold text-red-400">0</div><div class="text-xs text-red-400/70">失败</div></div>
</div>
<div id="batchAgentDetails" class="max-h-60 overflow-y-auto space-y-1"></div>
</div>
<div class="flex gap-2 pt-2"><button onclick="hideBatchAgentModal()" 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');
// ── State ──
2026-05-22 09:03:18 +08:00
let selectedServerId=null;
let _serversCache={};
let _currentDetailTab='info';
let _selectedIds=new Set();
let _apiBaseUrl=null;
// Pagination & sort state
let _currentPage=1, _perPage=50, _totalPages=1, _totalItems=0;
let _sortField='id', _sortOrder='asc';
let _statusFilter='';
// Auto-refresh state
let _autoRefreshTimer=null, _autoRefreshInterval=30000, _autoRefreshEnabled=false;
let _pauseAutoRefresh=false;
// Search debounce
let _searchDebounce=null;
// Agent install
let _cachedInstallCmd=null;
let _agentOnlinePoller=null;
// ── API Base URL ──
async function loadApiBaseUrl(){
try{
const r=await apiFetch(API+'/servers/meta/api_base_url');
if(r&&r.ok){const data=await r.json();_apiBaseUrl=data.api_base_url||null}
}catch(e){}
}
// ── Load Categories (independent of pagination) ──
async function loadCategories(){
try{
const r=await apiFetch(API+'/servers/categories');
if(!r||!r.ok)return;
const data=await r.json();
const cats=data.categories||[];
const sel=document.getElementById('categoryFilter');
const currentVal=sel.value;
sel.innerHTML='<option value="">全部分类</option>'+cats.map(c=>`<option value="${esc(c.name)}" ${c.name===currentVal?'selected':''}>${esc(c.name)} (${c.count})</option>`).join('');
// Also refresh the form datalist
_refreshCategoryDatalist(cats.map(c=>c.name));
}catch(e){}
}
// ── Server List (paginated, searchable, sortable) ──
async function loadServers(resetPage=false){
if(resetPage) _currentPage=1;
try{
2026-05-22 10:11:15 +08:00
const category=document.getElementById('categoryFilter').value;
const search=document.getElementById('searchInput').value.trim();
const params=new URLSearchParams({page:_currentPage,per_page:_perPage});
if(category) params.set('category',category);
if(search) params.set('search',search);
if(_sortField!=='id'||_sortOrder!=='asc'){params.set('sort_by',_sortField);params.set('sort_order',_sortOrder)}
if(_statusFilter==='online') params.set('is_online','true');
if(_statusFilter==='offline') params.set('is_online','false');
// Show skeleton while loading
if(resetPage) renderSkeleton();
const url=API+'/servers/?'+params.toString();
2026-05-22 10:11:15 +08:00
const res=await apiFetch(url);
if(!res)return;
const data=await res.json();
const servers=data.items||[];
_totalItems=data.total||0;
_totalPages=data.pages||1;
_currentPage=data.page||1;
_serversCache={};servers.forEach(s=>_serversCache[s.id]=s);
renderServerTable(servers);
renderPagination();
renderServerCount();
}catch(e){
document.getElementById('serversTbody').innerHTML='<tr><td colspan="8" class="px-4 py-8 text-center text-red-400">加载失败</td></tr>';
}
}
// ── Render: Server Table ──
function renderServerTable(servers){
const tbody=document.getElementById('serversTbody');
if(!servers.length){tbody.innerHTML=renderEmptyState();return}
tbody.innerHTML=servers.map(s=>renderServerRow(s)).join('');
document.getElementById('selectAll').checked=false;
updateBatchBar();
// Re-highlight selected server if on this page
if(selectedServerId&&_serversCache[selectedServerId]){
document.getElementById('row-'+selectedServerId)?.classList.add('bg-brand/5');
}
}
function renderServerRow(s){
const statusHtml=renderStatusBadge(s);
const driftHtml=driftBadge(s);
const actionsHtml=renderActions(s);
return `<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">${statusHtml}</td>
<td class="px-4 py-3 text-white font-medium">${esc(s.name)}${driftHtml}</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">${esc(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">${actionsHtml}</td>
</tr>`;
}
function renderStatusBadge(s){
if(s.agent_version&&s.is_online){
const sys=s.system_info;
const cpu=sys?.cpu_percent??sys?.cpu_usage;
const cpuHint=(cpu!=null&&cpu>80)?`<span class="text-red-400 text-[10px] ml-1">${cpu.toFixed(0)}%</span>`:'';
return `<span class="inline-flex items-center gap-1.5 text-xs"><span class="inline-block w-2 h-2 rounded-full bg-green-400"></span><span class="text-green-400">在线</span>${cpuHint}</span>`;
}
if(s.agent_version&&!s.is_online){
return `<span class="inline-flex items-center gap-1.5 text-xs"><span class="inline-block w-2 h-2 rounded-full bg-red-400 animate-pulse"></span><span class="text-red-400">离线</span></span>`;
}
return `<span class="inline-flex items-center gap-1.5 text-xs"><span class="inline-block w-2 h-2 rounded-full bg-slate-600"></span><span class="text-slate-500">未安装</span></span>`;
}
function renderActions(s){
let html=`<a href="/app/terminal.html?server_id=${s.id}" onclick="event.stopPropagation()" class="text-brand-light hover:underline text-xs mr-2">终端</a>`;
if(s.agent_version) html+=`<button onclick="event.stopPropagation();_quickCheck(${s.id})" class="text-emerald-400 hover:underline text-xs mr-2">检查</button>`;
html+=`<button onclick="event.stopPropagation();showEditServer(${s.id})" class="text-slate-400 hover:underline text-xs mr-2">编辑</button>`;
html+=`<button onclick="event.stopPropagation();deleteServer(${s.id})" class="text-red-400 hover:underline text-xs">删除</button>`;
return html;
}
function renderSkeleton(){
const tbody=document.getElementById('serversTbody');
tbody.innerHTML=Array(8).fill(0).map(()=>`<tr class="animate-pulse"><td class="px-4 py-3"><div class="w-4 h-4 bg-slate-700 rounded"></div></td><td class="px-4 py-3"><div class="w-12 h-4 bg-slate-700 rounded"></div></td><td class="px-4 py-3"><div class="w-24 h-4 bg-slate-700 rounded"></div></td><td class="px-4 py-3"><div class="w-28 h-4 bg-slate-700 rounded"></div></td><td class="px-4 py-3"><div class="w-16 h-4 bg-slate-700 rounded"></div></td><td class="px-4 py-3"><div class="w-12 h-4 bg-slate-700 rounded"></div></td><td class="px-4 py-3"><div class="w-20 h-4 bg-slate-700 rounded"></div></td><td class="px-4 py-3"><div class="w-16 h-4 bg-slate-700 rounded"></div></td></tr>`).join('');
}
function renderEmptyState(){
const hasFilters=document.getElementById('searchInput').value||document.getElementById('categoryFilter').value||_statusFilter;
if(hasFilters){
return `<tr><td colspan="8" class="px-4 py-16 text-center"><div class="text-slate-500 text-4xl mb-3">🔍</div><div class="text-slate-400 mb-1">没有匹配的服务器</div><div class="text-slate-600 text-xs">尝试调整搜索条件或筛选项</div></td></tr>`;
}
return `<tr><td colspan="8" class="px-4 py-16 text-center"><div class="text-slate-500 text-4xl mb-3">🖥</div><div class="text-slate-400 mb-1">暂无服务器</div><div class="text-slate-600 text-xs">点击右上角「+ 添加」或「↓ 导入」开始</div></td></tr>`;
}
// ── Render: Pagination ──
function renderPagination(){
const pg=document.getElementById('pagination');
if(!pg)return;
// Show page controls only when there are pages
const hasPages=_totalPages>1;
pg.classList.toggle('hidden',_totalPages<=1&&!_totalItems);
document.getElementById('serverCount').textContent=`第 ${_currentPage}/${Math.max(_totalPages,1)} 页,共 ${_totalItems} 台`;
const prevBtn=document.getElementById('prevBtn');
const nextBtn=document.getElementById('nextBtn');
const firstBtn=document.getElementById('firstBtn');
const lastBtn=document.getElementById('lastBtn');
if(prevBtn)prevBtn.disabled=_currentPage<=1;
if(nextBtn)nextBtn.disabled=_currentPage>=_totalPages;
if(firstBtn)firstBtn.disabled=_currentPage<=1;
if(lastBtn)lastBtn.disabled=_currentPage>=_totalPages;
// Page number buttons (show 5 centered around current)
const nums=document.getElementById('pageNumbers');
if(nums){
let start=Math.max(1,_currentPage-2);
let end=Math.min(_totalPages,start+4);
start=Math.max(1,end-4);
nums.innerHTML='';
for(let i=start;i<=end;i++){
const active=i===_currentPage;
nums.innerHTML+=`<button onclick="goToPage(${i})" class="px-2.5 py-1 text-xs rounded-lg transition ${active?'bg-brand text-white':'bg-slate-800 hover:bg-slate-700 text-slate-300'}">${i}</button>`;
2026-05-22 10:11:15 +08:00
}
}
}
function renderServerCount(){
// Already updated in renderPagination
}
function goToPage(page){
page=Math.max(1,Math.min(page,_totalPages));
if(page===_currentPage)return;
_currentPage=page;
loadServers();
}
function changePerPage(val){
_perPage=parseInt(val)||50;
_currentPage=1;
loadServers();
2026-05-22 09:03:18 +08:00
}
// ── Sort ──
function toggleSort(field){
if(_sortField===field){_sortOrder=_sortOrder==='asc'?'desc':'asc'}
else{_sortField=field;_sortOrder='asc'}
// Update sort indicators
document.querySelectorAll('[id^="sort-"]').forEach(el=>el.textContent='');
const indicator=document.getElementById('sort-'+field);
if(indicator)indicator.textContent=_sortOrder==='asc'?'▲':'▼';
loadServers(true);
}
// ── Search (server-side with debounce) ──
document.getElementById('searchInput').addEventListener('input',function(){
clearTimeout(_searchDebounce);
_searchDebounce=setTimeout(()=>loadServers(true),300);
});
// ── Auto Refresh ──
function toggleAutoRefresh(){
if(_autoRefreshEnabled) stopAutoRefresh();
else startAutoRefresh();
updateRefreshButton();
}
function startAutoRefresh(){
stopAutoRefresh();
_autoRefreshEnabled=true;
_autoRefreshTimer=setInterval(()=>{
if(_pauseAutoRefresh)return;
loadServers();
},_autoRefreshInterval);
}
function stopAutoRefresh(){
if(_autoRefreshTimer)clearInterval(_autoRefreshTimer);
_autoRefreshTimer=null;
_autoRefreshEnabled=false;
}
function updateRefreshButton(){
const btn=document.getElementById('refreshToggle');
if(!btn)return;
if(_autoRefreshEnabled){
btn.className='px-3 py-1.5 bg-brand/80 hover:bg-brand text-white text-sm rounded-lg transition';
btn.textContent='自动刷新中';
}else{
btn.className='px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 text-sm rounded-lg transition';
btn.textContent='自动刷新';
}
}
// ── Quick Health Check ──
async function _quickCheck(id){
const s=_serversCache[id];if(!s)return;
toast('info',`正在检查 ${s.name}...`);
try{
const r=await apiFetch(API+'/servers/check',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_ids:[id]})});
if(!r)return;
const results=await r.json();
const result=results[id];
if(result.status==='online'||result.status==='healthy') toast('success',`${s.name} 在线`);
else toast('warning',`${s.name} 离线${result.error?' ('+result.error+')':''}`);
loadServers();
}catch(e){toast('error','检查失败')}
}
// ── Keyboard Shortcuts ──
document.addEventListener('keydown',function(e){
const tag=e.target.tagName;
if(tag==='INPUT'||tag==='TEXTAREA'||tag==='SELECT')return;
const rows=Array.from(document.querySelectorAll('#serversTbody tr[id^="row-"]'));
if(!rows.length)return;
const currentIdx=rows.findIndex(r=>r.classList.contains('bg-brand/5'));
if(e.key==='ArrowDown'||e.key==='j'){
e.preventDefault();
const nextIdx=Math.min(currentIdx+1,rows.length-1);
const nextId=parseInt(rows[nextIdx].id.replace('row-',''));
selectServer(nextId);
rows[nextIdx].scrollIntoView({block:'nearest'});
}else if(e.key==='ArrowUp'||e.key==='k'){
e.preventDefault();
const prevIdx=Math.max(currentIdx-1,0);
const prevId=parseInt(rows[prevIdx].id.replace('row-',''));
selectServer(prevId);
rows[prevIdx].scrollIntoView({block:'nearest'});
}else if(e.key==='Escape'){
if(!document.getElementById('serverModal').classList.contains('hidden'))hideServerModal();
else if(!document.getElementById('detailPanel').classList.contains('hidden'))hideDetail();
}else if(e.key==='/'){
e.preventDefault();document.getElementById('searchInput').focus();
}else if(e.key==='r'&&!e.ctrlKey&&!e.metaKey){
loadServers();
}
});
// ── CSV Export ──
async function exportServersCSV(){
toast('info','正在导出...');
try{
const params=new URLSearchParams({page:1,per_page:5000});
const category=document.getElementById('categoryFilter').value;
const search=document.getElementById('searchInput').value.trim();
if(category)params.set('category',category);
if(search)params.set('search',search);
if(_statusFilter==='online')params.set('is_online','true');
if(_statusFilter==='offline')params.set('is_online','false');
const r=await apiFetch(API+'/servers/?'+params.toString());
if(!r){toast('error','导出失败');return}
const data=await r.json();
const servers=data.items||[];
const headers=['ID','名称','地址','端口','用户名','分类','状态','Agent版本','最后心跳'];
const rows=servers.map(s=>[s.id,s.name,s.domain,s.port,s.username,s.category||'',s.is_online?'在线':'离线',s.agent_version||'',s.last_heartbeat||'']);
const csv=''+[headers,...rows].map(r=>r.map(c=>`"${String(c).replace(/"/g,'""')}"`).join(',')).join('\n');
const blob=new Blob([csv],{type:'text/csv;charset=utf-8'});
const url=URL.createObjectURL(blob);
const a=document.createElement('a');a.href=url;a.download=`servers_${new Date().toISOString().slice(0,10)}.csv`;
a.click();URL.revokeObjectURL(url);
toast('success',`已导出 ${servers.length} 台服务器`);
}catch(e){toast('error','导出失败: '+e.message)}
}
// ── Detail Panel ──
2026-05-22 09:03:18 +08:00
async function selectServer(id){
selectedServerId=id;
document.querySelectorAll('#serversTbody tr').forEach(r=>r.classList.remove('bg-brand/5'));
document.getElementById('row-'+id)?.classList.add('bg-brand/5');
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');
loadSystemInfo(s);
showDetailTab('info');
}
2026-05-22 09:03:18 +08:00
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){
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('siOSRelease').textContent=sys.os_release||'--';
document.getElementById('siPlatform').textContent=sys.platform||'--';
2026-05-22 09:03:18 +08:00
}else{
document.getElementById('siCPU').textContent='--';
document.getElementById('siMem').textContent='--';
document.getElementById('siDisk').textContent='--';
document.getElementById('siOSRelease').textContent='--';
document.getElementById('siPlatform').textContent='--';
2026-05-22 09:03:18 +08:00
}
var desc=s.description||'';var target=s.target_path||'';var auth=s.auth_method;
document.getElementById('siDescRow').style.display=desc?'':'none';
document.getElementById('siDesc').textContent=desc;
document.getElementById('siTargetRow').style.display=target?'':'none';
document.getElementById('siTarget').textContent=target;
document.getElementById('siAuthRow').style.display=auth?'':'none';
document.getElementById('siAuth').textContent=auth==='password'?'密码':'SSH密钥';
2026-05-22 09:03:18 +08:00
}
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=>{
2026-05-22 09:03:18 +08:00
document.getElementById('tabContent'+t).classList.toggle('hidden',t.toLowerCase()!==tab);
const btn=document.getElementById('tab'+t);
if(!btn)return;
2026-05-22 09:03:18 +08:00
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');
const s=_serversCache[selectedServerId]||{};
const statusBadge=s.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 base_url_conf=!!(_apiBaseUrl);
const noKey=!s.agent_api_key_set;
let warnings='';
if(!base_url_conf)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 canShowCmd=base_url_conf&&!noKey;
const cmdBlock=_cachedInstallCmd
?`<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(_cachedInstallCmd)}</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">脚本将自动安装 Agent;心跳走 HTTPS 出站,无需公网放行 ${esc(String(s.agent_port||8601))}</p></div>`
:canShowCmd
?`<button onclick="revealInstallCmd()" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition">🔐 查看安装命令(需验证密码)</button>`
:`<div class="text-slate-500 text-sm">(请先配置 URL 和 API Key</div>`;
const remoteBtn=canShowCmd&&!s.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>`
:(s.is_online
?`<button disabled class="px-4 py-2 bg-slate-800 text-slate-500 text-sm rounded-lg cursor-not-allowed">⚡ 远程安装(已在线)</button>`
:`<button disabled class="px-4 py-2 bg-slate-800 text-slate-500 text-sm rounded-lg cursor-not-allowed">⚡ 远程安装</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}${s.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">⬆ 升级 Agent</button><button onclick="uninstallAgent()" id="uninstallAgentBtn" class="px-3 py-2 bg-red-900/60 hover:bg-red-800 text-white text-sm rounded-lg transition">🗑 卸载 Agent</button>`:''}<span class="text-slate-500 text-xs">安装约 30-60 秒;首次心跳约 60 秒后自动更新状态</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>${!s.is_online?`<div class="flex items-center gap-2 text-xs text-slate-500"><span>手动安装后,</span><button onclick="_startAgentOnlinePoller(${selectedServerId});this.textContent='等待中...';this.disabled=true" class="text-brand-light hover:underline">点击等待 Agent 上线</button><span>(最多 2 分钟)</span></div>`:''}`;
}
async function revealInstallCmd(){
if(!selectedServerId)return;
const pwd=prompt('请输入当前登录密码以查看安装命令:');
if(!pwd)return;
try{
const r=await apiFetch(API+'/servers/'+selectedServerId+'/agent-install-cmd',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({current_password:pwd})});
if(r.status===400){const d=await r.json();toast('error',d.detail||'密码错误');return}
if(!r||!r.ok){toast('error','获取安装命令失败');return}
const d=await r.json();
if(d.install_cmd){_cachedInstallCmd=d.install_cmd;loadAgentInstall();toast('success','安装命令已显示')}
else{toast('warning','无法生成安装命令,请检查配置')}
}catch(e){toast('error','获取安装命令失败')}
}
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'}}
}
async function uninstallAgent(){
if(!selectedServerId)return;
if(!confirm('确定卸载该服务器的 Agent?\n将停止服务、删除安装目录和日志文件,并清除 Agent 状态。'))return;
const btn=document.getElementById('uninstallAgentBtn');
if(btn){btn.disabled=true;btn.textContent='卸载中...'}
const logEl=document.getElementById('remoteInstallLog');
const logText=document.getElementById('remoteInstallLogText');
logEl?.classList.remove('hidden');logText.textContent='正在停止 Agent 服务并清理安装目录...\n';
try{
const r=await apiFetch(API+'/servers/'+selectedServerId+'/uninstall-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,1000)}
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','请手动选择并复制命令')}
}
function _startAgentOnlinePoller(serverId){
if(_agentOnlinePoller)clearInterval(_agentOnlinePoller);
let attempts=0;const MAX=24;
_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;
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();
}else if(attempts>=MAX){
clearInterval(_agentOnlinePoller);_agentOnlinePoller=null;
const logText=document.getElementById('remoteInstallLogText');
if(logText)logText.textContent+='\n⚠ 等待超时(2分钟)。请稍后手动刷新确认。';
}
}catch(e){console.warn('Agent online poller error:',e)}
},5000);
}
function _updateAgentStatusBadge(isOnline){
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 在线';
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✓ 安装成功!等待首次心跳上线...\n';
toast('success','Agent 安装成功,等待上线...');_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'}}
2026-05-22 09:03:18 +08:00
}
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();
}
// ── Server Form ──
function _toggleAgentKeyMode(isEdit){
document.getElementById('agentKeyAddMode').classList.toggle('hidden',isEdit);
document.getElementById('agentKeyEditMode').classList.toggle('hidden',!isEdit);
}
function _refreshCategoryDatalist(categories){
if(!categories){const servers=Object.values(_serversCache);categories=[...new Set(servers.map(s=>s.category).filter(Boolean))].sort()}
const sel=document.getElementById('srvCategorySelect');
if(!sel)return;
const currentVal=sel.dataset.currentVal||'';
sel.innerHTML='<option value="">— 无分类 —</option>'+categories.map(c=>`<option value="${esc(c)}" ${c===currentVal?'selected':''}>${esc(c)}</option>`).join('')+'<option value="__custom__">✏ 自定义...</option>';
}
function _onCategoryChange(){
const sel=document.getElementById('srvCategorySelect');
const custom=document.getElementById('srvCategory');
if(sel.value==='__custom__'){custom.classList.remove('hidden');custom.focus()}
else{custom.classList.add('hidden');custom.value=''}
}
function showAddServer(){
document.getElementById('editServerId').value='';
document.getElementById('srvName').value='';document.getElementById('srvDomain').value='';
document.getElementById('srvPort').value='22';document.getElementById('srvUser').value='root';
document.getElementById('srvAuth').value='password';document.getElementById('srvPassword').value='';
document.getElementById('srvPreset').value='';document.getElementById('srvKeyPath').value='';
document.getElementById('srvKeyPrivate').value='';document.getElementById('srvTarget').value='';
document.getElementById('srvDesc').value='';
const catSel=document.getElementById('srvCategorySelect');catSel.value='';_onCategoryChange();
document.getElementById('srvAgentKey').value='';
_toggleAgentKeyMode(false);toggleAuthFields();
document.getElementById('modalTitle').textContent='添加服务器';
document.getElementById('serverModal').classList.remove('hidden');
_pauseAutoRefresh=true;
}
async function showEditServer(id){
const s=_serversCache[id];if(!s)return;
document.getElementById('editServerId').value=id;
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('srvKeyPrivate').value='';
document.getElementById('srvTarget').value=s.target_path||'';
document.getElementById('srvDesc').value=s.description||'';
// Category
const catSel=document.getElementById('srvCategorySelect');
catSel.dataset.currentVal=s.category||'';
// Load presets then set category
loadPresetOptions();loadSshKeyPresetOptions();
_refreshCategoryDatalist();
catSel.value=s.category||'';
if(!s.category&&catSel.value!==s.category)catSel.value='';
_onCategoryChange();
// Agent key
document.getElementById('srvAgentKey').value=s.agent_api_key||'';
_toggleAgentKeyMode(true);
toggleAuthFields();
document.getElementById('modalTitle').textContent='编辑服务器';
document.getElementById('serverModal').classList.remove('hidden');
_pauseAutoRefresh=true;
}
function hideServerModal(){
document.getElementById('serverModal').classList.add('hidden');
_pauseAutoRefresh=false;
}
function toggleAuthFields(){
const isKey=document.getElementById('srvAuth').value==='key';
document.getElementById('srvPasswordSection').classList.toggle('hidden',isKey);
document.getElementById('srvKeySection').classList.toggle('hidden',!isKey);
}
async function loadPresetOptions(){
const sel=document.getElementById('srvPreset');
if(sel.dataset.loaded)return;
try{
const r=await apiFetch(API+'/presets/');
if(!r||!r.ok)return;const presets=await r.json();
sel.innerHTML='<option value="">— 手动输入 —</option>'+presets.map(p=>`<option value="${p.id}">${esc(p.name)}</option>`).join('');
sel.dataset.loaded='1';
}catch(e){}
}
async function loadSshKeyPresetOptions(){
const sel=document.getElementById('srvKeyPreset');
if(sel.dataset.loaded)return;
try{
const r=await apiFetch(API+'/ssh-key-presets/');
if(!r||!r.ok)return;const presets=await r.json();
sel.innerHTML='<option value="">— 手动输入 —</option>'+presets.map(p=>`<option value="${p.id}">${esc(p.name)}</option>`).join('');
sel.dataset.loaded='1';
}catch(e){}
}
document.getElementById('srvAuth').addEventListener('change',toggleAuthFields);
document.getElementById('srvPreset').addEventListener('change',function(){
document.getElementById('srvPasswordRow').style.display=this.value?'none':'';
});
document.getElementById('srvKeyPreset').addEventListener('change',function(){
document.getElementById('srvKeyPathRow').style.display=this.value?'none':'';
document.getElementById('srvKeyPrivateRow').style.display=this.value?'none':'';
});
async function saveServer(){
const id=document.getElementById('editServerId').value;
const isEdit=!!id;
const body={
name:document.getElementById('srvName').value.trim(),
domain:document.getElementById('srvDomain').value.trim(),
port:parseInt(document.getElementById('srvPort').value)||22,
username:document.getElementById('srvUser').value.trim()||'root',
auth_method:document.getElementById('srvAuth').value,
target_path:document.getElementById('srvTarget').value.trim()||null,
description:document.getElementById('srvDesc').value.trim()||null,
category:document.getElementById('srvCategorySelect').value==='__custom__'?document.getElementById('srvCategory').value.trim():document.getElementById('srvCategorySelect').value||null,
};
// Password or key
if(body.auth_method==='password'){
const presetId=document.getElementById('srvPreset').value;
if(presetId)body.preset_id=parseInt(presetId);
else if(document.getElementById('srvPassword').value)body.password=document.getElementById('srvPassword').value;
body.ssh_key_path=null;body.ssh_key_private=null;
}else{
const keyPresetId=document.getElementById('srvKeyPreset').value;
if(keyPresetId)body.ssh_key_preset_id=parseInt(keyPresetId);
else{
if(document.getElementById('srvKeyPath').value)body.ssh_key_path=document.getElementById('srvKeyPath').value;
if(document.getElementById('srvKeyPrivate').value)body.ssh_key_private=document.getElementById('srvKeyPrivate').value;
}
body.password=null;
}
try{
const url=isEdit?API+'/servers/'+id:API+'/servers/';
const method=isEdit?'PUT':'POST';
const r=await apiFetch(url,{method,headers:apiHeadersJSON(),body:JSON.stringify(body)});
if(!r){toast('error','保存失败');return}
if(!r.ok){const d=await r.json();toast('error',d.detail||'保存失败');return}
toast('success',isEdit?'服务器已更新':'服务器已添加');
hideServerModal();loadServers(true);loadCategories();
}catch(e){toast('error','保存失败: '+e.message)}
}
async function generateAgentKey(){
if(!selectedServerId)return;
const pwd=prompt('请输入当前登录密码以重新生成 Agent API Key:');
if(!pwd)return;
try{
const r=await apiFetch(API+'/servers/'+selectedServerId+'/agent-key',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({current_password:pwd})});
if(!r||!r.ok){toast('error','生成失败');return}
const d=await r.json();
if(d.agent_api_key){
navigator.clipboard?.writeText(d.agent_api_key);
toast('success','Agent API Key 已生成并复制到剪贴板(仅显示一次)');
document.getElementById('srvAgentKey').value=d.agent_api_key_preview||d.agent_api_key;
}
}catch(e){toast('error','生成失败')}
}
// ── 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);
const total=document.querySelectorAll('.srv-chk').length;
document.getElementById('selectAll').checked=total>0&&count===total;
let allInstalled=true,allNotInstalled=true;
_selectedIds.forEach(id=>{
const s=_serversCache[id];
if(s){const hasAgent=!!s.agent_version;if(!hasAgent)allInstalled=false;if(hasAgent)allNotInstalled=false}
});
const btnInstall=document.getElementById('btnBatchInstall');
const btnUpgrade=document.getElementById('btnBatchUpgrade');
const btnDetect=document.getElementById('btnBatchDetect');
const btnUninstall=document.getElementById('btnBatchUninstall');
if(btnInstall)btnInstall.disabled=allInstalled;
if(btnUpgrade)btnUpgrade.disabled=allNotInstalled;
if(btnDetect)btnDetect.disabled=allNotInstalled;
if(btnUninstall)btnUninstall.disabled=allNotInstalled;
}
function batchPush(){
if(!_selectedIds.size){toast('warning','请先选择服务器');return}
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();
}
async function batchCheck(){
if(!_selectedIds.size){toast('warning','请先选择服务器');return}
const ids=[..._selectedIds];
const btn=document.querySelector('#batchBar button[onclick="batchCheck()"]');
if(btn){btn.disabled=true;btn.textContent='检查中...'}
_pauseAutoRefresh=true;
try{
const r=await apiFetch(API+'/servers/check',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_ids:ids})});
if(!r){toast('error','健康检查请求失败');return}
const results=await r.json();
let online=0,offline=0,errors=0;let detail='';
for(const[id,result]of Object.entries(results)){
const s=_serversCache[id];const name=s?s.name:'#'+id;
if(result.status==='online'||result.status==='healthy'){online++;detail+=`✓ ${name} — 在线\n`}
else if(result.status==='offline'){offline++;detail+=`✗ ${name} — 离线${result.error?' ('+result.error+')':''}\n`}
else{errors++;detail+=`? ${name}${result.status}${result.error?' ('+result.error+')':''}\n`}
}
toast(offline+errors?'warning':'success',`健康检查: ${online} 在线, ${offline} 离线${errors?', '+errors+' 异常':''}`);
if(detail){
const logEl=document.getElementById('remoteInstallLog');const logText=document.getElementById('remoteInstallLogText');
if(logEl&&logText){logEl.classList.remove('hidden');logText.textContent='健康检查结果:\n'+detail}
}
loadServers();
}catch(e){toast('error','健康检查失败: '+e.message)}
finally{if(btn){btn.disabled=false;btn.textContent='健康检查'}_pauseAutoRefresh=false}
}
// ── Import ──
function showImportModal(){
document.getElementById('importFile').value='';
document.getElementById('importProgress').classList.add('hidden');
document.getElementById('importResult').classList.add('hidden');
document.getElementById('importBtn').disabled=false;
document.getElementById('importModal').classList.remove('hidden');
_pauseAutoRefresh=true;
}
function hideImportModal(){
document.getElementById('importModal').classList.add('hidden');
_pauseAutoRefresh=false;
}
async function doImport(){
const file=document.getElementById('importFile').files[0];
if(!file){toast('warning','请选择 CSV 文件');return}
const fd=new FormData();fd.append('file',file);
document.getElementById('importProgress').classList.remove('hidden');
document.getElementById('importProgressBar').style.width='50%';
document.getElementById('importStatus').textContent='上传中...';
document.getElementById('importBtn').disabled=true;
try{
const r=await apiFetch(API+'/servers/import',{method:'POST',body:fd});
if(!r){toast('error','导入失败');return}
const data=await r.json();
document.getElementById('importProgressBar').style.width='100%';
document.getElementById('importStatus').textContent='导入完成';
document.getElementById('importResult').classList.remove('hidden');
document.getElementById('importCreated').textContent=data.created||0;
document.getElementById('importSkipped').textContent=data.skipped||0;
document.getElementById('importFailed').textContent=data.failed||0;
if(data.errors&&data.errors.length){
document.getElementById('importErrors').classList.remove('hidden');
document.getElementById('importErrors').innerHTML=data.errors.map(e=>`<div>行 ${e.row}: ${esc(e.error)}</div>`).join('');
}
if(data.created>0){loadServers(true);loadCategories()}
}catch(e){toast('error','导入失败: '+e.message)}
}
// ── Batch Agent Modals ──
function hideBatchAgentModal(){document.getElementById('batchAgentModal').classList.add('hidden');_pauseAutoRefresh=false}
async function batchInstallAgent(){
if(!_selectedIds.size){toast('warning','请先选择服务器');return}
const ids=[..._selectedIds];
if(!confirm(`确定对 ${ids.length} 台服务器远程安装 Agent`))return;
document.getElementById('batchAgentTitle').textContent='批量安装 Agent';
document.getElementById('batchAgentModal').classList.remove('hidden');
document.getElementById('batchAgentProgress').classList.remove('hidden');
document.getElementById('batchAgentResult').classList.add('hidden');
_pauseAutoRefresh=true;
try{
const r=await apiFetch(API+'/servers/batch/install-agent',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_ids:ids})});
if(!r){toast('error','请求失败');return}
const data=await r.json();_showBatchAgentResult(data.results||[]);
}catch(e){toast('error','批量安装失败: '+e.message);hideBatchAgentModal()}
}
async function batchUpgradeAgent(){
if(!_selectedIds.size){toast('warning','请先选择服务器');return}
const ids=[..._selectedIds];
if(!confirm(`确定对 ${ids.length} 台服务器升级 Agent`))return;
document.getElementById('batchAgentTitle').textContent='批量升级 Agent';
document.getElementById('batchAgentModal').classList.remove('hidden');
document.getElementById('batchAgentProgress').classList.remove('hidden');
document.getElementById('batchAgentResult').classList.add('hidden');
_pauseAutoRefresh=true;
try{
const r=await apiFetch(API+'/servers/batch/upgrade-agent',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_ids:ids})});
if(!r){toast('error','请求失败');return}
const data=await r.json();_showBatchAgentResult(data.results||[]);
}catch(e){toast('error','批量升级失败: '+e.message);hideBatchAgentModal()}
}
async function batchDetectPath(){
if(!_selectedIds.size){toast('warning','请先选择服务器');return}
const ids=[..._selectedIds];
if(!confirm(`确定对 ${ids.length} 台服务器自动检测 target_path`))return;
document.getElementById('batchAgentTitle').textContent='自动检测路径';
document.getElementById('batchAgentModal').classList.remove('hidden');
document.getElementById('batchAgentProgress').classList.remove('hidden');
document.getElementById('batchAgentResult').classList.add('hidden');
_pauseAutoRefresh=true;
try{
const r=await apiFetch(API+'/servers/batch/detect-path',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_ids:ids})});
if(!r){toast('error','请求失败');return}
const data=await r.json();_showBatchAgentResult(data.results||[]);loadServers();
}catch(e){toast('error','检测失败: '+e.message);hideBatchAgentModal()}
}
async function batchUninstallAgent(){
if(!_selectedIds.size){toast('warning','请先选择服务器');return}
const ids=[..._selectedIds];
if(!confirm(`确定对 ${ids.length} 台已安装 Agent 的服务器执行卸载?`))return;
document.getElementById('batchAgentTitle').textContent='批量卸载 Agent';
document.getElementById('batchAgentModal').classList.remove('hidden');
document.getElementById('batchAgentProgress').classList.remove('hidden');
document.getElementById('batchAgentResult').classList.add('hidden');
_pauseAutoRefresh=true;
try{
const r=await apiFetch(API+'/servers/batch/uninstall-agent',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_ids:ids})});
if(!r){toast('error','请求失败');return}
const data=await r.json();_showBatchAgentResult(data.results||[]);loadServers();
}catch(e){toast('error','卸载失败: '+e.message);hideBatchAgentModal()}
}
function _showBatchAgentResult(results){
document.getElementById('batchAgentProgress').classList.add('hidden');
document.getElementById('batchAgentResult').classList.remove('hidden');
const ok=results.filter(r=>r.success).length;
const fail=results.length-ok;
document.getElementById('batchAgentOk').textContent=ok;
document.getElementById('batchAgentFail').textContent=fail;
const details=document.getElementById('batchAgentDetails');
details.innerHTML=results.map(r=>{
if(r.success)return `<div class="flex items-center gap-2 text-xs text-emerald-400"><span>✓</span><span>${esc(r.server_name)}</span>${r.stdout?`<span class="text-slate-400">${esc(r.stdout)}</span>`:''}</div>`;
return `<div class="flex items-center gap-2 text-xs text-red-400"><span>✗</span><span>${esc(r.server_name)}</span><span class="text-slate-500 truncate" title="${esc(r.error)}">${esc(r.error)}</span></div>`;
}).join('');
}
// ── Utilities ──
function driftBadge(s){
if(!s.drift_level||s.drift_level==='ok')return '';
if(s.drift_level==='warn')return `<span class="ml-1.5 text-[10px] px-1.5 py-0.5 rounded bg-amber-900/50 text-amber-400 border border-amber-500/30" title="时钟偏差 ${s.time_drift_seconds||0}s">⏱${s.time_drift_seconds||0}s</span>`;
if(s.drift_level==='crit')return `<span class="ml-1.5 text-[10px] px-1.5 py-0.5 rounded bg-red-900/50 text-red-400 border border-red-500/30" title="时钟偏差 ${s.time_drift_seconds||0}s(严重)">⏱${s.time_drift_seconds||0}s</span>`;
return '';
}
function esc(s){if(!s)return '';const d=document.createElement('div');d.textContent=s;return d.innerHTML.replace(/"/g,'&quot;')}
function fmtTime(t){
if(!t)return '--';
try{
const d=new Date(t);
if(isNaN(d.getTime()))return '--';
const now=new Date();
const diff=now-d;
if(diff<60000)return '刚刚';
if(diff<3600000)return Math.floor(diff/60000)+'分钟前';
const month=d.getMonth()+1;const day=d.getDate();
const hour=String(d.getHours()).padStart(2,'0');const min=String(d.getMinutes()).padStart(2,'0');
if(d.toDateString()===now.toDateString())return `${hour}:${min}`;
const yesterday=new Date(now);yesterday.setDate(yesterday.getDate()-1);
if(d.toDateString()===yesterday.toDateString())return `昨天 ${hour}:${min}`;
return `${month}${day}${hour}:${min}`;
}catch(e){return '--'}
}
// ── Init ──
loadApiBaseUrl();
loadCategories();
loadServers();
// Auto-select server from URL param
const highlight=new URLSearchParams(location.search).get('highlight');
if(highlight)setTimeout(()=>selectServer(parseInt(highlight)),1000);
</script>
2026-05-22 09:03:18 +08:00
</body></html>