3c7036babf
从 worktree 分支同步完整的 servers.html,补回: - 批量操作栏"检测路径"按钮(搜索 workerman.bat 自动设置 target_path) - 批量操作栏"卸载 Agent"按钮 - batchDetectPath() 和 batchUninstallAgent() 函数 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1073 lines
69 KiB
HTML
1073 lines
69 KiB
HTML
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 服务器</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><link rel="stylesheet" href="/app/vendor/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style>
|
||
<style>
|
||
.detail-panel{max-height:0;overflow:hidden;transition:max-height .3s ease}
|
||
.detail-panel.open{max-height:800px}
|
||
</style>
|
||
</head>
|
||
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
|
||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
|
||
<div class="flex-1 flex flex-col min-w-0">
|
||
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
|
||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">服务器列表</h1></div>
|
||
<div class="flex items-center gap-3">
|
||
<select id="categoryFilter" onchange="this.dataset.loaded='';loadServers()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||
<option value="">全部分类</option>
|
||
</select>
|
||
<input id="searchInput" type="text" placeholder="搜索服务器..." 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 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="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>
|
||
</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">Agent 状态</th><th class="text-left px-4 py-3">名称</th><th class="text-left px-4 py-3">地址</th><th class="text-left px-4 py-3">分类</th><th class="text-left px-4 py-3">Agent</th><th class="text-left px-4 py-3">最后心跳</th><th class="text-right px-4 py-3">操作</th></tr></thead><tbody id="serversTbody" class="divide-y divide-slate-800"><tr><td colspan="8" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr></tbody></table>
|
||
</div>
|
||
<div class="mt-4 flex items-center justify-between text-xs text-slate-500"><span id="serverCount">共 -- 台</span><button onclick="loadServers()" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 rounded-lg transition">刷新</button></div>
|
||
<!-- Batch Action Bar -->
|
||
<div id="batchBar" class="hidden mt-3 bg-brand/10 border border-brand/30 rounded-xl px-5 py-3 flex items-center justify-between">
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-brand-light text-sm font-medium">已选择 <span id="selectedCount">0</span> 台服务器</span>
|
||
<button onclick="clearSelection()" class="text-slate-400 hover:text-white text-xs underline">取消选择</button>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<button onclick="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');
|
||
let selectedServerId=null;
|
||
let _serversCache={};
|
||
let _currentDetailTab='info';
|
||
let _selectedIds=new Set();
|
||
let _apiBaseUrl=null; // cached from /api/servers/meta/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){/* ignore — _apiBaseUrl stays null */}
|
||
}
|
||
|
||
async function loadServers(){
|
||
try{
|
||
// Clear selection on reload (server list may have changed)
|
||
_selectedIds.clear();
|
||
const category=document.getElementById('categoryFilter').value;
|
||
const url=API+'/servers/'+(category?'?category='+encodeURIComponent(category):'');
|
||
const res=await apiFetch(url);
|
||
if(!res) return;
|
||
const data=await res.json();
|
||
const servers=data.items||data;
|
||
const total=data.total||servers.length;
|
||
_serversCache={};servers.forEach(s=>_serversCache[s.id]=s);
|
||
document.getElementById('serverCount').textContent=`共 ${total} 台`;
|
||
|
||
// Populate category filter from server data (only on first load)
|
||
if(!document.getElementById('categoryFilter').dataset.loaded){
|
||
const categories=[...new Set(servers.map(s=>s.category).filter(Boolean))].sort();
|
||
const sel=document.getElementById('categoryFilter');
|
||
const currentVal=sel.value;
|
||
sel.innerHTML='<option value="">全部分类</option>'+categories.map(c=>`<option value="${esc(c)}" ${c===currentVal?'selected':''}>${esc(c)}</option>`).join('');
|
||
sel.dataset.loaded='1';
|
||
// Also populate the datalist for the server form category combobox
|
||
_refreshCategoryDatalist(categories);
|
||
}
|
||
const tbody=document.getElementById('serversTbody');
|
||
if(!servers.length){tbody.innerHTML='<tr><td colspan="8" class="px-4 py-8 text-center text-slate-500">暂无服务器</td></tr>';return}
|
||
tbody.innerHTML=servers.map(s=>`<tr class="hover:bg-slate-800/30 transition cursor-pointer ${selectedServerId===s.id?'bg-brand/5':''}" onclick="selectServer(${s.id})" id="row-${s.id}">
|
||
<td class="px-4 py-3" onclick="event.stopPropagation()"><input type="checkbox" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand srv-chk" data-id="${s.id}" onchange="toggleSelect(${s.id},this.checked)" ${_selectedIds.has(s.id)?'checked':''}></td>
|
||
<td class="px-4 py-3">${s.agent_version?(s.is_online?'<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></span>':'<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>'):'<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>'}</td>
|
||
<td class="px-4 py-3 text-white font-medium">${esc(s.name)}${driftBadge(s)}</td>
|
||
<td class="px-4 py-3 text-slate-400">${esc(s.domain)}:${s.port||22}</td>
|
||
<td class="px-4 py-3 text-slate-400">${esc(s.category||'--')}</td>
|
||
<td class="px-4 py-3 text-slate-400">${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"><a href="/app/terminal.html?server_id=${s.id}" onclick="event.stopPropagation()" class="text-brand-light hover:underline text-xs mr-2">终端</a><button onclick="event.stopPropagation();showEditServer(${s.id})" class="text-slate-400 hover:underline text-xs mr-2">编辑</button><button onclick="event.stopPropagation();deleteServer(${s.id})" class="text-red-400 hover:underline text-xs">删除</button></td>
|
||
</tr>`).join('');
|
||
document.getElementById('selectAll').checked=false;
|
||
updateBatchBar();
|
||
}catch(e){document.getElementById('serversTbody').innerHTML='<tr><td colspan="8" class="px-4 py-8 text-center text-red-400">加载失败</td></tr>'}
|
||
}
|
||
|
||
async function selectServer(id){
|
||
selectedServerId=id;
|
||
// Highlight row
|
||
document.querySelectorAll('#serversTbody tr').forEach(r=>r.classList.remove('bg-brand/5'));
|
||
document.getElementById('row-'+id)?.classList.add('bg-brand/5');
|
||
// Show detail panel
|
||
const s=_serversCache[id];if(!s)return;
|
||
document.getElementById('detailName').textContent=s.name;
|
||
document.getElementById('detailAddr').textContent=s.domain+':'+(s.port||22);
|
||
document.getElementById('detailStatus').className='inline-block w-3 h-3 rounded-full '+(s.is_online?'bg-green-400':'bg-red-400');
|
||
document.getElementById('detailSSHLink').href='/app/terminal.html?server_id='+id;
|
||
document.getElementById('detailPanel').classList.remove('hidden');
|
||
// Load detail data
|
||
loadSystemInfo(s);
|
||
showDetailTab('info');
|
||
}
|
||
|
||
function hideDetail(){
|
||
document.getElementById('detailPanel').classList.add('hidden');
|
||
selectedServerId=null;
|
||
document.querySelectorAll('#serversTbody tr').forEach(r=>r.classList.remove('bg-brand/5'));
|
||
}
|
||
|
||
function loadSystemInfo(s){
|
||
// System info from Redis (via server object or direct read)
|
||
// Agent may send cpu_percent or cpu_usage — normalize both
|
||
const sys=s.system_info;
|
||
if(sys&&typeof sys==='object'){
|
||
const cpu=sys.cpu_percent??sys.cpu_usage??null;
|
||
const mem=sys.memory_percent??sys.mem_usage??sys.mem_percent??null;
|
||
const disk=sys.disk_percent??sys.disk_usage??null;
|
||
document.getElementById('siCPU').textContent=(cpu!=null?cpu.toFixed(1)+'%':'--');
|
||
document.getElementById('siCPU').className='text-2xl font-bold '+(cpu>80?'text-red-400':cpu>60?'text-yellow-400':'text-green-400');
|
||
document.getElementById('siMem').textContent=(mem!=null?mem.toFixed(1)+'%':'--');
|
||
document.getElementById('siMem').className='text-2xl font-bold '+(mem>80?'text-red-400':mem>60?'text-yellow-400':'text-green-400');
|
||
document.getElementById('siDisk').textContent=(disk!=null?disk.toFixed(1)+'%':'--');
|
||
document.getElementById('siDisk').className='text-2xl font-bold '+(disk>80?'text-red-400':disk>60?'text-yellow-400':'text-green-400');
|
||
document.getElementById('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 ──
|
||
let _cachedInstallCmd=null;
|
||
async function loadAgentInstall(){
|
||
if(!selectedServerId)return;
|
||
const el=document.getElementById('agentInstallContent');
|
||
const s=_serversCache[selectedServerId]||{};
|
||
|
||
// Show status + manual/remote install buttons without revealing the command
|
||
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">脚本将自动:Python 3.12 venv → 下载 agent.py → systemd(本机 127.0.0.1:${esc(String(s.agent_port||8601))})→ 心跳走 HTTPS 出站,<strong>无需</strong>公网放行 ${esc(String(s.agent_port||8601))};脚本/推送走 SSH 22</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" title="Agent 已在线">⚡ 远程安装(已在线)</button>`
|
||
:`<button disabled class="px-4 py-2 bg-slate-800 text-slate-500 text-sm rounded-lg cursor-not-allowed" title="请先配置 URL 和 API Key">⚡ 远程安装</button>`);
|
||
|
||
el.innerHTML=`
|
||
<div class="flex items-center justify-between">
|
||
<h3 class="text-white font-medium">Nexus Agent 安装</h3>
|
||
${statusBadge}
|
||
</div>
|
||
${warnings}
|
||
<div class="rounded-xl border border-slate-700 bg-slate-800/30 p-4 space-y-4">
|
||
${cmdBlock}
|
||
</div>
|
||
<div class="flex items-center gap-3">
|
||
${remoteBtn}
|
||
${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" title="下载最新 agent.py 并重启服务">
|
||
⬆ 升级 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" title="停止 Agent 服务并删除安装目录">
|
||
🗑 卸载 Agent
|
||
</button>`:''}
|
||
<span class="text-slate-500 text-xs">安装约需 30-60 秒;安装完成后 Agent 发送首次心跳约需再等 60 秒,状态徽章将自动更新为 <span class="text-green-400">● Agent 在线</span></span>
|
||
</div>
|
||
<div id="remoteInstallLog" class="hidden rounded-lg border border-slate-700 bg-slate-950 p-3 max-h-56 overflow-y-auto">
|
||
<pre id="remoteInstallLogText" class="text-xs text-slate-400 whitespace-pre-wrap"></pre>
|
||
</div>
|
||
${!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(); // re-render with command visible
|
||
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将停止服务、删除 /opt/nexus-agent 目录和日志文件,并清除 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','请手动选择并复制命令');
|
||
}
|
||
}
|
||
|
||
let _agentOnlinePoller = null;
|
||
|
||
function _startAgentOnlinePoller(serverId){
|
||
if(_agentOnlinePoller)clearInterval(_agentOnlinePoller);
|
||
let attempts=0;
|
||
const MAX=24; // 24 × 5s = 2 minutes
|
||
_agentOnlinePoller=setInterval(async()=>{
|
||
attempts++;
|
||
try{
|
||
const r=await apiFetch(API+'/servers/'+serverId);
|
||
if(!r)return;
|
||
const s=await r.json();
|
||
if(s.is_online){
|
||
clearInterval(_agentOnlinePoller);_agentOnlinePoller=null;
|
||
// Update server cache and badge
|
||
if(_serversCache[serverId])_serversCache[serverId].is_online=true;
|
||
_updateAgentStatusBadge(true);
|
||
const logText=document.getElementById('remoteInstallLogText');
|
||
if(logText)logText.textContent+='\n✅ Agent 心跳已收到,服务器在线!';
|
||
toast('success','Agent 已上线!');
|
||
loadServers(); // refresh list row
|
||
}else if(attempts>=MAX){
|
||
clearInterval(_agentOnlinePoller);_agentOnlinePoller=null;
|
||
const logText=document.getElementById('remoteInstallLogText');
|
||
if(logText)logText.textContent+='\n⚠ 等待超时(2分钟)。Agent 已安装,请稍后手动刷新确认是否在线。';
|
||
}
|
||
}catch(e){console.warn('Agent online poller error:',e)}
|
||
},5000);
|
||
}
|
||
|
||
function _updateAgentStatusBadge(isOnline){
|
||
// Find the status badge element inside agentInstallContent and update it
|
||
const el=document.getElementById('agentInstallContent');
|
||
if(!el)return;
|
||
const badges=el.querySelectorAll('span[class*="rounded-full"]');
|
||
if(!badges.length)return;
|
||
const badge=badges[0];
|
||
if(isOnline){
|
||
badge.className='inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-green-900/40 text-green-400 border border-green-500/30';
|
||
badge.textContent='● Agent 在线';
|
||
// Disable remote install button (now online)
|
||
const btn=document.getElementById('remoteInstallBtn');
|
||
if(btn){btn.disabled=true;btn.textContent='⚡ 远程安装(已在线)';btn.className='px-4 py-2 bg-slate-800 text-slate-500 text-sm rounded-lg cursor-not-allowed';}
|
||
}
|
||
}
|
||
|
||
async function remoteInstallAgent(){
|
||
if(!selectedServerId)return;
|
||
const btn=document.getElementById('remoteInstallBtn');
|
||
const logEl=document.getElementById('remoteInstallLog');
|
||
const logText=document.getElementById('remoteInstallLogText');
|
||
if(btn){btn.disabled=true;btn.textContent='安装中... (约30-60秒)'}
|
||
logEl?.classList.remove('hidden');
|
||
logText.textContent='正在通过 SSH 连接并执行安装脚本...\n';
|
||
|
||
try{
|
||
const r=await apiFetch(API+'/servers/'+selectedServerId+'/install-agent',{method:'POST',headers:apiHeadersJSON()});
|
||
if(!r){logText.textContent+='请求失败\n';return}
|
||
const d=await r.json();
|
||
if(r.ok&&d.success){
|
||
logText.textContent+=d.stdout||'';
|
||
logText.textContent+='\n✓ 安装成功!Agent 服务已启动,等待第一次心跳上线(最多 2 分钟)...\n';
|
||
toast('success','Agent 安装成功,等待上线...');
|
||
// Start polling for Agent to come online
|
||
_startAgentOnlinePoller(selectedServerId);
|
||
}else{
|
||
logText.textContent+=(d.detail||d.stdout||'安装失败')+'\n';
|
||
toast('error','安装失败,查看日志');
|
||
}
|
||
}catch(e){
|
||
logText.textContent+='错误: '+e.message;
|
||
toast('error','安装请求失败');
|
||
}finally{
|
||
if(btn){btn.disabled=false;btn.textContent='⚡ 一键远程安装(通过 SSH)'}
|
||
}
|
||
}
|
||
|
||
async function deleteServer(id){if(!confirm('确定删除服务器 #'+id+'?'))return;const r=await apiFetch(API+'/servers/'+id,{method:'DELETE'});if(r&&r.ok)toast('success','服务器已删除');else toast('error','删除失败');if(selectedServerId===id)hideDetail();loadServers()}
|
||
function _toggleAgentKeyMode(isEdit){
|
||
document.getElementById('agentKeyAddMode').classList.toggle('hidden',isEdit);
|
||
document.getElementById('agentKeyEditMode').classList.toggle('hidden',!isEdit);
|
||
}
|
||
// Refresh the category datalist from cached server data or API
|
||
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 curVal=sel.value;
|
||
sel.innerHTML='<option value="">— 无分类 —</option>'+categories.map(c=>`<option value="${esc(c)}">${esc(c)}</option>`).join('')+'<option value="__custom__">✏️ 自定义输入...</option>';
|
||
if(curVal)sel.value=curVal;
|
||
// Sync hidden input
|
||
_onCategoryChange();
|
||
}
|
||
function _onCategoryChange(){
|
||
const sel=document.getElementById('srvCategorySelect');
|
||
const inp=document.getElementById('srvCategory');
|
||
if(!sel||!inp)return;
|
||
if(sel.value==='__custom__'){
|
||
inp.classList.remove('hidden');
|
||
inp.value='';
|
||
inp.focus();
|
||
} else {
|
||
inp.classList.add('hidden');
|
||
inp.value=sel.value;
|
||
}
|
||
}
|
||
function showAddServer(){
|
||
document.getElementById('editServerId').value='';
|
||
document.getElementById('modalTitle').textContent='添加服务器';
|
||
['srvName','srvDomain','srvPassword','srvCategory','srvDesc','srvKeyPath','srvKeyPrivate','srvAgentKey'].forEach(id=>{const el=document.getElementById(id);if(el)el.value=''});
|
||
document.getElementById('srvTarget').value='/www/wwwroot';
|
||
document.getElementById('srvPort').value='22';
|
||
document.getElementById('srvUser').value='root';
|
||
document.getElementById('srvAuth').value='password';
|
||
document.getElementById('srvAgentPort').value='8601';
|
||
document.getElementById('srvPreset').value='';
|
||
document.getElementById('srvPreset').dataset.loaded='';
|
||
document.getElementById('srvKeyPreset').value='';
|
||
document.getElementById('srvKeyPreset').dataset.loaded='';
|
||
_toggleAgentKeyMode(false);
|
||
toggleAuthFields();
|
||
_refreshCategoryDatalist();
|
||
document.getElementById('serverModal').classList.remove('hidden');
|
||
}
|
||
function showEditServer(id){
|
||
const s=_serversCache[id];if(!s)return;
|
||
document.getElementById('editServerId').value=s.id;
|
||
document.getElementById('modalTitle').textContent='编辑服务器';
|
||
document.getElementById('srvName').value=s.name||'';
|
||
document.getElementById('srvDomain').value=s.domain||'';
|
||
document.getElementById('srvPort').value=s.port||22;
|
||
document.getElementById('srvUser').value=s.username||'root';
|
||
document.getElementById('srvAuth').value=s.auth_method||'key';
|
||
document.getElementById('srvPassword').value='';
|
||
document.getElementById('srvKeyPath').value=s.ssh_key_path||'';
|
||
document.getElementById('srvKeyPrivate').value='';
|
||
document.getElementById('srvAgentPort').value=s.agent_port||8601;
|
||
document.getElementById('srvAgentKey').value=s.agent_api_key_set?s.agent_api_key:'';
|
||
document.getElementById('srvAgentKey').placeholder=s.agent_api_key_set?'已设置(点击🔄重新生成)':'留空则使用全局 API Key';
|
||
// Sync category: populate select first, then set value
|
||
_refreshCategoryDatalist();
|
||
{const cat=s.category||'';const sel=document.getElementById('srvCategorySelect');const inp=document.getElementById('srvCategory');
|
||
if(cat){const opts=Array.from(sel.options).map(o=>o.value);
|
||
if(opts.includes(cat)){sel.value=cat;inp.classList.add('hidden');inp.value=cat}
|
||
else{sel.value='__custom__';inp.classList.remove('hidden');inp.value=cat}
|
||
}else{sel.value='';inp.classList.add('hidden');inp.value=''}}
|
||
document.getElementById('srvTarget').value=s.target_path||'';
|
||
document.getElementById('srvDesc').value=s.description||'';
|
||
// Preset: load options then set value
|
||
document.getElementById('srvPreset').dataset.loaded='';
|
||
_toggleAgentKeyMode(true);
|
||
toggleAuthFields();
|
||
// Load presets, then set preset_id
|
||
loadPresetOptions().then(()=>{
|
||
document.getElementById('srvPreset').value=s.preset_id||'';
|
||
// Trigger change to show/hide password row
|
||
document.getElementById('srvPreset').dispatchEvent(new Event('change'));
|
||
});
|
||
// Load SSH key presets, then set ssh_key_preset_id
|
||
loadSshKeyPresetOptions().then(()=>{
|
||
document.getElementById('srvKeyPreset').value=s.ssh_key_preset_id||'';
|
||
document.getElementById('srvKeyPreset').dispatchEvent(new Event('change'));
|
||
});
|
||
document.getElementById('serverModal').classList.remove('hidden');
|
||
}
|
||
function hideServerModal(){document.getElementById('serverModal').classList.add('hidden')}
|
||
function toggleAuthFields(){
|
||
const isKey=document.getElementById('srvAuth').value==='key';
|
||
document.getElementById('srvPasswordSection').classList.toggle('hidden',isKey);
|
||
document.getElementById('srvKeySection').classList.toggle('hidden',!isKey);
|
||
if(!isKey) loadPresetOptions();
|
||
if(isKey) loadSshKeyPresetOptions();
|
||
}
|
||
document.getElementById('srvAuth').addEventListener('change',toggleAuthFields);
|
||
|
||
// ── Preset dropdown (no re-auth, backend resolves preset_id) ──
|
||
let _presetsCache=[];
|
||
async function loadPresetOptions(){
|
||
const sel=document.getElementById('srvPreset');
|
||
if(sel.dataset.loaded) return;
|
||
try{
|
||
const r=await apiFetch(API+'/presets/');
|
||
if(!r)return;
|
||
const presets=await r.json();
|
||
_presetsCache=presets;
|
||
sel.innerHTML='<option value="">— 手动输入 —</option>'+presets.map(p=>`<option value="${esc(p.id)}">${esc(p.name)}</option>`).join('');
|
||
sel.dataset.loaded='1';
|
||
}catch(e){console.warn('Preset load error:',e)}
|
||
}
|
||
// When preset is selected → hide password input; when cleared → show it
|
||
document.getElementById('srvPreset')?.addEventListener('change',function(){
|
||
const hasPreset=!!this.value;
|
||
document.getElementById('srvPasswordRow').style.display=hasPreset?'none':'';
|
||
if(hasPreset) document.getElementById('srvPassword').value='';
|
||
});
|
||
// ── SSH Key Preset dropdown (no re-auth, backend resolves ssh_key_preset_id) ──
|
||
let _sshKeyPresetsCache=[];
|
||
async function loadSshKeyPresetOptions(){
|
||
const sel=document.getElementById('srvKeyPreset');
|
||
if(sel.dataset.loaded) return;
|
||
try{
|
||
const r=await apiFetch(API+'/ssh-key-presets/');
|
||
if(!r)return;
|
||
const presets=await r.json();
|
||
_sshKeyPresetsCache=presets;
|
||
sel.innerHTML='<option value="">— 手动输入 —</option>'+presets.map(p=>`<option value="${esc(p.id)}">${esc(p.name)}</option>`).join('');
|
||
sel.dataset.loaded='1';
|
||
}catch(e){console.warn('SSH key preset load error:',e)}
|
||
}
|
||
// When SSH key preset is selected → hide path and private key inputs; when cleared → show them
|
||
document.getElementById('srvKeyPreset')?.addEventListener('change',function(){
|
||
const hasPreset=!!this.value;
|
||
document.getElementById('srvKeyPathRow').style.display=hasPreset?'none':'';
|
||
document.getElementById('srvKeyPrivateRow').style.display=hasPreset?'none':'';
|
||
if(hasPreset){
|
||
document.getElementById('srvKeyPath').value='';
|
||
document.getElementById('srvKeyPrivate').value='';
|
||
}
|
||
});
|
||
async function saveServer(){
|
||
const id=document.getElementById('editServerId').value;
|
||
const isKey=document.getElementById('srvAuth').value==='key';
|
||
const presetVal=document.getElementById('srvPreset').value;
|
||
const body={
|
||
name:document.getElementById('srvName').value,
|
||
domain:document.getElementById('srvDomain').value,
|
||
port:parseInt(document.getElementById('srvPort').value)||22,
|
||
username:document.getElementById('srvUser').value||'root',
|
||
auth_method:document.getElementById('srvAuth').value,
|
||
agent_port:parseInt(document.getElementById('srvAgentPort').value)||8601,
|
||
category:document.getElementById('srvCategory').value||null,
|
||
target_path:document.getElementById('srvTarget').value||null,
|
||
description:document.getElementById('srvDesc').value||null,
|
||
};
|
||
if(isKey){
|
||
const keyPresetVal=document.getElementById('srvKeyPreset').value;
|
||
const keyPath=document.getElementById('srvKeyPath').value;
|
||
const keyPrivate=document.getElementById('srvKeyPrivate').value;
|
||
if(keyPresetVal){
|
||
body.ssh_key_preset_id=parseInt(keyPresetVal);
|
||
}else{
|
||
if(keyPrivate) body.ssh_key_private=keyPrivate;
|
||
if(keyPath) body.ssh_key_path=keyPath;
|
||
}
|
||
// Clear password-side fields when switching to key auth
|
||
body.password=null;
|
||
body.preset_id=null;
|
||
}else{
|
||
// Password mode: preset_id or manual password
|
||
if(presetVal){
|
||
body.preset_id=parseInt(presetVal);
|
||
}else{
|
||
body.password=document.getElementById('srvPassword').value;
|
||
}
|
||
// Clear key-side fields when switching to password auth
|
||
body.ssh_key_private=null;
|
||
body.ssh_key_path=null;
|
||
body.ssh_key_public=null;
|
||
body.ssh_key_preset_id=null;
|
||
}
|
||
if(!body.name||!body.domain){toast('warning','名称和地址必填');return}
|
||
if(id){
|
||
const r=await apiFetch(API+'/servers/'+id,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||
if(r&&r.ok)toast('success','服务器已更新');else toast('error','更新失败');
|
||
}else{
|
||
const r=await apiFetch(API+'/servers/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||
if(r&&r.ok){
|
||
toast('success','服务器已添加,Agent密钥已自动生成,安装Agent时将自动使用');
|
||
}else{toast('error','添加失败')}
|
||
}
|
||
hideServerModal();loadServers();
|
||
if(selectedServerId&&id==selectedServerId)selectServer(parseInt(id));
|
||
}
|
||
function driftBadge(s){
|
||
const level=s.drift_level;
|
||
const drift=s.time_drift_seconds||0;
|
||
if(!level||level==='ok'||!drift)return'';
|
||
const isCrit=level==='crit';
|
||
const color=isCrit?'bg-red-500/20 text-red-400 border-red-500/40':'bg-amber-500/20 text-amber-400 border-amber-500/40';
|
||
const icon='🕐';
|
||
const tip=`时钟偏差 ${drift}s${isCrit?' — 影响 TOTP/cron,请检查 NTP':' — 建议检查 NTP'}`;
|
||
return ` <span class="ml-1.5 inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded border ${color} text-[10px] font-medium align-middle cursor-default" title="${tip}">${icon} ${drift}s</span>`;
|
||
}
|
||
|
||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML.replace(/"/g,'"')}
|
||
|
||
// ── Regenerate Agent API Key (edit mode only) ──
|
||
async function generateAgentKey(){
|
||
const id=document.getElementById('editServerId').value;
|
||
if(!id){toast('warning','新建服务器时自动生成,无需手动操作');return}
|
||
const pwd=prompt('请输入当前登录密码以重新生成密钥:');
|
||
if(!pwd)return;
|
||
try{
|
||
const r=await apiFetch(API+'/servers/'+id+'/agent-key',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({current_password:pwd})});
|
||
if(r&&r.status===400){const d=await r.json();toast('error',d.detail||'密码错误');return}
|
||
if(!r)return;
|
||
const data=await r.json();
|
||
const newKey=data.agent_api_key||'';
|
||
document.getElementById('srvAgentKey').value=data.agent_api_key_preview||newKey;
|
||
if(newKey&&navigator.clipboard?.writeText){
|
||
try{await navigator.clipboard.writeText(newKey)}catch(e){console.warn('Clipboard write failed:',e)}
|
||
}
|
||
toast('success','密钥已重新生成并复制到剪贴板,已立即生效');
|
||
}catch(e){toast('error','生成密钥失败')}
|
||
}
|
||
function fmtTime(t){if(!t)return'--';var d=new Date(t);if(isNaN(d.getTime())){d=new Date(t+'Z')}return d.toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
|
||
|
||
// Clear stale autocomplete values on page load (must outlast browser autofill timing)
|
||
requestAnimationFrame(()=>requestAnimationFrame(()=>{document.getElementById('searchInput').value=''}));
|
||
document.getElementById('searchInput').addEventListener('input',()=>{
|
||
const q=document.getElementById('searchInput').value.toLowerCase();
|
||
document.querySelectorAll('#serversTbody tr').forEach(r=>{
|
||
const name=r.querySelector('td:nth-child(3)')?.textContent.toLowerCase()||'';
|
||
const addr=r.querySelector('td:nth-child(4)')?.textContent.toLowerCase()||'';
|
||
const cat=r.querySelector('td:nth-child(5)')?.textContent.toLowerCase()||'';
|
||
r.style.display=(!q||name.includes(q)||addr.includes(q)||cat.includes(q))?'':'none';
|
||
});
|
||
});
|
||
|
||
// ── Batch Operations ──
|
||
function toggleSelect(id,checked){
|
||
if(checked) _selectedIds.add(id); else _selectedIds.delete(id);
|
||
updateBatchBar();
|
||
}
|
||
function toggleSelectAll(checked){
|
||
const checkboxes=document.querySelectorAll('.srv-chk');
|
||
checkboxes.forEach(cb=>{
|
||
const id=parseInt(cb.dataset.id);
|
||
cb.checked=checked;
|
||
if(checked) _selectedIds.add(id); else _selectedIds.delete(id);
|
||
});
|
||
updateBatchBar();
|
||
}
|
||
function clearSelection(){
|
||
_selectedIds.clear();
|
||
document.querySelectorAll('.srv-chk').forEach(cb=>cb.checked=false);
|
||
document.getElementById('selectAll').checked=false;
|
||
updateBatchBar();
|
||
}
|
||
function updateBatchBar(){
|
||
const bar=document.getElementById('batchBar');
|
||
const count=_selectedIds.size;
|
||
document.getElementById('selectedCount').textContent=count;
|
||
bar.classList.toggle('hidden',count===0);
|
||
// Update select-all state
|
||
const total=document.querySelectorAll('.srv-chk').length;
|
||
document.getElementById('selectAll').checked=total>0&&count===total;
|
||
// Agent install/upgrade button states
|
||
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; // all have agent → disable install
|
||
if(btnUpgrade) btnUpgrade.disabled=allNotInstalled; // none have agent → disable upgrade
|
||
if(btnDetect) btnDetect.disabled=allNotInstalled; // need agent for SSH connection
|
||
if(btnUninstall) btnUninstall.disabled=allNotInstalled; // need agent for uninstall
|
||
}
|
||
function batchPush(){
|
||
if(!_selectedIds.size){toast('warning','请先选择服务器');return}
|
||
// Store selected IDs to sessionStorage and redirect to push page
|
||
sessionStorage.setItem('batchPushIds',JSON.stringify([..._selectedIds]));
|
||
window.location.href='/app/push.html';
|
||
}
|
||
async function batchDelete(){
|
||
if(!_selectedIds.size){toast('warning','请先选择服务器');return}
|
||
const ids=[..._selectedIds];
|
||
if(!confirm(`确定删除 ${ids.length} 台服务器?此操作不可撤销。`))return;
|
||
let ok=0,fail=0;
|
||
for(const id of ids){
|
||
const r=await apiFetch(API+'/servers/'+id,{method:'DELETE'});
|
||
if(r&&r.ok)ok++;else fail++;
|
||
}
|
||
toast(ok?'success':'error',`删除完成: ${ok} 成功${fail?', '+fail+' 失败':''}`);
|
||
clearSelection();
|
||
loadServers();
|
||
if(selectedServerId&&ids.includes(selectedServerId))hideDetail();
|
||
}
|
||
|
||
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='检查中...'}
|
||
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='健康检查'}}
|
||
}
|
||
|
||
// ── CSV 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');
|
||
}
|
||
function hideImportModal(){document.getElementById('importModal').classList.add('hidden')}
|
||
|
||
async function doImport(){
|
||
const fileInput=document.getElementById('importFile');
|
||
if(!fileInput.files.length){toast('warning','请选择 CSV 文件');return}
|
||
const formData=new FormData();
|
||
formData.append('file',fileInput.files[0]);
|
||
document.getElementById('importBtn').disabled=true;
|
||
document.getElementById('importProgress').classList.remove('hidden');
|
||
document.getElementById('importResult').classList.add('hidden');
|
||
document.getElementById('importStatus').textContent='上传并导入中...';
|
||
document.getElementById('importProgressBar').style.width='50%';
|
||
try{
|
||
const r=await apiFetch(API+'/servers/import',{method:'POST',body:formData});
|
||
if(!r){toast('error','导入请求失败');return}
|
||
const data=await r.json();
|
||
document.getElementById('importProgressBar').style.width='100%';
|
||
document.getElementById('importProgress').classList.add('hidden');
|
||
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;
|
||
const errDiv=document.getElementById('importErrors');
|
||
if(data.errors&&data.errors.length){
|
||
errDiv.classList.remove('hidden');
|
||
errDiv.innerHTML=data.errors.map(e=>`<div>第${esc(e.row)}行 ${esc(e.name||'')} ${esc(e.domain||'')}: ${esc(e.error)}</div>`).join('');
|
||
}else{errDiv.classList.add('hidden')}
|
||
toast(data.failed?'warning':'success',`导入完成: ${data.created} 成功, ${data.skipped} 跳过, ${data.failed} 失败`);
|
||
if(data.created>0)loadServers();
|
||
}catch(e){
|
||
toast('error','导入失败: '+e.message);
|
||
document.getElementById('importProgress').classList.add('hidden');
|
||
}finally{document.getElementById('importBtn').disabled=false}
|
||
}
|
||
|
||
// ── Batch Agent Install/Upgrade ──
|
||
function hideBatchAgentModal(){document.getElementById('batchAgentModal').classList.add('hidden')}
|
||
|
||
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');
|
||
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');
|
||
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?\n将在 /www/wwwroot 下搜索 workerman.bat,找到后自动设置目标路径。`))return;
|
||
document.getElementById('batchAgentTitle').textContent='自动检测路径';
|
||
document.getElementById('batchAgentModal').classList.remove('hidden');
|
||
document.getElementById('batchAgentProgress').classList.remove('hidden');
|
||
document.getElementById('batchAgentResult').classList.add('hidden');
|
||
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 的服务器执行卸载?\n将停止服务、删除 /opt/nexus-agent 目录和日志文件,并清除 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');
|
||
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('');
|
||
}
|
||
|
||
loadApiBaseUrl();
|
||
loadServers().then(()=>{
|
||
const hid=new URLSearchParams(location.search).get('highlight');
|
||
if(hid)selectServer(parseInt(hid));
|
||
});
|
||
</script>
|
||
</body></html>
|