2026-05-23 15:26:56 +08:00
|
|
|
|
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 服务器</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><link rel="stylesheet" href="/app/vendor/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style>
|
2026-05-22 09:03:18 +08:00
|
|
|
|
<style>
|
|
|
|
|
|
.detail-panel{max-height:0;overflow:hidden;transition:max-height .3s ease}
|
|
|
|
|
|
.detail-panel.open{max-height:800px}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
2026-05-22 09:46:23 +08:00
|
|
|
|
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
|
2026-05-22 09:29:25 +08:00
|
|
|
|
<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>
|
2026-05-21 22:11:38 +08:00
|
|
|
|
<div class="flex-1 flex flex-col min-w-0">
|
2026-05-22 09:03:18 +08:00
|
|
|
|
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
|
|
|
|
|
|
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">服务器列表</h1></div>
|
2026-05-30 19:45:53 +08:00
|
|
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
|
|
|
|
<select id="categoryFilter" onchange="loadServers(true)" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
2026-05-22 10:11:15 +08:00
|
|
|
|
<option value="">全部分类</option>
|
|
|
|
|
|
</select>
|
2026-05-30 19:45:53 +08:00
|
|
|
|
<select id="statusFilter" onchange="_statusFilter=this.value;loadServers(true)" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
|
|
|
|
|
<option value="">全部状态</option>
|
|
|
|
|
|
<option value="online">在线</option>
|
|
|
|
|
|
<option value="offline">离线</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<input id="searchInput" type="text" placeholder="搜索名称/地址..." autocomplete="off" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48">
|
|
|
|
|
|
<button id="refreshToggle" onclick="toggleAutoRefresh()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 text-sm rounded-lg transition" title="自动刷新">自动刷新</button>
|
2026-05-22 10:11:15 +08:00
|
|
|
|
<button onclick="loadServers()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
|
2026-05-30 19:45:53 +08:00
|
|
|
|
<button onclick="exportServersCSV()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">↑ 导出</button>
|
2026-05-26 00:17:43 +08:00
|
|
|
|
<button onclick="showImportModal()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">↓ 导入</button>
|
2026-05-22 09:03:18 +08:00
|
|
|
|
<button onclick="showAddServer()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 添加</button>
|
2026-05-30 19:45:53 +08:00
|
|
|
|
<button onclick="document.getElementById('shortcutHelp').classList.toggle('hidden')" class="px-2 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 text-sm rounded-lg transition" title="快捷键">?</button>
|
2026-05-22 09:03:18 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
2026-05-21 22:11:38 +08:00
|
|
|
|
<main class="flex-1 overflow-y-auto p-6">
|
|
|
|
|
|
<div id="serversTable" class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
2026-05-30 19:45:53 +08:00
|
|
|
|
<table class="w-full text-sm"><thead class="bg-slate-800/50 text-slate-400 text-xs uppercase"><tr><th class="px-4 py-3 w-10"><input type="checkbox" id="selectAll" onchange="toggleSelectAll(this.checked)" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand"></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('status')">Agent 状态 <span id="sort-status" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('name')">名称 <span id="sort-name" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('domain')">地址 <span id="sort-domain" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('category')">分类 <span id="sort-category" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('agent_version')">Agent <span id="sort-agent_version" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('heartbeat')">最后心跳 <span id="sort-heartbeat" class="text-[10px]"></span></th><th class="text-right px-4 py-3">操作</th></tr></thead><tbody id="serversTbody" class="divide-y divide-slate-800"><tr><td colspan="8" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr></tbody></table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div id="pagination" class="mt-4 flex items-center justify-between text-xs text-slate-500">
|
|
|
|
|
|
<span id="serverCount">共 -- 台</span>
|
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
<button onclick="goToPage(1)" id="firstBtn" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">首页</button>
|
|
|
|
|
|
<button onclick="goToPage(_currentPage-1)" id="prevBtn" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">←</button>
|
|
|
|
|
|
<span id="pageNumbers" class="flex gap-1"></span>
|
|
|
|
|
|
<button onclick="goToPage(_currentPage+1)" id="nextBtn" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">→</button>
|
|
|
|
|
|
<button onclick="goToPage(_totalPages)" id="lastBtn" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">末页</button>
|
|
|
|
|
|
<select id="perPageSelect" onchange="changePerPage(this.value)" class="px-2 py-1 bg-slate-800 border border-slate-700 rounded-lg text-white text-xs">
|
|
|
|
|
|
<option value="50">50条/页</option>
|
|
|
|
|
|
<option value="100">100条/页</option>
|
|
|
|
|
|
<option value="200">200条/页</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<!-- Keyboard shortcut help -->
|
|
|
|
|
|
<div id="shortcutHelp" class="hidden fixed bottom-4 right-4 bg-slate-800 border border-slate-700 rounded-xl p-4 text-xs text-slate-300 space-y-1 z-50">
|
|
|
|
|
|
<div class="text-white font-medium mb-2">快捷键</div>
|
|
|
|
|
|
<div><kbd class="px-1.5 py-0.5 bg-slate-700 rounded text-[10px]">↑↓</kbd> / <kbd class="px-1.5 py-0.5 bg-slate-700 rounded text-[10px]">j k</kbd> 上下移动</div>
|
|
|
|
|
|
<div><kbd class="px-1.5 py-0.5 bg-slate-700 rounded text-[10px]">Esc</kbd> 关闭面板/弹窗</div>
|
|
|
|
|
|
<div><kbd class="px-1.5 py-0.5 bg-slate-700 rounded text-[10px]">/</kbd> 搜索</div>
|
|
|
|
|
|
<div><kbd class="px-1.5 py-0.5 bg-slate-700 rounded text-[10px]">r</kbd> 刷新列表</div>
|
2026-05-22 09:03:18 +08:00
|
|
|
|
</div>
|
2026-05-22 11:01:17 +08:00
|
|
|
|
<!-- 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">
|
2026-05-24 16:26:40 +08:00
|
|
|
|
<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>
|
2026-05-27 02:17:35 +08:00
|
|
|
|
<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>
|
2026-05-29 16:56:23 +08:00
|
|
|
|
<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>
|
2026-05-22 11:01:17 +08:00
|
|
|
|
<button onclick="batchPush()" class="px-4 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">批量推送</button>
|
|
|
|
|
|
<button onclick="batchDelete()" class="px-4 py-1.5 bg-red-600/80 hover:bg-red-600 text-white text-sm rounded-lg transition">批量删除</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-22 09:03:18 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- Server Detail Panel -->
|
|
|
|
|
|
<div id="detailPanel" class="hidden mt-4 bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
|
|
|
|
|
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-800 bg-slate-800/30">
|
|
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
|
<span id="detailStatus" class="inline-block w-3 h-3 rounded-full"></span>
|
|
|
|
|
|
<h2 id="detailName" class="text-white font-semibold text-lg"></h2>
|
|
|
|
|
|
<span id="detailAddr" class="text-slate-400 text-sm"></span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
<a id="detailSSHLink" href="#" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">SSH终端</a>
|
|
|
|
|
|
<button onclick="showEditServer(selectedServerId)" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">编辑</button>
|
|
|
|
|
|
<button onclick="hideDetail()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">关闭</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<!-- Tabs -->
|
|
|
|
|
|
<div class="flex border-b border-slate-800 px-6">
|
|
|
|
|
|
<button onclick="showDetailTab('info')" id="tabInfo" class="px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition">系统信息</button>
|
|
|
|
|
|
<button onclick="showDetailTab('sync')" id="tabSync" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">同步日志</button>
|
|
|
|
|
|
<button onclick="showDetailTab('ssh')" id="tabSSH" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">SSH会话</button>
|
2026-05-23 17:16:14 +08:00
|
|
|
|
<button onclick="showDetailTab('agent')" id="tabAgent" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">Agent 安装</button>
|
2026-05-22 09:03:18 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<!-- Tab Content: System Info -->
|
|
|
|
|
|
<div id="tabContentInfo" class="p-6">
|
|
|
|
|
|
<div id="sysInfoGrid" class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
|
|
|
|
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">CPU使用率</div><div id="siCPU" class="text-2xl font-bold text-white">--</div></div>
|
|
|
|
|
|
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">内存使用率</div><div id="siMem" class="text-2xl font-bold text-white">--</div></div>
|
|
|
|
|
|
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">磁盘使用率</div><div id="siDisk" class="text-2xl font-bold text-white">--</div></div>
|
2026-05-27 02:17:35 +08:00
|
|
|
|
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">发行版</div><div id="siOSRelease" class="text-lg font-medium text-white truncate">--</div></div>
|
|
|
|
|
|
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">内核平台</div><div id="siPlatform" class="text-sm font-medium text-slate-300 truncate">--</div></div>
|
2026-05-22 09:03:18 +08:00
|
|
|
|
</div>
|
2026-05-27 02:17:35 +08:00
|
|
|
|
<div id="sysInfoMeta" class="mt-4 flex flex-wrap gap-x-6 gap-y-2 text-sm">
|
|
|
|
|
|
<div id="siDescRow"><span class="text-slate-500">描述:</span><span id="siDesc" class="text-slate-300"></span></div>
|
|
|
|
|
|
<div id="siTargetRow"><span class="text-slate-500">目标路径:</span><code id="siTarget" class="text-slate-300 text-xs"></code></div>
|
|
|
|
|
|
<div id="siAuthRow"><span class="text-slate-500">认证方式:</span><span id="siAuth" class="text-slate-300"></span></div>
|
2026-05-22 09:03:18 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<!-- Tab Content: Sync Logs -->
|
|
|
|
|
|
<div id="tabContentSync" class="hidden p-6">
|
|
|
|
|
|
<table class="w-full text-sm"><thead class="text-slate-500 text-xs uppercase"><tr><th class="text-left py-2">模式</th><th class="text-left py-2">状态</th><th class="text-left py-2">源路径</th><th class="text-left py-2">耗时</th><th class="text-left py-2">时间</th></tr></thead><tbody id="syncLogTbody"><tr><td colspan="5" class="py-4 text-center text-slate-500">加载中...</td></tr></tbody></table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<!-- Tab Content: SSH Sessions -->
|
|
|
|
|
|
<div id="tabContentSSH" class="hidden p-6">
|
|
|
|
|
|
<table class="w-full text-sm"><thead class="text-slate-500 text-xs uppercase"><tr><th class="text-left py-2">来源IP</th><th class="text-left py-2">状态</th><th class="text-left py-2">开始</th><th class="text-left py-2">结束</th></tr></thead><tbody id="sshSessTbody"><tr><td colspan="4" class="py-4 text-center text-slate-500">加载中...</td></tr></tbody></table>
|
|
|
|
|
|
</div>
|
2026-05-23 17:16:14 +08:00
|
|
|
|
<!-- 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>
|
2026-05-21 22:11:38 +08:00
|
|
|
|
</div>
|
2026-05-22 00:02:46 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- Add/Edit Server Modal -->
|
2026-05-22 22:29:06 +08:00
|
|
|
|
<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">
|
2026-05-22 00:02:46 +08:00
|
|
|
|
<h2 id="modalTitle" class="text-white font-semibold text-lg">添加服务器</h2>
|
|
|
|
|
|
<input type="hidden" id="editServerId">
|
2026-05-22 22:29:06 +08:00
|
|
|
|
<!-- Basic -->
|
|
|
|
|
|
<div class="text-slate-500 text-xs uppercase tracking-wider mb-1">基本</div>
|
2026-05-22 00:02:46 +08:00
|
|
|
|
<div class="grid grid-cols-2 gap-3">
|
2026-05-22 22:29:06 +08:00
|
|
|
|
<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>
|
2026-05-22 00:02:46 +08:00
|
|
|
|
</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>
|
2026-05-25 19:33:39 +08:00
|
|
|
|
<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>
|
2026-05-22 00:02:46 +08:00
|
|
|
|
</div>
|
2026-05-25 22:49:57 +08:00
|
|
|
|
<!-- 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>
|
2026-05-22 22:29:06 +08:00
|
|
|
|
<!-- Agent -->
|
|
|
|
|
|
<div class="text-slate-500 text-xs uppercase tracking-wider mt-3 mb-1">Agent</div>
|
2026-05-26 09:16:05 +08:00
|
|
|
|
<div class="grid grid-cols-1 gap-3">
|
|
|
|
|
|
<input id="srvAgentPort" type="hidden" value="8601">
|
2026-05-25 22:49:57 +08:00
|
|
|
|
<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>
|
2026-05-22 22:29:06 +08:00
|
|
|
|
</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">
|
2026-05-26 09:16:05 +08:00
|
|
|
|
<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>
|
2026-05-22 22:29:06 +08:00
|
|
|
|
<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>
|
2026-05-22 00:02:46 +08:00
|
|
|
|
</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>
|
2026-05-26 00:17:43 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 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">
|
2026-05-29 16:56:23 +08:00
|
|
|
|
<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>
|
2026-05-26 00:17:43 +08:00
|
|
|
|
</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>
|
2026-05-26 00:46:39 +08:00
|
|
|
|
|
2026-05-21 22:11:38 +08:00
|
|
|
|
</main>
|
|
|
|
|
|
</div>
|
2026-05-21 23:18:49 +08:00
|
|
|
|
<script src="/app/api.js"></script>
|
2026-05-22 09:29:25 +08:00
|
|
|
|
<script src="/app/layout.js"></script>
|
2026-05-21 22:11:38 +08:00
|
|
|
|
<script>
|
2026-05-22 09:29:25 +08:00
|
|
|
|
initLayout('servers');
|
2026-05-30 19:45:53 +08:00
|
|
|
|
// ── State ──
|
2026-05-22 09:03:18 +08:00
|
|
|
|
let selectedServerId=null;
|
|
|
|
|
|
let _serversCache={};
|
|
|
|
|
|
let _currentDetailTab='info';
|
2026-05-22 11:01:17 +08:00
|
|
|
|
let _selectedIds=new Set();
|
2026-05-30 19:45:53 +08:00
|
|
|
|
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;
|
2026-05-27 14:33:19 +08:00
|
|
|
|
|
2026-05-30 19:45:53 +08:00
|
|
|
|
// ── API Base URL ──
|
2026-05-27 14:33:19 +08:00
|
|
|
|
async function loadApiBaseUrl(){
|
|
|
|
|
|
try{
|
|
|
|
|
|
const r=await apiFetch(API+'/servers/meta/api_base_url');
|
2026-05-30 19:45:53 +08:00
|
|
|
|
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){}
|
2026-05-27 14:33:19 +08:00
|
|
|
|
}
|
2026-05-21 22:11:38 +08:00
|
|
|
|
|
2026-05-30 19:45:53 +08:00
|
|
|
|
// ── Server List (paginated, searchable, sortable) ──
|
|
|
|
|
|
async function loadServers(resetPage=false){
|
|
|
|
|
|
if(resetPage) _currentPage=1;
|
2026-05-21 22:11:38 +08:00
|
|
|
|
try{
|
2026-05-22 10:11:15 +08:00
|
|
|
|
const category=document.getElementById('categoryFilter').value;
|
2026-05-30 19:45:53 +08:00
|
|
|
|
const search=document.getElementById('searchInput').value.trim();
|
|
|
|
|
|
const params=new URLSearchParams({page:_currentPage,per_page:_perPage});
|
|
|
|
|
|
if(category) params.set('category',category);
|
|
|
|
|
|
if(search) params.set('search',search);
|
|
|
|
|
|
if(_sortField!=='id'||_sortOrder!=='asc'){params.set('sort_by',_sortField);params.set('sort_order',_sortOrder)}
|
|
|
|
|
|
if(_statusFilter==='online') params.set('is_online','true');
|
|
|
|
|
|
if(_statusFilter==='offline') params.set('is_online','false');
|
|
|
|
|
|
|
|
|
|
|
|
// Show skeleton while loading
|
|
|
|
|
|
if(resetPage) renderSkeleton();
|
|
|
|
|
|
|
|
|
|
|
|
const url=API+'/servers/?'+params.toString();
|
2026-05-22 10:11:15 +08:00
|
|
|
|
const res=await apiFetch(url);
|
2026-05-30 19:45:53 +08:00
|
|
|
|
if(!res)return;
|
2026-05-21 23:18:49 +08:00
|
|
|
|
const data=await res.json();
|
2026-05-30 19:45:53 +08:00
|
|
|
|
const servers=data.items||[];
|
|
|
|
|
|
_totalItems=data.total||0;
|
|
|
|
|
|
_totalPages=data.pages||1;
|
|
|
|
|
|
_currentPage=data.page||1;
|
2026-05-22 00:02:46 +08:00
|
|
|
|
_serversCache={};servers.forEach(s=>_serversCache[s.id]=s);
|
2026-05-30 19:45:53 +08:00
|
|
|
|
|
|
|
|
|
|
renderServerTable(servers);
|
|
|
|
|
|
renderPagination();
|
|
|
|
|
|
renderServerCount();
|
|
|
|
|
|
}catch(e){
|
|
|
|
|
|
document.getElementById('serversTbody').innerHTML='<tr><td colspan="8" class="px-4 py-8 text-center text-red-400">加载失败</td></tr>';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Render: Server Table ──
|
|
|
|
|
|
function renderServerTable(servers){
|
|
|
|
|
|
const tbody=document.getElementById('serversTbody');
|
|
|
|
|
|
if(!servers.length){tbody.innerHTML=renderEmptyState();return}
|
|
|
|
|
|
tbody.innerHTML=servers.map(s=>renderServerRow(s)).join('');
|
|
|
|
|
|
document.getElementById('selectAll').checked=false;
|
|
|
|
|
|
updateBatchBar();
|
|
|
|
|
|
// Re-highlight selected server if on this page
|
|
|
|
|
|
if(selectedServerId&&_serversCache[selectedServerId]){
|
|
|
|
|
|
document.getElementById('row-'+selectedServerId)?.classList.add('bg-brand/5');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderServerRow(s){
|
|
|
|
|
|
const statusHtml=renderStatusBadge(s);
|
|
|
|
|
|
const driftHtml=driftBadge(s);
|
|
|
|
|
|
const actionsHtml=renderActions(s);
|
|
|
|
|
|
return `<tr class="hover:bg-slate-800/30 transition cursor-pointer ${selectedServerId===s.id?'bg-brand/5':''}" onclick="selectServer(${s.id})" id="row-${s.id}">
|
|
|
|
|
|
<td class="px-4 py-3" onclick="event.stopPropagation()"><input type="checkbox" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand srv-chk" data-id="${s.id}" onchange="toggleSelect(${s.id},this.checked)" ${_selectedIds.has(s.id)?'checked':''}></td>
|
|
|
|
|
|
<td class="px-4 py-3">${statusHtml}</td>
|
|
|
|
|
|
<td class="px-4 py-3 text-white font-medium">${esc(s.name)}${driftHtml}</td>
|
|
|
|
|
|
<td class="px-4 py-3 text-slate-400">${esc(s.domain)}:${s.port||22}</td>
|
|
|
|
|
|
<td class="px-4 py-3 text-slate-400">${esc(s.category||'--')}</td>
|
|
|
|
|
|
<td class="px-4 py-3 text-slate-400">${esc(s.agent_version)||'--'}</td>
|
|
|
|
|
|
<td class="px-4 py-3 text-slate-500 text-xs">${fmtTime(s.last_heartbeat)}</td>
|
|
|
|
|
|
<td class="px-4 py-3 text-right">${actionsHtml}</td>
|
|
|
|
|
|
</tr>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderStatusBadge(s){
|
|
|
|
|
|
if(s.agent_version&&s.is_online){
|
|
|
|
|
|
const sys=s.system_info;
|
|
|
|
|
|
const cpu=sys?.cpu_percent??sys?.cpu_usage;
|
|
|
|
|
|
const cpuHint=(cpu!=null&&cpu>80)?`<span class="text-red-400 text-[10px] ml-1">${cpu.toFixed(0)}%</span>`:'';
|
|
|
|
|
|
return `<span class="inline-flex items-center gap-1.5 text-xs"><span class="inline-block w-2 h-2 rounded-full bg-green-400"></span><span class="text-green-400">在线</span>${cpuHint}</span>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
if(s.agent_version&&!s.is_online){
|
|
|
|
|
|
return `<span class="inline-flex items-center gap-1.5 text-xs"><span class="inline-block w-2 h-2 rounded-full bg-red-400 animate-pulse"></span><span class="text-red-400">离线</span></span>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return `<span class="inline-flex items-center gap-1.5 text-xs"><span class="inline-block w-2 h-2 rounded-full bg-slate-600"></span><span class="text-slate-500">未安装</span></span>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderActions(s){
|
|
|
|
|
|
let html=`<a href="/app/terminal.html?server_id=${s.id}" onclick="event.stopPropagation()" class="text-brand-light hover:underline text-xs mr-2">终端</a>`;
|
|
|
|
|
|
if(s.agent_version) html+=`<button onclick="event.stopPropagation();_quickCheck(${s.id})" class="text-emerald-400 hover:underline text-xs mr-2">检查</button>`;
|
|
|
|
|
|
html+=`<button onclick="event.stopPropagation();showEditServer(${s.id})" class="text-slate-400 hover:underline text-xs mr-2">编辑</button>`;
|
|
|
|
|
|
html+=`<button onclick="event.stopPropagation();deleteServer(${s.id})" class="text-red-400 hover:underline text-xs">删除</button>`;
|
|
|
|
|
|
return html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderSkeleton(){
|
|
|
|
|
|
const tbody=document.getElementById('serversTbody');
|
|
|
|
|
|
tbody.innerHTML=Array(8).fill(0).map(()=>`<tr class="animate-pulse"><td class="px-4 py-3"><div class="w-4 h-4 bg-slate-700 rounded"></div></td><td class="px-4 py-3"><div class="w-12 h-4 bg-slate-700 rounded"></div></td><td class="px-4 py-3"><div class="w-24 h-4 bg-slate-700 rounded"></div></td><td class="px-4 py-3"><div class="w-28 h-4 bg-slate-700 rounded"></div></td><td class="px-4 py-3"><div class="w-16 h-4 bg-slate-700 rounded"></div></td><td class="px-4 py-3"><div class="w-12 h-4 bg-slate-700 rounded"></div></td><td class="px-4 py-3"><div class="w-20 h-4 bg-slate-700 rounded"></div></td><td class="px-4 py-3"><div class="w-16 h-4 bg-slate-700 rounded"></div></td></tr>`).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderEmptyState(){
|
|
|
|
|
|
const hasFilters=document.getElementById('searchInput').value||document.getElementById('categoryFilter').value||_statusFilter;
|
|
|
|
|
|
if(hasFilters){
|
|
|
|
|
|
return `<tr><td colspan="8" class="px-4 py-16 text-center"><div class="text-slate-500 text-4xl mb-3">🔍</div><div class="text-slate-400 mb-1">没有匹配的服务器</div><div class="text-slate-600 text-xs">尝试调整搜索条件或筛选项</div></td></tr>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return `<tr><td colspan="8" class="px-4 py-16 text-center"><div class="text-slate-500 text-4xl mb-3">🖥</div><div class="text-slate-400 mb-1">暂无服务器</div><div class="text-slate-600 text-xs">点击右上角「+ 添加」或「↓ 导入」开始</div></td></tr>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Render: Pagination ──
|
|
|
|
|
|
function renderPagination(){
|
|
|
|
|
|
const pg=document.getElementById('pagination');
|
|
|
|
|
|
if(!pg)return;
|
|
|
|
|
|
// Show page controls only when there are pages
|
|
|
|
|
|
const hasPages=_totalPages>1;
|
|
|
|
|
|
pg.classList.toggle('hidden',_totalPages<=1&&!_totalItems);
|
|
|
|
|
|
document.getElementById('serverCount').textContent=`第 ${_currentPage}/${Math.max(_totalPages,1)} 页,共 ${_totalItems} 台`;
|
|
|
|
|
|
const prevBtn=document.getElementById('prevBtn');
|
|
|
|
|
|
const nextBtn=document.getElementById('nextBtn');
|
|
|
|
|
|
const firstBtn=document.getElementById('firstBtn');
|
|
|
|
|
|
const lastBtn=document.getElementById('lastBtn');
|
|
|
|
|
|
if(prevBtn)prevBtn.disabled=_currentPage<=1;
|
|
|
|
|
|
if(nextBtn)nextBtn.disabled=_currentPage>=_totalPages;
|
|
|
|
|
|
if(firstBtn)firstBtn.disabled=_currentPage<=1;
|
|
|
|
|
|
if(lastBtn)lastBtn.disabled=_currentPage>=_totalPages;
|
|
|
|
|
|
// Page number buttons (show 5 centered around current)
|
|
|
|
|
|
const nums=document.getElementById('pageNumbers');
|
|
|
|
|
|
if(nums){
|
|
|
|
|
|
let start=Math.max(1,_currentPage-2);
|
|
|
|
|
|
let end=Math.min(_totalPages,start+4);
|
|
|
|
|
|
start=Math.max(1,end-4);
|
|
|
|
|
|
nums.innerHTML='';
|
|
|
|
|
|
for(let i=start;i<=end;i++){
|
|
|
|
|
|
const active=i===_currentPage;
|
|
|
|
|
|
nums.innerHTML+=`<button onclick="goToPage(${i})" class="px-2.5 py-1 text-xs rounded-lg transition ${active?'bg-brand text-white':'bg-slate-800 hover:bg-slate-700 text-slate-300'}">${i}</button>`;
|
2026-05-22 10:11:15 +08:00
|
|
|
|
}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderServerCount(){
|
|
|
|
|
|
// Already updated in renderPagination
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function goToPage(page){
|
|
|
|
|
|
page=Math.max(1,Math.min(page,_totalPages));
|
|
|
|
|
|
if(page===_currentPage)return;
|
|
|
|
|
|
_currentPage=page;
|
|
|
|
|
|
loadServers();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function changePerPage(val){
|
|
|
|
|
|
_perPage=parseInt(val)||50;
|
|
|
|
|
|
_currentPage=1;
|
|
|
|
|
|
loadServers();
|
2026-05-22 09:03:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 19:45:53 +08:00
|
|
|
|
// ── Sort ──
|
|
|
|
|
|
function toggleSort(field){
|
|
|
|
|
|
if(_sortField===field){_sortOrder=_sortOrder==='asc'?'desc':'asc'}
|
|
|
|
|
|
else{_sortField=field;_sortOrder='asc'}
|
|
|
|
|
|
// Update sort indicators
|
|
|
|
|
|
document.querySelectorAll('[id^="sort-"]').forEach(el=>el.textContent='');
|
|
|
|
|
|
const indicator=document.getElementById('sort-'+field);
|
|
|
|
|
|
if(indicator)indicator.textContent=_sortOrder==='asc'?'▲':'▼';
|
|
|
|
|
|
loadServers(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Search (server-side with debounce) ──
|
|
|
|
|
|
document.getElementById('searchInput').addEventListener('input',function(){
|
|
|
|
|
|
clearTimeout(_searchDebounce);
|
|
|
|
|
|
_searchDebounce=setTimeout(()=>loadServers(true),300);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── Auto Refresh ──
|
|
|
|
|
|
function toggleAutoRefresh(){
|
|
|
|
|
|
if(_autoRefreshEnabled) stopAutoRefresh();
|
|
|
|
|
|
else startAutoRefresh();
|
|
|
|
|
|
updateRefreshButton();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function startAutoRefresh(){
|
|
|
|
|
|
stopAutoRefresh();
|
|
|
|
|
|
_autoRefreshEnabled=true;
|
|
|
|
|
|
_autoRefreshTimer=setInterval(()=>{
|
|
|
|
|
|
if(_pauseAutoRefresh)return;
|
|
|
|
|
|
loadServers();
|
|
|
|
|
|
},_autoRefreshInterval);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stopAutoRefresh(){
|
|
|
|
|
|
if(_autoRefreshTimer)clearInterval(_autoRefreshTimer);
|
|
|
|
|
|
_autoRefreshTimer=null;
|
|
|
|
|
|
_autoRefreshEnabled=false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateRefreshButton(){
|
|
|
|
|
|
const btn=document.getElementById('refreshToggle');
|
|
|
|
|
|
if(!btn)return;
|
|
|
|
|
|
if(_autoRefreshEnabled){
|
|
|
|
|
|
btn.className='px-3 py-1.5 bg-brand/80 hover:bg-brand text-white text-sm rounded-lg transition';
|
|
|
|
|
|
btn.textContent='自动刷新中';
|
|
|
|
|
|
}else{
|
|
|
|
|
|
btn.className='px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 text-sm rounded-lg transition';
|
|
|
|
|
|
btn.textContent='自动刷新';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Quick Health Check ──
|
|
|
|
|
|
async function _quickCheck(id){
|
|
|
|
|
|
const s=_serversCache[id];if(!s)return;
|
|
|
|
|
|
toast('info',`正在检查 ${s.name}...`);
|
|
|
|
|
|
try{
|
|
|
|
|
|
const r=await apiFetch(API+'/servers/check',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_ids:[id]})});
|
|
|
|
|
|
if(!r)return;
|
|
|
|
|
|
const results=await r.json();
|
|
|
|
|
|
const result=results[id];
|
|
|
|
|
|
if(result.status==='online'||result.status==='healthy') toast('success',`${s.name} 在线`);
|
|
|
|
|
|
else toast('warning',`${s.name} 离线${result.error?' ('+result.error+')':''}`);
|
|
|
|
|
|
loadServers();
|
|
|
|
|
|
}catch(e){toast('error','检查失败')}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Keyboard Shortcuts ──
|
|
|
|
|
|
document.addEventListener('keydown',function(e){
|
|
|
|
|
|
const tag=e.target.tagName;
|
|
|
|
|
|
if(tag==='INPUT'||tag==='TEXTAREA'||tag==='SELECT')return;
|
|
|
|
|
|
const rows=Array.from(document.querySelectorAll('#serversTbody tr[id^="row-"]'));
|
|
|
|
|
|
if(!rows.length)return;
|
|
|
|
|
|
const currentIdx=rows.findIndex(r=>r.classList.contains('bg-brand/5'));
|
|
|
|
|
|
if(e.key==='ArrowDown'||e.key==='j'){
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const nextIdx=Math.min(currentIdx+1,rows.length-1);
|
|
|
|
|
|
const nextId=parseInt(rows[nextIdx].id.replace('row-',''));
|
|
|
|
|
|
selectServer(nextId);
|
|
|
|
|
|
rows[nextIdx].scrollIntoView({block:'nearest'});
|
|
|
|
|
|
}else if(e.key==='ArrowUp'||e.key==='k'){
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const prevIdx=Math.max(currentIdx-1,0);
|
|
|
|
|
|
const prevId=parseInt(rows[prevIdx].id.replace('row-',''));
|
|
|
|
|
|
selectServer(prevId);
|
|
|
|
|
|
rows[prevIdx].scrollIntoView({block:'nearest'});
|
|
|
|
|
|
}else if(e.key==='Escape'){
|
|
|
|
|
|
if(!document.getElementById('serverModal').classList.contains('hidden'))hideServerModal();
|
|
|
|
|
|
else if(!document.getElementById('detailPanel').classList.contains('hidden'))hideDetail();
|
|
|
|
|
|
}else if(e.key==='/'){
|
|
|
|
|
|
e.preventDefault();document.getElementById('searchInput').focus();
|
|
|
|
|
|
}else if(e.key==='r'&&!e.ctrlKey&&!e.metaKey){
|
|
|
|
|
|
loadServers();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── CSV Export ──
|
|
|
|
|
|
async function exportServersCSV(){
|
|
|
|
|
|
toast('info','正在导出...');
|
|
|
|
|
|
try{
|
|
|
|
|
|
const params=new URLSearchParams({page:1,per_page:5000});
|
|
|
|
|
|
const category=document.getElementById('categoryFilter').value;
|
|
|
|
|
|
const search=document.getElementById('searchInput').value.trim();
|
|
|
|
|
|
if(category)params.set('category',category);
|
|
|
|
|
|
if(search)params.set('search',search);
|
|
|
|
|
|
if(_statusFilter==='online')params.set('is_online','true');
|
|
|
|
|
|
if(_statusFilter==='offline')params.set('is_online','false');
|
|
|
|
|
|
const r=await apiFetch(API+'/servers/?'+params.toString());
|
|
|
|
|
|
if(!r){toast('error','导出失败');return}
|
|
|
|
|
|
const data=await r.json();
|
|
|
|
|
|
const servers=data.items||[];
|
|
|
|
|
|
const headers=['ID','名称','地址','端口','用户名','分类','状态','Agent版本','最后心跳'];
|
|
|
|
|
|
const rows=servers.map(s=>[s.id,s.name,s.domain,s.port,s.username,s.category||'',s.is_online?'在线':'离线',s.agent_version||'',s.last_heartbeat||'']);
|
|
|
|
|
|
const csv=''+[headers,...rows].map(r=>r.map(c=>`"${String(c).replace(/"/g,'""')}"`).join(',')).join('\n');
|
|
|
|
|
|
const blob=new Blob([csv],{type:'text/csv;charset=utf-8'});
|
|
|
|
|
|
const url=URL.createObjectURL(blob);
|
|
|
|
|
|
const a=document.createElement('a');a.href=url;a.download=`servers_${new Date().toISOString().slice(0,10)}.csv`;
|
|
|
|
|
|
a.click();URL.revokeObjectURL(url);
|
|
|
|
|
|
toast('success',`已导出 ${servers.length} 台服务器`);
|
|
|
|
|
|
}catch(e){toast('error','导出失败: '+e.message)}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Detail Panel ──
|
2026-05-22 09:03:18 +08:00
|
|
|
|
async function selectServer(id){
|
|
|
|
|
|
selectedServerId=id;
|
|
|
|
|
|
document.querySelectorAll('#serversTbody tr').forEach(r=>r.classList.remove('bg-brand/5'));
|
|
|
|
|
|
document.getElementById('row-'+id)?.classList.add('bg-brand/5');
|
|
|
|
|
|
const s=_serversCache[id];if(!s)return;
|
|
|
|
|
|
document.getElementById('detailName').textContent=s.name;
|
|
|
|
|
|
document.getElementById('detailAddr').textContent=s.domain+':'+(s.port||22);
|
|
|
|
|
|
document.getElementById('detailStatus').className='inline-block w-3 h-3 rounded-full '+(s.is_online?'bg-green-400':'bg-red-400');
|
|
|
|
|
|
document.getElementById('detailSSHLink').href='/app/terminal.html?server_id='+id;
|
|
|
|
|
|
document.getElementById('detailPanel').classList.remove('hidden');
|
|
|
|
|
|
loadSystemInfo(s);
|
|
|
|
|
|
showDetailTab('info');
|
2026-05-21 22:11:38 +08:00
|
|
|
|
}
|
2026-05-22 09:03:18 +08:00
|
|
|
|
|
|
|
|
|
|
function hideDetail(){
|
|
|
|
|
|
document.getElementById('detailPanel').classList.add('hidden');
|
|
|
|
|
|
selectedServerId=null;
|
|
|
|
|
|
document.querySelectorAll('#serversTbody tr').forEach(r=>r.classList.remove('bg-brand/5'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function loadSystemInfo(s){
|
|
|
|
|
|
const sys=s.system_info;
|
|
|
|
|
|
if(sys&&typeof sys==='object'){
|
2026-05-22 11:23:19 +08:00
|
|
|
|
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');
|
2026-05-27 02:17:35 +08:00
|
|
|
|
document.getElementById('siOSRelease').textContent=sys.os_release||'--';
|
|
|
|
|
|
document.getElementById('siPlatform').textContent=sys.platform||'--';
|
2026-05-22 09:03:18 +08:00
|
|
|
|
}else{
|
|
|
|
|
|
document.getElementById('siCPU').textContent='--';
|
|
|
|
|
|
document.getElementById('siMem').textContent='--';
|
|
|
|
|
|
document.getElementById('siDisk').textContent='--';
|
2026-05-27 02:17:35 +08:00
|
|
|
|
document.getElementById('siOSRelease').textContent='--';
|
|
|
|
|
|
document.getElementById('siPlatform').textContent='--';
|
2026-05-22 09:03:18 +08:00
|
|
|
|
}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
var desc=s.description||'';var target=s.target_path||'';var auth=s.auth_method;
|
2026-05-27 02:17:35 +08:00
|
|
|
|
document.getElementById('siDescRow').style.display=desc?'':'none';
|
|
|
|
|
|
document.getElementById('siDesc').textContent=desc;
|
|
|
|
|
|
document.getElementById('siTargetRow').style.display=target?'':'none';
|
|
|
|
|
|
document.getElementById('siTarget').textContent=target;
|
|
|
|
|
|
document.getElementById('siAuthRow').style.display=auth?'':'none';
|
|
|
|
|
|
document.getElementById('siAuth').textContent=auth==='password'?'密码':'SSH密钥';
|
2026-05-22 09:03:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadSyncLogs(){
|
|
|
|
|
|
if(!selectedServerId)return;
|
|
|
|
|
|
try{
|
|
|
|
|
|
const r=await apiFetch(API+'/servers/'+selectedServerId+'/logs?limit=20');
|
|
|
|
|
|
if(!r)return;const logs=await r.json();
|
|
|
|
|
|
const tbody=document.getElementById('syncLogTbody');
|
|
|
|
|
|
if(!logs.length){tbody.innerHTML='<tr><td colspan="5" class="py-4 text-center text-slate-500">暂无同步记录</td></tr>';return}
|
|
|
|
|
|
tbody.innerHTML=logs.map(l=>{
|
|
|
|
|
|
const sc=l.status==='success'?'text-green-400':l.status==='failed'?'text-red-400':'text-yellow-400';
|
|
|
|
|
|
return `<tr class="border-b border-slate-800/50"><td class="py-2 text-slate-300">${esc(l.sync_mode||'--')}</td><td class="py-2 ${sc}">${esc(l.status)}</td><td class="py-2 text-slate-500 text-xs max-w-xs truncate">${esc(l.source_path||'--')}</td><td class="py-2 text-slate-500">${l.duration_seconds!=null?l.duration_seconds+'s':'--'}</td><td class="py-2 text-slate-600 text-xs">${fmtTime(l.started_at)}</td></tr>`}).join('');
|
|
|
|
|
|
}catch(e){document.getElementById('syncLogTbody').innerHTML='<tr><td colspan="5" class="py-4 text-center text-red-400">加载失败</td></tr>'}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadSSHSessions(){
|
|
|
|
|
|
if(!selectedServerId)return;
|
|
|
|
|
|
try{
|
|
|
|
|
|
const r=await apiFetch(API+'/assets/ssh-sessions?server_id='+selectedServerId+'&limit=20');
|
|
|
|
|
|
if(!r)return;const sessions=await r.json();
|
|
|
|
|
|
const tbody=document.getElementById('sshSessTbody');
|
|
|
|
|
|
if(!sessions.length){tbody.innerHTML='<tr><td colspan="4" class="py-4 text-center text-slate-500">暂无SSH会话</td></tr>';return}
|
|
|
|
|
|
tbody.innerHTML=sessions.map(s=>{
|
|
|
|
|
|
const sc=s.status==='active'?'text-green-400':s.status==='closed'?'text-slate-500':'text-yellow-400';
|
|
|
|
|
|
return `<tr class="border-b border-slate-800/50"><td class="py-2 text-slate-300">${esc(s.remote_addr||'--')}</td><td class="py-2 ${sc}">${esc(s.status)}</td><td class="py-2 text-slate-500 text-xs">${fmtTime(s.started_at)}</td><td class="py-2 text-slate-600 text-xs">${fmtTime(s.closed_at)}</td></tr>`}).join('');
|
|
|
|
|
|
}catch(e){document.getElementById('sshSessTbody').innerHTML='<tr><td colspan="4" class="py-4 text-center text-red-400">加载失败</td></tr>'}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showDetailTab(tab){
|
|
|
|
|
|
_currentDetailTab=tab;
|
2026-05-23 17:16:14 +08:00
|
|
|
|
['Info','Sync','SSH','Agent'].forEach(t=>{
|
2026-05-22 09:03:18 +08:00
|
|
|
|
document.getElementById('tabContent'+t).classList.toggle('hidden',t.toLowerCase()!==tab);
|
|
|
|
|
|
const btn=document.getElementById('tab'+t);
|
2026-05-23 17:16:14 +08:00
|
|
|
|
if(!btn)return;
|
2026-05-22 09:03:18 +08:00
|
|
|
|
if(t.toLowerCase()===tab){btn.className='px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition'}
|
|
|
|
|
|
else{btn.className='px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition'}
|
|
|
|
|
|
});
|
|
|
|
|
|
if(tab==='sync')loadSyncLogs();
|
|
|
|
|
|
if(tab==='ssh')loadSSHSessions();
|
2026-05-23 17:16:14 +08:00
|
|
|
|
if(tab==='agent')loadAgentInstall();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Agent Install Tab ──
|
|
|
|
|
|
async function loadAgentInstall(){
|
|
|
|
|
|
if(!selectedServerId)return;
|
|
|
|
|
|
const el=document.getElementById('agentInstallContent');
|
2026-05-25 22:49:57 +08:00
|
|
|
|
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>';
|
2026-05-27 14:33:19 +08:00
|
|
|
|
const base_url_conf=!!(_apiBaseUrl);
|
2026-05-25 22:49:57 +08:00
|
|
|
|
const noKey=!s.agent_api_key_set;
|
|
|
|
|
|
let warnings='';
|
2026-05-30 19:45:53 +08:00
|
|
|
|
if(!base_url_conf)warnings+=`<div class="text-amber-400 text-xs">⚠ NEXUS_API_BASE_URL 未配置,请在系统设置中填写主站对外 URL</div>`;
|
2026-05-25 22:49:57 +08:00
|
|
|
|
if(noKey)warnings+=`<div class="text-amber-400 text-xs">⚠ 尚未生成 Agent API Key,请点击「编辑」→「🔄 重新生成」后再安装</div>`;
|
|
|
|
|
|
const canShowCmd=base_url_conf&&!noKey;
|
|
|
|
|
|
const cmdBlock=_cachedInstallCmd
|
2026-05-30 19:45:53 +08:00
|
|
|
|
?`<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>`
|
2026-05-25 22:49:57 +08:00
|
|
|
|
:canShowCmd
|
|
|
|
|
|
?`<button onclick="revealInstallCmd()" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition">🔐 查看安装命令(需验证密码)</button>`
|
2026-05-23 17:16:14 +08:00
|
|
|
|
:`<div class="text-slate-500 text-sm">(请先配置 URL 和 API Key)</div>`;
|
2026-05-25 22:49:57 +08:00
|
|
|
|
const remoteBtn=canShowCmd&&!s.is_online
|
2026-05-30 19:45:53 +08:00
|
|
|
|
?`<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>`
|
2026-05-25 22:49:57 +08:00
|
|
|
|
:(s.is_online
|
2026-05-30 19:45:53 +08:00
|
|
|
|
?`<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>`:''}`;
|
2026-05-25 22:49:57 +08:00
|
|
|
|
}
|
2026-05-23 17:47:22 +08:00
|
|
|
|
|
2026-05-25 22:49:57 +08:00
|
|
|
|
async function revealInstallCmd(){
|
|
|
|
|
|
if(!selectedServerId)return;
|
|
|
|
|
|
const pwd=prompt('请输入当前登录密码以查看安装命令:');
|
|
|
|
|
|
if(!pwd)return;
|
|
|
|
|
|
try{
|
2026-05-30 19:45:53 +08:00
|
|
|
|
const r=await apiFetch(API+'/servers/'+selectedServerId+'/agent-install-cmd',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({current_password:pwd})});
|
2026-05-25 22:49:57 +08:00
|
|
|
|
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();
|
2026-05-30 19:45:53 +08:00
|
|
|
|
if(d.install_cmd){_cachedInstallCmd=d.install_cmd;loadAgentInstall();toast('success','安装命令已显示')}
|
|
|
|
|
|
else{toast('warning','无法生成安装命令,请检查配置')}
|
2026-05-25 22:49:57 +08:00
|
|
|
|
}catch(e){toast('error','获取安装命令失败')}
|
2026-05-23 17:16:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 18:27:12 +08:00
|
|
|
|
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');
|
2026-05-30 19:45:53 +08:00
|
|
|
|
logEl?.classList.remove('hidden');logText.textContent='正在下载最新 agent.py 并重启服务...\n';
|
2026-05-23 18:27:12 +08:00
|
|
|
|
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();
|
2026-05-30 19:45:53 +08:00
|
|
|
|
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','升级失败')}
|
2026-05-23 18:27:12 +08:00
|
|
|
|
}catch(e){logText.textContent+='错误: '+e.message;toast('error','升级请求失败')}
|
|
|
|
|
|
finally{if(btn){btn.disabled=false;btn.textContent='⬆ 升级 Agent'}}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 16:56:23 +08:00
|
|
|
|
async function uninstallAgent(){
|
|
|
|
|
|
if(!selectedServerId)return;
|
2026-05-30 19:45:53 +08:00
|
|
|
|
if(!confirm('确定卸载该服务器的 Agent?\n将停止服务、删除安装目录和日志文件,并清除 Agent 状态。'))return;
|
2026-05-29 16:56:23 +08:00
|
|
|
|
const btn=document.getElementById('uninstallAgentBtn');
|
|
|
|
|
|
if(btn){btn.disabled=true;btn.textContent='卸载中...'}
|
|
|
|
|
|
const logEl=document.getElementById('remoteInstallLog');
|
|
|
|
|
|
const logText=document.getElementById('remoteInstallLogText');
|
2026-05-30 19:45:53 +08:00
|
|
|
|
logEl?.classList.remove('hidden');logText.textContent='正在停止 Agent 服务并清理安装目录...\n';
|
2026-05-29 16:56:23 +08:00
|
|
|
|
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();
|
2026-05-30 19:45:53 +08:00
|
|
|
|
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','卸载失败')}
|
2026-05-29 16:56:23 +08:00
|
|
|
|
}catch(e){logText.textContent+='错误: '+e.message;toast('error','卸载请求失败')}
|
|
|
|
|
|
finally{if(btn){btn.disabled=false;btn.textContent='🗑 卸载 Agent'}}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 17:16:14 +08:00
|
|
|
|
function copyAgentCmd(){
|
|
|
|
|
|
const cmd=document.getElementById('agentCmdText')?.textContent;
|
2026-05-30 19:45:53 +08:00
|
|
|
|
if(cmd){if(navigator.clipboard)navigator.clipboard.writeText(cmd).then(()=>toast('success','命令已复制')).catch(()=>toast('error','复制失败'));else toast('info','请手动选择并复制命令')}
|
2026-05-23 17:16:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 17:47:22 +08:00
|
|
|
|
function _startAgentOnlinePoller(serverId){
|
|
|
|
|
|
if(_agentOnlinePoller)clearInterval(_agentOnlinePoller);
|
2026-05-30 19:45:53 +08:00
|
|
|
|
let attempts=0;const MAX=24;
|
2026-05-23 17:47:22 +08:00
|
|
|
|
_agentOnlinePoller=setInterval(async()=>{
|
|
|
|
|
|
attempts++;
|
|
|
|
|
|
try{
|
|
|
|
|
|
const r=await apiFetch(API+'/servers/'+serverId);
|
2026-05-30 19:45:53 +08:00
|
|
|
|
if(!r)return;const s=await r.json();
|
2026-05-23 17:47:22 +08:00
|
|
|
|
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 心跳已收到,服务器在线!';
|
2026-05-30 19:45:53 +08:00
|
|
|
|
toast('success','Agent 已上线!');loadServers();
|
2026-05-23 17:47:22 +08:00
|
|
|
|
}else if(attempts>=MAX){
|
|
|
|
|
|
clearInterval(_agentOnlinePoller);_agentOnlinePoller=null;
|
|
|
|
|
|
const logText=document.getElementById('remoteInstallLogText');
|
2026-05-30 19:45:53 +08:00
|
|
|
|
if(logText)logText.textContent+='\n⚠ 等待超时(2分钟)。请稍后手动刷新确认。';
|
2026-05-23 17:47:22 +08:00
|
|
|
|
}
|
2026-05-26 09:16:05 +08:00
|
|
|
|
}catch(e){console.warn('Agent online poller error:',e)}
|
2026-05-23 17:47:22 +08:00
|
|
|
|
},5000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function _updateAgentStatusBadge(isOnline){
|
2026-05-30 19:45:53 +08:00
|
|
|
|
const el=document.getElementById('agentInstallContent');if(!el)return;
|
|
|
|
|
|
const badges=el.querySelectorAll('span[class*="rounded-full"]');if(!badges.length)return;
|
2026-05-23 17:47:22 +08:00
|
|
|
|
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');
|
2026-05-30 19:45:53 +08:00
|
|
|
|
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'}
|
2026-05-23 17:47:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 17:16:14 +08:00
|
|
|
|
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秒)'}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
logEl?.classList.remove('hidden');logText.textContent='正在通过 SSH 连接并执行安装脚本...\n';
|
2026-05-23 17:16:14 +08:00
|
|
|
|
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){
|
2026-05-30 19:45:53 +08:00
|
|
|
|
logText.textContent+=d.stdout||'';logText.textContent+='\n✓ 安装成功!等待首次心跳上线...\n';
|
|
|
|
|
|
toast('success','Agent 安装成功,等待上线...');_startAgentOnlinePoller(selectedServerId);
|
|
|
|
|
|
}else{logText.textContent+=(d.detail||d.stdout||'安装失败')+'\n';toast('error','安装失败,查看日志')}
|
|
|
|
|
|
}catch(e){logText.textContent+='错误: '+e.message;toast('error','安装请求失败')}
|
|
|
|
|
|
finally{if(btn){btn.disabled=false;btn.textContent='⚡ 一键远程安装(通过 SSH)'}}
|
2026-05-22 09:03:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 19:45:53 +08:00
|
|
|
|
async function deleteServer(id){
|
|
|
|
|
|
if(!confirm('确定删除服务器 #'+id+'?'))return;
|
|
|
|
|
|
const r=await apiFetch(API+'/servers/'+id,{method:'DELETE'});
|
|
|
|
|
|
if(r&&r.ok)toast('success','服务器已删除');
|
|
|
|
|
|
else toast('error','删除失败');
|
|
|
|
|
|
if(selectedServerId===id)hideDetail();
|
|
|
|
|
|
loadServers();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Server Form ──
|
2026-05-25 22:49:57 +08:00
|
|
|
|
function _toggleAgentKeyMode(isEdit){
|
|
|
|
|
|
document.getElementById('agentKeyAddMode').classList.toggle('hidden',isEdit);
|
|
|
|
|
|
document.getElementById('agentKeyEditMode').classList.toggle('hidden',!isEdit);
|
|
|
|
|
|
}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
|
2026-05-26 09:16:05 +08:00
|
|
|
|
function _refreshCategoryDatalist(categories){
|
2026-05-30 19:45:53 +08:00
|
|
|
|
if(!categories){const servers=Object.values(_serversCache);categories=[...new Set(servers.map(s=>s.category).filter(Boolean))].sort()}
|
2026-05-26 09:16:05 +08:00
|
|
|
|
const sel=document.getElementById('srvCategorySelect');
|
|
|
|
|
|
if(!sel)return;
|
2026-05-30 19:45:53 +08:00
|
|
|
|
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>';
|
2026-05-26 09:16:05 +08:00
|
|
|
|
}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
|
2026-05-26 09:16:05 +08:00
|
|
|
|
function _onCategoryChange(){
|
|
|
|
|
|
const sel=document.getElementById('srvCategorySelect');
|
2026-05-30 19:45:53 +08:00
|
|
|
|
const custom=document.getElementById('srvCategory');
|
|
|
|
|
|
if(sel.value==='__custom__'){custom.classList.remove('hidden');custom.focus()}
|
|
|
|
|
|
else{custom.classList.add('hidden');custom.value=''}
|
2026-05-26 09:16:05 +08:00
|
|
|
|
}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
|
2026-05-25 22:49:57 +08:00
|
|
|
|
function showAddServer(){
|
|
|
|
|
|
document.getElementById('editServerId').value='';
|
2026-05-30 19:45:53 +08:00
|
|
|
|
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();
|
2026-05-25 22:49:57 +08:00
|
|
|
|
document.getElementById('modalTitle').textContent='添加服务器';
|
|
|
|
|
|
document.getElementById('serverModal').classList.remove('hidden');
|
2026-05-30 19:45:53 +08:00
|
|
|
|
_pauseAutoRefresh=true;
|
2026-05-25 22:49:57 +08:00
|
|
|
|
}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
|
|
|
|
|
|
async function showEditServer(id){
|
2026-05-25 22:49:57 +08:00
|
|
|
|
const s=_serversCache[id];if(!s)return;
|
2026-05-30 19:45:53 +08:00
|
|
|
|
document.getElementById('editServerId').value=id;
|
2026-05-25 22:49:57 +08:00
|
|
|
|
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||'';
|
2026-05-30 19:45:53 +08:00
|
|
|
|
// 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||'';
|
2026-05-25 22:49:57 +08:00
|
|
|
|
_toggleAgentKeyMode(true);
|
|
|
|
|
|
toggleAuthFields();
|
2026-05-30 19:45:53 +08:00
|
|
|
|
document.getElementById('modalTitle').textContent='编辑服务器';
|
2026-05-25 22:49:57 +08:00
|
|
|
|
document.getElementById('serverModal').classList.remove('hidden');
|
2026-05-30 19:45:53 +08:00
|
|
|
|
_pauseAutoRefresh=true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hideServerModal(){
|
|
|
|
|
|
document.getElementById('serverModal').classList.add('hidden');
|
|
|
|
|
|
_pauseAutoRefresh=false;
|
2026-05-25 22:49:57 +08:00
|
|
|
|
}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
|
2026-05-25 22:49:57 +08:00
|
|
|
|
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');
|
2026-05-30 19:45:53 +08:00
|
|
|
|
if(sel.dataset.loaded)return;
|
2026-05-25 22:49:57 +08:00
|
|
|
|
try{
|
|
|
|
|
|
const r=await apiFetch(API+'/presets/');
|
2026-05-30 19:45:53 +08:00
|
|
|
|
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('');
|
2026-05-25 22:49:57 +08:00
|
|
|
|
sel.dataset.loaded='1';
|
2026-05-30 19:45:53 +08:00
|
|
|
|
}catch(e){}
|
2026-05-25 22:49:57 +08:00
|
|
|
|
}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
|
2026-05-25 22:49:57 +08:00
|
|
|
|
async function loadSshKeyPresetOptions(){
|
|
|
|
|
|
const sel=document.getElementById('srvKeyPreset');
|
2026-05-30 19:45:53 +08:00
|
|
|
|
if(sel.dataset.loaded)return;
|
2026-05-25 22:49:57 +08:00
|
|
|
|
try{
|
|
|
|
|
|
const r=await apiFetch(API+'/ssh-key-presets/');
|
2026-05-30 19:45:53 +08:00
|
|
|
|
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('');
|
2026-05-25 22:49:57 +08:00
|
|
|
|
sel.dataset.loaded='1';
|
2026-05-30 19:45:53 +08:00
|
|
|
|
}catch(e){}
|
2026-05-25 22:49:57 +08:00
|
|
|
|
}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
|
|
|
|
|
|
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':'';
|
2026-05-25 22:49:57 +08:00
|
|
|
|
});
|
2026-05-30 19:45:53 +08:00
|
|
|
|
|
2026-05-22 00:02:46 +08:00
|
|
|
|
async function saveServer(){
|
|
|
|
|
|
const id=document.getElementById('editServerId').value;
|
2026-05-30 19:45:53 +08:00
|
|
|
|
const isEdit=!!id;
|
2026-05-25 22:49:57 +08:00
|
|
|
|
const body={
|
2026-05-30 19:45:53 +08:00
|
|
|
|
name:document.getElementById('srvName').value.trim(),
|
|
|
|
|
|
domain:document.getElementById('srvDomain').value.trim(),
|
2026-05-25 22:49:57 +08:00
|
|
|
|
port:parseInt(document.getElementById('srvPort').value)||22,
|
2026-05-30 19:45:53 +08:00
|
|
|
|
username:document.getElementById('srvUser').value.trim()||'root',
|
2026-05-25 22:49:57 +08:00
|
|
|
|
auth_method:document.getElementById('srvAuth').value,
|
2026-05-30 19:45:53 +08:00
|
|
|
|
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,
|
2026-05-25 22:49:57 +08:00
|
|
|
|
};
|
2026-05-30 19:45:53 +08:00
|
|
|
|
// 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;
|
2026-05-25 22:49:57 +08:00
|
|
|
|
}else{
|
2026-05-30 19:45:53 +08:00
|
|
|
|
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;
|
2026-05-25 22:49:57 +08:00
|
|
|
|
}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
body.password=null;
|
2026-05-25 22:49:57 +08:00
|
|
|
|
}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
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)}
|
2026-05-23 17:04:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 22:29:06 +08:00
|
|
|
|
async function generateAgentKey(){
|
2026-05-30 19:45:53 +08:00
|
|
|
|
if(!selectedServerId)return;
|
|
|
|
|
|
const pwd=prompt('请输入当前登录密码以重新生成 Agent API Key:');
|
2026-05-25 22:49:57 +08:00
|
|
|
|
if(!pwd)return;
|
2026-05-22 22:29:06 +08:00
|
|
|
|
try{
|
2026-05-30 19:45:53 +08:00
|
|
|
|
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;
|
2026-05-23 15:26:56 +08:00
|
|
|
|
}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
}catch(e){toast('error','生成失败')}
|
2026-05-22 22:29:06 +08:00
|
|
|
|
}
|
2026-05-21 22:11:38 +08:00
|
|
|
|
|
2026-05-22 11:01:17 +08:00
|
|
|
|
// ── Batch Operations ──
|
|
|
|
|
|
function toggleSelect(id,checked){
|
2026-05-30 19:45:53 +08:00
|
|
|
|
if(checked)_selectedIds.add(id);else _selectedIds.delete(id);
|
2026-05-22 11:01:17 +08:00
|
|
|
|
updateBatchBar();
|
|
|
|
|
|
}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
|
2026-05-22 11:01:17 +08:00
|
|
|
|
function toggleSelectAll(checked){
|
|
|
|
|
|
const checkboxes=document.querySelectorAll('.srv-chk');
|
|
|
|
|
|
checkboxes.forEach(cb=>{
|
2026-05-30 19:45:53 +08:00
|
|
|
|
const id=parseInt(cb.dataset.id);cb.checked=checked;
|
|
|
|
|
|
if(checked)_selectedIds.add(id);else _selectedIds.delete(id);
|
2026-05-22 11:01:17 +08:00
|
|
|
|
});
|
|
|
|
|
|
updateBatchBar();
|
|
|
|
|
|
}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
|
2026-05-22 11:01:17 +08:00
|
|
|
|
function clearSelection(){
|
|
|
|
|
|
_selectedIds.clear();
|
|
|
|
|
|
document.querySelectorAll('.srv-chk').forEach(cb=>cb.checked=false);
|
|
|
|
|
|
document.getElementById('selectAll').checked=false;
|
|
|
|
|
|
updateBatchBar();
|
|
|
|
|
|
}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
|
2026-05-22 11:01:17 +08:00
|
|
|
|
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;
|
2026-05-30 19:45:53 +08:00
|
|
|
|
let allInstalled=true,allNotInstalled=true;
|
2026-05-27 02:17:35 +08:00
|
|
|
|
_selectedIds.forEach(id=>{
|
|
|
|
|
|
const s=_serversCache[id];
|
2026-05-30 19:45:53 +08:00
|
|
|
|
if(s){const hasAgent=!!s.agent_version;if(!hasAgent)allInstalled=false;if(hasAgent)allNotInstalled=false}
|
2026-05-27 02:17:35 +08:00
|
|
|
|
});
|
|
|
|
|
|
const btnInstall=document.getElementById('btnBatchInstall');
|
|
|
|
|
|
const btnUpgrade=document.getElementById('btnBatchUpgrade');
|
2026-05-29 16:56:23 +08:00
|
|
|
|
const btnDetect=document.getElementById('btnBatchDetect');
|
|
|
|
|
|
const btnUninstall=document.getElementById('btnBatchUninstall');
|
2026-05-30 19:45:53 +08:00
|
|
|
|
if(btnInstall)btnInstall.disabled=allInstalled;
|
|
|
|
|
|
if(btnUpgrade)btnUpgrade.disabled=allNotInstalled;
|
|
|
|
|
|
if(btnDetect)btnDetect.disabled=allNotInstalled;
|
|
|
|
|
|
if(btnUninstall)btnUninstall.disabled=allNotInstalled;
|
2026-05-22 11:01:17 +08:00
|
|
|
|
}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
|
2026-05-22 11:01:17 +08:00
|
|
|
|
function batchPush(){
|
|
|
|
|
|
if(!_selectedIds.size){toast('warning','请先选择服务器');return}
|
|
|
|
|
|
sessionStorage.setItem('batchPushIds',JSON.stringify([..._selectedIds]));
|
|
|
|
|
|
window.location.href='/app/push.html';
|
|
|
|
|
|
}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
|
2026-05-22 11:01:17 +08:00
|
|
|
|
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+' 失败':''}`);
|
2026-05-30 19:45:53 +08:00
|
|
|
|
clearSelection();loadServers();
|
2026-05-22 11:01:17 +08:00
|
|
|
|
if(selectedServerId&&ids.includes(selectedServerId))hideDetail();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 16:26:40 +08:00
|
|
|
|
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='检查中...'}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
_pauseAutoRefresh=true;
|
2026-05-24 16:26:40 +08:00
|
|
|
|
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();
|
2026-05-30 19:45:53 +08:00
|
|
|
|
let online=0,offline=0,errors=0;let detail='';
|
2026-05-24 16:26:40 +08:00
|
|
|
|
for(const[id,result]of Object.entries(results)){
|
2026-05-30 19:45:53 +08:00
|
|
|
|
const s=_serversCache[id];const name=s?s.name:'#'+id;
|
2026-05-24 16:26:40 +08:00
|
|
|
|
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){
|
2026-05-30 19:45:53 +08:00
|
|
|
|
const logEl=document.getElementById('remoteInstallLog');const logText=document.getElementById('remoteInstallLogText');
|
2026-05-24 16:26:40 +08:00
|
|
|
|
if(logEl&&logText){logEl.classList.remove('hidden');logText.textContent='健康检查结果:\n'+detail}
|
|
|
|
|
|
}
|
|
|
|
|
|
loadServers();
|
|
|
|
|
|
}catch(e){toast('error','健康检查失败: '+e.message)}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
finally{if(btn){btn.disabled=false;btn.textContent='健康检查'}_pauseAutoRefresh=false}
|
2026-05-24 16:26:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 19:45:53 +08:00
|
|
|
|
// ── Import ──
|
2026-05-26 00:17:43 +08:00
|
|
|
|
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');
|
2026-05-30 19:45:53 +08:00
|
|
|
|
_pauseAutoRefresh=true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hideImportModal(){
|
|
|
|
|
|
document.getElementById('importModal').classList.add('hidden');
|
|
|
|
|
|
_pauseAutoRefresh=false;
|
2026-05-26 00:17:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function doImport(){
|
2026-05-30 19:45:53 +08:00
|
|
|
|
const file=document.getElementById('importFile').files[0];
|
|
|
|
|
|
if(!file){toast('warning','请选择 CSV 文件');return}
|
|
|
|
|
|
const fd=new FormData();fd.append('file',file);
|
2026-05-26 00:17:43 +08:00
|
|
|
|
document.getElementById('importProgress').classList.remove('hidden');
|
|
|
|
|
|
document.getElementById('importProgressBar').style.width='50%';
|
2026-05-30 19:45:53 +08:00
|
|
|
|
document.getElementById('importStatus').textContent='上传中...';
|
|
|
|
|
|
document.getElementById('importBtn').disabled=true;
|
2026-05-26 00:17:43 +08:00
|
|
|
|
try{
|
2026-05-30 19:45:53 +08:00
|
|
|
|
const r=await apiFetch(API+'/servers/import',{method:'POST',body:fd});
|
|
|
|
|
|
if(!r){toast('error','导入失败');return}
|
2026-05-26 00:17:43 +08:00
|
|
|
|
const data=await r.json();
|
|
|
|
|
|
document.getElementById('importProgressBar').style.width='100%';
|
2026-05-30 19:45:53 +08:00
|
|
|
|
document.getElementById('importStatus').textContent='导入完成';
|
2026-05-26 00:17:43 +08:00
|
|
|
|
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){
|
2026-05-30 19:45:53 +08:00
|
|
|
|
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)}
|
2026-05-26 00:17:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 19:45:53 +08:00
|
|
|
|
// ── Batch Agent Modals ──
|
|
|
|
|
|
function hideBatchAgentModal(){document.getElementById('batchAgentModal').classList.add('hidden');_pauseAutoRefresh=false}
|
2026-05-26 00:17:43 +08:00
|
|
|
|
|
|
|
|
|
|
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');
|
2026-05-30 19:45:53 +08:00
|
|
|
|
_pauseAutoRefresh=true;
|
2026-05-26 00:17:43 +08:00
|
|
|
|
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}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
const data=await r.json();_showBatchAgentResult(data.results||[]);
|
2026-05-26 00:17:43 +08:00
|
|
|
|
}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');
|
2026-05-30 19:45:53 +08:00
|
|
|
|
_pauseAutoRefresh=true;
|
2026-05-26 00:17:43 +08:00
|
|
|
|
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}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
const data=await r.json();_showBatchAgentResult(data.results||[]);
|
2026-05-26 00:17:43 +08:00
|
|
|
|
}catch(e){toast('error','批量升级失败: '+e.message);hideBatchAgentModal()}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 16:56:23 +08:00
|
|
|
|
async function batchDetectPath(){
|
|
|
|
|
|
if(!_selectedIds.size){toast('warning','请先选择服务器');return}
|
|
|
|
|
|
const ids=[..._selectedIds];
|
2026-05-30 19:45:53 +08:00
|
|
|
|
if(!confirm(`确定对 ${ids.length} 台服务器自动检测 target_path?`))return;
|
2026-05-29 16:56:23 +08:00
|
|
|
|
document.getElementById('batchAgentTitle').textContent='自动检测路径';
|
|
|
|
|
|
document.getElementById('batchAgentModal').classList.remove('hidden');
|
|
|
|
|
|
document.getElementById('batchAgentProgress').classList.remove('hidden');
|
|
|
|
|
|
document.getElementById('batchAgentResult').classList.add('hidden');
|
2026-05-30 19:45:53 +08:00
|
|
|
|
_pauseAutoRefresh=true;
|
2026-05-29 16:56:23 +08:00
|
|
|
|
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}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
const data=await r.json();_showBatchAgentResult(data.results||[]);loadServers();
|
2026-05-29 16:56:23 +08:00
|
|
|
|
}catch(e){toast('error','检测失败: '+e.message);hideBatchAgentModal()}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function batchUninstallAgent(){
|
|
|
|
|
|
if(!_selectedIds.size){toast('warning','请先选择服务器');return}
|
|
|
|
|
|
const ids=[..._selectedIds];
|
2026-05-30 19:45:53 +08:00
|
|
|
|
if(!confirm(`确定对 ${ids.length} 台已安装 Agent 的服务器执行卸载?`))return;
|
2026-05-29 16:56:23 +08:00
|
|
|
|
document.getElementById('batchAgentTitle').textContent='批量卸载 Agent';
|
|
|
|
|
|
document.getElementById('batchAgentModal').classList.remove('hidden');
|
|
|
|
|
|
document.getElementById('batchAgentProgress').classList.remove('hidden');
|
|
|
|
|
|
document.getElementById('batchAgentResult').classList.add('hidden');
|
2026-05-30 19:45:53 +08:00
|
|
|
|
_pauseAutoRefresh=true;
|
2026-05-29 16:56:23 +08:00
|
|
|
|
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}
|
2026-05-30 19:45:53 +08:00
|
|
|
|
const data=await r.json();_showBatchAgentResult(data.results||[]);loadServers();
|
2026-05-29 16:56:23 +08:00
|
|
|
|
}catch(e){toast('error','卸载失败: '+e.message);hideBatchAgentModal()}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 00:17:43 +08:00
|
|
|
|
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=>{
|
2026-05-29 16:56:23 +08:00
|
|
|
|
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>`;
|
2026-05-26 00:17:43 +08:00
|
|
|
|
}).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 19:45:53 +08:00
|
|
|
|
// ── 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,'"')}
|
|
|
|
|
|
|
|
|
|
|
|
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 ──
|
2026-05-27 14:33:19 +08:00
|
|
|
|
loadApiBaseUrl();
|
2026-05-30 19:45:53 +08:00
|
|
|
|
loadCategories();
|
|
|
|
|
|
loadServers();
|
|
|
|
|
|
// Auto-select server from URL param
|
|
|
|
|
|
const highlight=new URLSearchParams(location.search).get('highlight');
|
|
|
|
|
|
if(highlight)setTimeout(()=>selectServer(parseInt(highlight)),1000);
|
2026-05-21 22:11:38 +08:00
|
|
|
|
</script>
|
2026-05-30 19:45:53 +08:00
|
|
|
|
|
2026-05-22 09:03:18 +08:00
|
|
|
|
</body></html>
|