Files
Nexus/web/app/servers.html
T
Your Name 00a3309005 feat: 服务器列表加「文件」按钮直达文件管理
- servers.html: renderActions 加「文件」链接(琥珀色) → /app/files.html?server_id=X
- files.html: 支持 server_id URL参数, 自动选中服务器并浏览 /www/wwwroot
2026-05-31 01:33:52 +08:00

1202 lines
78 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 服务器</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><link rel="stylesheet" href="/app/vendor/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style>
<style>
.detail-panel{max-height:0;overflow:hidden;transition:max-height .3s ease}
.detail-panel.open{max-height:800px}
</style>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">服务器列表</h1></div>
<div class="flex items-center gap-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">
<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>
<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>
<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>
</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>
</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>
<!-- Server Detail Panel -->
<div id="detailPanel" class="hidden mt-4 bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-800 bg-slate-800/30">
<div class="flex items-center gap-3">
<span id="detailStatus" class="inline-block w-3 h-3 rounded-full"></span>
<h2 id="detailName" class="text-white font-semibold text-lg"></h2>
<span id="detailAddr" class="text-slate-400 text-sm"></span>
</div>
<div class="flex items-center gap-2">
<a id="detailSSHLink" href="#" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">SSH终端</a>
<button onclick="showEditServer(selectedServerId)" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">编辑</button>
<button onclick="hideDetail()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">关闭</button>
</div>
</div>
<!-- Tabs -->
<div class="flex border-b border-slate-800 px-6">
<button onclick="showDetailTab('info')" id="tabInfo" class="px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition">系统信息</button>
<button onclick="showDetailTab('sync')" id="tabSync" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">同步日志</button>
<button onclick="showDetailTab('ssh')" id="tabSSH" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">SSH会话</button>
<button onclick="showDetailTab('agent')" id="tabAgent" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">Agent 安装</button>
</div>
<!-- Tab Content: System Info -->
<div id="tabContentInfo" class="p-6">
<div id="sysInfoGrid" class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">CPU使用率</div><div id="siCPU" class="text-2xl font-bold text-white">--</div></div>
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">内存使用率</div><div id="siMem" class="text-2xl font-bold text-white">--</div></div>
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">磁盘使用率</div><div id="siDisk" class="text-2xl font-bold text-white">--</div></div>
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">发行版</div><div id="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>
</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>
</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 ──
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{
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();
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>`;
html+=`<a href="/app/files.html?server_id=${s.id}" onclick="event.stopPropagation()" class="text-amber-400 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>`;
}
}
}
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();
}
// ── 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 ──
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');
}
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||'--';
}else{
document.getElementById('siCPU').textContent='--';
document.getElementById('siMem').textContent='--';
document.getElementById('siDisk').textContent='--';
document.getElementById('siOSRelease').textContent='--';
document.getElementById('siPlatform').textContent='--';
}
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密钥';
}
async function loadSyncLogs(){
if(!selectedServerId)return;
try{
const r=await apiFetch(API+'/servers/'+selectedServerId+'/logs?limit=20');
if(!r)return;const logs=await r.json();
const tbody=document.getElementById('syncLogTbody');
if(!logs.length){tbody.innerHTML='<tr><td colspan="5" class="py-4 text-center text-slate-500">暂无同步记录</td></tr>';return}
tbody.innerHTML=logs.map(l=>{
const sc=l.status==='success'?'text-green-400':l.status==='failed'?'text-red-400':'text-yellow-400';
return `<tr class="border-b border-slate-800/50"><td class="py-2 text-slate-300">${esc(l.sync_mode||'--')}</td><td class="py-2 ${sc}">${esc(l.status)}</td><td class="py-2 text-slate-500 text-xs max-w-xs truncate">${esc(l.source_path||'--')}</td><td class="py-2 text-slate-500">${l.duration_seconds!=null?l.duration_seconds+'s':'--'}</td><td class="py-2 text-slate-600 text-xs">${fmtTime(l.started_at)}</td></tr>`}).join('');
}catch(e){document.getElementById('syncLogTbody').innerHTML='<tr><td colspan="5" class="py-4 text-center text-red-400">加载失败</td></tr>'}
}
async function loadSSHSessions(){
if(!selectedServerId)return;
try{
const r=await apiFetch(API+'/assets/ssh-sessions?server_id='+selectedServerId+'&limit=20');
if(!r)return;const sessions=await r.json();
const tbody=document.getElementById('sshSessTbody');
if(!sessions.length){tbody.innerHTML='<tr><td colspan="4" class="py-4 text-center text-slate-500">暂无SSH会话</td></tr>';return}
tbody.innerHTML=sessions.map(s=>{
const sc=s.status==='active'?'text-green-400':s.status==='closed'?'text-slate-500':'text-yellow-400';
return `<tr class="border-b border-slate-800/50"><td class="py-2 text-slate-300">${esc(s.remote_addr||'--')}</td><td class="py-2 ${sc}">${esc(s.status)}</td><td class="py-2 text-slate-500 text-xs">${fmtTime(s.started_at)}</td><td class="py-2 text-slate-600 text-xs">${fmtTime(s.closed_at)}</td></tr>`}).join('');
}catch(e){document.getElementById('sshSessTbody').innerHTML='<tr><td colspan="4" class="py-4 text-center text-red-400">加载失败</td></tr>'}
}
function showDetailTab(tab){
_currentDetailTab=tab;
['Info','Sync','SSH','Agent'].forEach(t=>{
document.getElementById('tabContent'+t).classList.toggle('hidden',t.toLowerCase()!==tab);
const btn=document.getElementById('tab'+t);
if(!btn)return;
if(t.toLowerCase()===tab){btn.className='px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition'}
else{btn.className='px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition'}
});
if(tab==='sync')loadSyncLogs();
if(tab==='ssh')loadSSHSessions();
if(tab==='agent')loadAgentInstall();
}
// ── Agent Install Tab ──
async function loadAgentInstall(){
if(!selectedServerId)return;
const el=document.getElementById('agentInstallContent');
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'}}
}
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>
</body></html>