P1+P2: 前端功能增强
- servers.html: 服务器详情面板(系统信息/同步日志/SSH会话三标签页) - index.html: 仪表盘增强(分类分布图/最近同步日志/自动刷新30s) - credentials.html: 密码预设标签页(对接/api/presets/ CRUD)
This commit is contained in:
+88
-20
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 凭据管理</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><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></head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true,tab:'db'}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0">
|
||||
<div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div>
|
||||
<nav class="flex-1 py-3 px-3 space-y-1">
|
||||
@@ -19,12 +19,24 @@
|
||||
<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>
|
||||
<button onclick="showAddCred()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 新建凭据</button>
|
||||
<button onclick="showAddCred()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 新建</button>
|
||||
</header>
|
||||
<!-- Tabs -->
|
||||
<div class="bg-slate-900 border-b border-slate-800 px-6 flex">
|
||||
<button onclick="switchTab('db')" id="tabDB" class="px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition">数据库凭据</button>
|
||||
<button onclick="switchTab('preset')" id="tabPreset" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">密码预设</button>
|
||||
</div>
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<div id="credsList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
|
||||
<!-- DB Credentials -->
|
||||
<div id="dbSection">
|
||||
<div id="credsList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
|
||||
</div>
|
||||
<!-- Password Presets -->
|
||||
<div id="presetSection" class="hidden">
|
||||
<div id="presetsList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Add Credential Modal -->
|
||||
<!-- Add DB Credential Modal -->
|
||||
<div id="credModal" 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-md space-y-3">
|
||||
<h2 class="text-white font-semibold text-lg">新建数据库凭据</h2>
|
||||
@@ -42,6 +54,17 @@
|
||||
<div class="flex gap-2 pt-2"><button onclick="createCred()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideCredModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Password Preset Modal -->
|
||||
<div id="presetModal" 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-md space-y-3">
|
||||
<h2 class="text-white font-semibold text-lg">新建密码预设</h2>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="presetName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="生产环境root密码"></div>
|
||||
<div><label class="block text-slate-400 text-xs mb-1">密码</label><input id="presetPass" type="password" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="输入密码(将加密存储)"></div>
|
||||
<div class="bg-slate-800/50 rounded-lg p-3 text-xs text-slate-500">密码将使用 AES-256 加密后存储,不可逆向查看。推送时自动解密使用。</div>
|
||||
<div class="flex gap-2 pt-2"><button onclick="createPreset()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hidePresetModal()" 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>
|
||||
@@ -49,10 +72,18 @@
|
||||
const DB_ICONS={mysql:'🐬',postgresql:'🐘',mariadb:'🐬',mongodb:'🍃',redis:'🔴'};
|
||||
const DB_PORTS={mysql:3306,postgresql:5432,mariadb:3306,mongodb:27017,redis:6379};
|
||||
|
||||
function switchTab(t){
|
||||
document.getElementById('tabDB').className='px-4 py-3 text-sm border-b-2 '+(t==='db'?'border-brand text-brand-light':'border-transparent text-slate-400 hover:text-white')+' transition';
|
||||
document.getElementById('tabPreset').className='px-4 py-3 text-sm border-b-2 '+(t==='preset'?'border-brand text-brand-light':'border-transparent text-slate-400 hover:text-white')+' transition';
|
||||
document.getElementById('dbSection').classList.toggle('hidden',t!=='db');
|
||||
document.getElementById('presetSection').classList.toggle('hidden',t!=='preset');
|
||||
if(t==='preset')loadPresets();
|
||||
}
|
||||
|
||||
// ── DB Credentials ──
|
||||
async function loadCreds(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/scripts/credentials');
|
||||
if(!r)return;
|
||||
const r=await apiFetch(API+'/scripts/credentials');if(!r)return;
|
||||
const creds=await r.json();
|
||||
document.getElementById('credsList').innerHTML=creds.length?creds.map(c=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -65,17 +96,26 @@
|
||||
</div>
|
||||
<button onclick="deleteCred(${c.id})" class="text-red-400 text-xs hover:underline">删除</button>
|
||||
</div>
|
||||
</div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无凭据</div>';
|
||||
</div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无数据库凭据</div>';
|
||||
}catch(e){document.getElementById('credsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
||||
}
|
||||
|
||||
function showAddCred(){
|
||||
['credName','credHost','credUser','credPass','credDb'].forEach(id=>document.getElementById(id).value='');
|
||||
document.getElementById('credType').value='mysql';
|
||||
document.getElementById('credPort').value='3306';
|
||||
document.getElementById('credModal').classList.remove('hidden');
|
||||
const t=Alpine?Alpine.store?'db':document.querySelector('[x-data]')?.__x?.$data?.tab:'db':'db';
|
||||
// Check which tab is active
|
||||
if(!document.getElementById('dbSection').classList.contains('hidden')){
|
||||
['credName','credHost','credUser','credPass','credDb'].forEach(id=>document.getElementById(id).value='');
|
||||
document.getElementById('credType').value='mysql';
|
||||
document.getElementById('credPort').value='3306';
|
||||
document.getElementById('credModal').classList.remove('hidden');
|
||||
}else{
|
||||
document.getElementById('presetName').value='';
|
||||
document.getElementById('presetPass').value='';
|
||||
document.getElementById('presetModal').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
function hideCredModal(){document.getElementById('credModal').classList.add('hidden')}
|
||||
function hidePresetModal(){document.getElementById('presetModal').classList.add('hidden')}
|
||||
|
||||
document.getElementById('credType').addEventListener('change',()=>{
|
||||
const t=document.getElementById('credType').value;
|
||||
@@ -83,15 +123,7 @@
|
||||
});
|
||||
|
||||
async function createCred(){
|
||||
const body={
|
||||
name:document.getElementById('credName').value,
|
||||
db_type:document.getElementById('credType').value,
|
||||
host:document.getElementById('credHost').value,
|
||||
port:parseInt(document.getElementById('credPort').value)||3306,
|
||||
username:document.getElementById('credUser').value,
|
||||
password:document.getElementById('credPass').value,
|
||||
database:document.getElementById('credDb').value||null,
|
||||
};
|
||||
const body={name:document.getElementById('credName').value,db_type:document.getElementById('credType').value,host:document.getElementById('credHost').value,port:parseInt(document.getElementById('credPort').value)||3306,username:document.getElementById('credUser').value,password:document.getElementById('credPass').value,database:document.getElementById('credDb').value||null};
|
||||
if(!body.name||!body.host||!body.username||!body.password){alert('名称、主机、用户名、密码必填');return}
|
||||
await apiFetch(API+'/scripts/credentials',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
hideCredModal();loadCreds();
|
||||
@@ -103,7 +135,43 @@
|
||||
loadCreds();
|
||||
}
|
||||
|
||||
// ── Password Presets ──
|
||||
async function loadPresets(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/presets/');if(!r)return;
|
||||
const presets=await r.json();
|
||||
document.getElementById('presetsList').innerHTML=presets.length?presets.map(p=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg">🔑</span>
|
||||
<div>
|
||||
<span class="text-white font-medium">${esc(p.name)}</span>
|
||||
<div class="text-slate-500 text-xs mt-0.5">加密存储 · 创建于 ${fmtTime(p.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="deletePreset(${p.id})" class="text-red-400 text-xs hover:underline">删除</button>
|
||||
</div>
|
||||
</div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无密码预设</div>';
|
||||
}catch(e){document.getElementById('presetsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
|
||||
}
|
||||
|
||||
async function createPreset(){
|
||||
const name=document.getElementById('presetName').value;
|
||||
const pw=document.getElementById('presetPass').value;
|
||||
if(!name||!pw){alert('名称和密码必填');return}
|
||||
await apiFetch(API+'/presets/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({name,encrypted_pw:pw})});
|
||||
hidePresetModal();loadPresets();
|
||||
}
|
||||
|
||||
async function deletePreset(id){
|
||||
if(!confirm('确定删除密码预设?'))return;
|
||||
await apiFetch(API+'/presets/'+id,{method:'DELETE'});
|
||||
loadPresets();
|
||||
}
|
||||
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
function fmtTime(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
|
||||
|
||||
loadCreds();
|
||||
</script>
|
||||
</body></html>
|
||||
|
||||
+140
-50
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" x-data="{ darkMode: localStorage.getItem('darkMode') !== 'false', sidebarOpen: true }" x-bind:class="darkMode ? 'dark' : ''" x-init="$watch('darkMode', v => localStorage.setItem('darkMode', v))">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -11,20 +11,18 @@
|
||||
--color-brand: oklch(55% 0.2 250);
|
||||
--color-brand-light: oklch(75% 0.15 250);
|
||||
--color-brand-dark: oklch(35% 0.18 250);
|
||||
--color-surface: oklch(98.5% 0.002 250);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex">
|
||||
|
||||
<!-- Sidebar (F2) -->
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0">
|
||||
<div class="px-5 py-4 border-b border-slate-800">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg>
|
||||
</div>
|
||||
<span class="text-white font-bold text-lg">Nexus</span>
|
||||
<span id="brandName" class="text-white font-bold text-lg">Nexus</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="flex-1 overflow-y-auto py-3 px-3 space-y-1">
|
||||
@@ -48,9 +46,7 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Header -->
|
||||
<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">
|
||||
@@ -59,18 +55,15 @@
|
||||
<h1 class="text-white font-semibold">仪表盘</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="darkMode = !darkMode" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">
|
||||
<span x-show="darkMode">🌙</span><span x-show="!darkMode">☀️</span>
|
||||
</button>
|
||||
<button onclick="refreshAll()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">刷新</button>
|
||||
<span id="headerUser" class="text-slate-400 text-sm">...</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Dashboard Content (F4) -->
|
||||
<main class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
|
||||
<!-- Stats -->
|
||||
<div id="statsRow" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<div class="text-slate-400 text-sm mb-1">服务器总数</div>
|
||||
<div class="text-3xl font-bold text-white" id="statTotal">--</div>
|
||||
@@ -89,19 +82,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Alerts -->
|
||||
<div id="alertBanner" class="hidden bg-red-900/30 border border-red-700/50 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-red-300 font-semibold text-sm">实时告警</h2>
|
||||
<button onclick="document.getElementById('alertBanner').classList.add('hidden')" class="text-red-400 hover:text-red-300 text-xs">清除</button>
|
||||
<!-- Live Alerts + Category Pie -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<!-- Alerts -->
|
||||
<div class="lg:col-span-2">
|
||||
<div id="alertBanner" class="hidden bg-red-900/30 border border-red-700/50 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-red-300 font-semibold text-sm">实时告警</h2>
|
||||
<button onclick="clearAlerts()" class="text-red-400 hover:text-red-300 text-xs">清除</button>
|
||||
</div>
|
||||
<div id="alertList" class="space-y-1 max-h-40 overflow-y-auto text-sm"></div>
|
||||
</div>
|
||||
<!-- No alerts state -->
|
||||
<div id="noAlertBanner" class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="flex items-center gap-2 text-slate-500 text-sm">
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-green-400"></span>
|
||||
所有服务器运行正常
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Category Breakdown -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-white font-semibold mb-4 text-sm">分类分布</h2>
|
||||
<div id="categoryBars" class="space-y-3">
|
||||
<div class="text-slate-500 text-sm">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="alertList" class="space-y-1 max-h-40 overflow-y-auto text-sm"></div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<h2 class="text-white font-semibold mb-4">最近活动</h2>
|
||||
<div id="recentActivity" class="text-slate-400 text-sm">加载中...</div>
|
||||
<!-- Recent Sync + Recent Audit -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<!-- Recent Sync Logs -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-white font-semibold text-sm">最近同步</h2>
|
||||
<a href="/app/push.html" class="text-brand-light text-xs hover:underline">查看全部</a>
|
||||
</div>
|
||||
<div id="recentSyncs" class="text-slate-400 text-sm">加载中...</div>
|
||||
</div>
|
||||
<!-- Recent Audit -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-white font-semibold text-sm">最近活动</h2>
|
||||
<a href="/app/audit.html" class="text-brand-light text-xs hover:underline">查看全部</a>
|
||||
</div>
|
||||
<div id="recentActivity" class="text-slate-400 text-sm">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -112,33 +138,80 @@
|
||||
let _alerts = [];
|
||||
let _ws = null;
|
||||
let _wsRetry = 0;
|
||||
let _stats = null;
|
||||
|
||||
// ── Dashboard Stats ──
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const res = await apiFetch(API + '/servers/stats');
|
||||
if (!res) return;
|
||||
const stats = await res.json();
|
||||
document.getElementById('statTotal').textContent = stats.total || 0;
|
||||
document.getElementById('statOnline').textContent = stats.online || 0;
|
||||
document.getElementById('statOffline').textContent = stats.offline || 0;
|
||||
document.getElementById('statAlerts').textContent = stats.alerts || 0;
|
||||
_stats = await res.json();
|
||||
document.getElementById('statTotal').textContent = _stats.total || 0;
|
||||
document.getElementById('statOnline').textContent = _stats.online || 0;
|
||||
document.getElementById('statOffline').textContent = _stats.offline || 0;
|
||||
document.getElementById('statAlerts').textContent = _stats.alerts || 0;
|
||||
// Category breakdown
|
||||
renderCategories(_stats.categories || {});
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function renderCategories(categories) {
|
||||
const entries = Object.entries(categories);
|
||||
if (!entries.length) {
|
||||
document.getElementById('categoryBars').innerHTML = '<div class="text-slate-500 text-sm">暂无分类数据</div>';
|
||||
return;
|
||||
}
|
||||
const total = entries.reduce((s,[,v]) => s+v, 0);
|
||||
const colors = ['bg-brand','bg-green-500','bg-yellow-500','bg-purple-500','bg-cyan-500','bg-pink-500'];
|
||||
document.getElementById('categoryBars').innerHTML = entries.map(([cat,count],i) => {
|
||||
const pct = Math.round(count/total*100);
|
||||
return `<div>
|
||||
<div class="flex justify-between text-xs mb-1"><span class="text-slate-300">${esc(cat||'未分类')}</span><span class="text-slate-500">${count}台 (${pct}%)</span></div>
|
||||
<div class="w-full bg-slate-800 rounded-full h-2"><div class="${colors[i%colors.length]} h-2 rounded-full transition-all" style="width:${pct}%"></div></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Recent Syncs ──
|
||||
async function loadSyncs() {
|
||||
try {
|
||||
const res = await apiFetch(API + '/servers/logs?limit=8');
|
||||
if (!res) return;
|
||||
const logs = await res.json();
|
||||
if (!logs.length) {
|
||||
document.getElementById('recentSyncs').innerHTML = '<div class="text-slate-500 text-sm">暂无同步记录</div>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('recentSyncs').innerHTML = logs.map(l => {
|
||||
const sc = l.status==='success'?'text-green-400':l.status==='failed'?'text-red-400':'text-yellow-400';
|
||||
const icon = l.status==='success'?'✅':l.status==='failed'?'❌':'⏳';
|
||||
return `<div class="flex items-center justify-between py-2 border-b border-slate-800 last:border-0">
|
||||
<div class="flex items-center gap-2"><span class="text-xs">${icon}</span><span class="text-slate-300 text-xs">${esc(l.sync_mode||'sync')} #${l.server_id||'?'}</span><span class="${sc} text-xs">${esc(l.status)}</span></div>
|
||||
<span class="text-slate-600 text-xs">${fmtTime(l.started_at)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(e) {
|
||||
document.getElementById('recentSyncs').textContent = '加载失败';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recent Activity ──
|
||||
async function loadActivity() {
|
||||
try {
|
||||
const res = await apiFetch(API + '/audit/?limit=10');
|
||||
const res = await apiFetch(API + '/audit/?limit=8');
|
||||
if (!res) return;
|
||||
const audits = await res.json();
|
||||
const html = audits.map(a =>
|
||||
`<div class="py-2 border-b border-slate-800 last:border-0">` +
|
||||
`<span class="text-brand-light">${a.admin_username || 'system'}</span> ` +
|
||||
`<span class="text-slate-500">${a.action}</span> ` +
|
||||
`<span class="text-slate-600 text-xs float-right">${fmtTime(a.created_at)}</span></div>`
|
||||
).join('') || '<div class="text-slate-500">暂无活动</div>';
|
||||
document.getElementById('recentActivity').innerHTML = html;
|
||||
if (!audits.length) {
|
||||
document.getElementById('recentActivity').innerHTML = '<div class="text-slate-500 text-sm">暂无活动</div>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('recentActivity').innerHTML = audits.map(a => {
|
||||
const icon = a.action.includes('delete')?'🔴':a.action.includes('create')?'🔵':a.action.includes('login')?'🟢':'⚪';
|
||||
return `<div class="flex items-center justify-between py-2 border-b border-slate-800 last:border-0">
|
||||
<div class="flex items-center gap-2"><span class="text-xs">${icon}</span><span class="text-brand-light text-xs">${esc(a.admin_username||'system')}</span><span class="text-slate-500 text-xs">${esc(a.action)}</span></div>
|
||||
<span class="text-slate-600 text-xs">${fmtTime(a.created_at)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(e) {
|
||||
document.getElementById('recentActivity').textContent = '加载失败';
|
||||
}
|
||||
@@ -155,26 +228,26 @@
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── Brand Name ──
|
||||
function loadBrand() {
|
||||
const admin = localStorage.getItem('admin');
|
||||
if (admin) {
|
||||
try { const a = JSON.parse(admin); if (a.system_name) document.getElementById('brandName').textContent = a.system_name; } catch(e) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── WebSocket Real-time Updates ──
|
||||
function connectWS() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) return;
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
_ws = new WebSocket(`${proto}://${location.host}/ws/alerts?token=${token}`);
|
||||
|
||||
_ws.onopen = () => {
|
||||
_wsRetry = 0;
|
||||
};
|
||||
|
||||
_ws.onopen = () => { _wsRetry = 0; };
|
||||
_ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'ping') { _ws.send('pong'); return; }
|
||||
|
||||
// Refresh stats on any event
|
||||
loadDashboard();
|
||||
|
||||
if (msg.type === 'alert') {
|
||||
_addAlert('🔴', `${msg.server_name} ${msg.alert_type} ${msg.alert_value.toFixed(1)}%`);
|
||||
} else if (msg.type === 'recovery') {
|
||||
@@ -184,13 +257,11 @@
|
||||
}
|
||||
} catch(ex) {}
|
||||
};
|
||||
|
||||
_ws.onclose = () => {
|
||||
const delay = Math.min(1000 * Math.pow(2, _wsRetry), 30000);
|
||||
_wsRetry++;
|
||||
setTimeout(connectWS, delay);
|
||||
};
|
||||
|
||||
_ws.onerror = () => { _ws.close(); };
|
||||
}
|
||||
|
||||
@@ -203,19 +274,38 @@
|
||||
function renderAlerts() {
|
||||
if (!_alerts.length) return;
|
||||
document.getElementById('alertBanner').classList.remove('hidden');
|
||||
document.getElementById('noAlertBanner').classList.add('hidden');
|
||||
document.getElementById('alertList').innerHTML = _alerts.map(a =>
|
||||
`<div class="text-slate-300">${a.icon} ${a.text} <span class="text-slate-600 text-xs float-right">${fmtTimeObj(a.time)}</span></div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
function fmtTime(t) { if (!t) return ''; const d = new Date(t + 'Z'); return d.toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit' }); }
|
||||
function fmtTimeObj(d) { return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); }
|
||||
function clearAlerts() {
|
||||
_alerts = [];
|
||||
document.getElementById('alertBanner').classList.add('hidden');
|
||||
document.getElementById('noAlertBanner').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
loadDashboard();
|
||||
loadActivity();
|
||||
loadSyncs();
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
function fmtTime(t) { if (!t) return ''; return new Date(t + 'Z').toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit' }); }
|
||||
function fmtTimeObj(d) { return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); }
|
||||
function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
loadBrand();
|
||||
loadUser();
|
||||
loadDashboard();
|
||||
loadActivity();
|
||||
loadSyncs();
|
||||
connectWS();
|
||||
|
||||
// Auto-refresh every 30s
|
||||
setInterval(() => { loadDashboard(); loadSyncs(); }, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
+154
-14
@@ -1,4 +1,9 @@
|
||||
<!DOCTYPE html><html lang="zh-CN" x-data="{ darkMode: true, sidebarOpen: true }" x-bind:class="darkMode ? 'dark' : ''" x-init="$watch('darkMode', v => { localStorage.setItem('darkMode', v); document.documentElement.classList.toggle('dark', v); })"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Nexus — 服务器</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250);--color-surface:oklch(98.5% 0.002 250)}</style></head>
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 服务器</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style>
|
||||
<style>
|
||||
.detail-panel{max-height:0;overflow:hidden;transition:max-height .3s ease}
|
||||
.detail-panel.open{max-height:800px}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0">
|
||||
<div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div>
|
||||
@@ -18,12 +23,62 @@
|
||||
<div class="border-t border-slate-800 p-4"><div class="flex items-center justify-between"><span id="sidebarUser" class="text-slate-400 text-sm">...</span><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs transition">退出</button></div></div>
|
||||
</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"><input id="searchInput" type="text" placeholder="搜索服务器..." class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48"><a href="/app/terminal.html" id="sshLink" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">SSH终端</a><button onclick="showAddServer()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">+ 添加</button></div></header>
|
||||
<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">
|
||||
<input id="searchInput" type="text" placeholder="搜索服务器..." class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48">
|
||||
<button onclick="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="text-left px-4 py-3">状态</th><th class="text-left px-4 py-3">名称</th><th class="text-left px-4 py-3">地址</th><th class="text-left px-4 py-3">平台</th><th class="text-left px-4 py-3">Agent版本</th><th class="text-left px-4 py-3">最后心跳</th><th class="text-right px-4 py-3">操作</th></tr></thead><tbody id="serversTbody" class="divide-y divide-slate-800"><tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr></tbody></table>
|
||||
<table class="w-full text-sm"><thead class="bg-slate-800/50 text-slate-400 text-xs uppercase"><tr><th class="text-left px-4 py-3">状态</th><th class="text-left px-4 py-3">名称</th><th class="text-left px-4 py-3">地址</th><th class="text-left px-4 py-3">分类</th><th class="text-left px-4 py-3">Agent</th><th class="text-left px-4 py-3">最后心跳</th><th class="text-right px-4 py-3">操作</th></tr></thead><tbody id="serversTbody" class="divide-y divide-slate-800"><tr><td colspan="7" 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>
|
||||
|
||||
<!-- Server Detail Panel -->
|
||||
<div id="detailPanel" class="hidden mt-4 bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-800 bg-slate-800/30">
|
||||
<div class="flex items-center gap-3">
|
||||
<span id="detailStatus" class="inline-block w-3 h-3 rounded-full"></span>
|
||||
<h2 id="detailName" class="text-white font-semibold text-lg"></h2>
|
||||
<span id="detailAddr" class="text-slate-400 text-sm"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a id="detailSSHLink" href="#" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">SSH终端</a>
|
||||
<button onclick="showEditServer(selectedServerId)" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">编辑</button>
|
||||
<button onclick="hideDetail()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-slate-800 px-6">
|
||||
<button onclick="showDetailTab('info')" id="tabInfo" class="px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition">系统信息</button>
|
||||
<button onclick="showDetailTab('sync')" id="tabSync" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">同步日志</button>
|
||||
<button onclick="showDetailTab('ssh')" id="tabSSH" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">SSH会话</button>
|
||||
</div>
|
||||
<!-- Tab Content: System Info -->
|
||||
<div id="tabContentInfo" class="p-6">
|
||||
<div id="sysInfoGrid" class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">CPU使用率</div><div id="siCPU" class="text-2xl font-bold text-white">--</div></div>
|
||||
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">内存使用率</div><div id="siMem" class="text-2xl font-bold text-white">--</div></div>
|
||||
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">磁盘使用率</div><div id="siDisk" class="text-2xl font-bold text-white">--</div></div>
|
||||
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">系统版本</div><div id="siOS" class="text-lg font-medium text-white truncate">--</div></div>
|
||||
</div>
|
||||
<div id="sysInfoMeta" class="mt-4 grid grid-cols-3 gap-4 text-sm">
|
||||
<div><span class="text-slate-500">描述:</span><span id="siDesc" class="text-slate-300">--</span></div>
|
||||
<div><span class="text-slate-500">目标路径:</span><span id="siTarget" class="text-slate-300">--</span></div>
|
||||
<div><span class="text-slate-500">认证方式:</span><span id="siAuth" class="text-slate-300">--</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tab Content: Sync Logs -->
|
||||
<div id="tabContentSync" class="hidden p-6">
|
||||
<table class="w-full text-sm"><thead class="text-slate-500 text-xs uppercase"><tr><th class="text-left py-2">模式</th><th class="text-left py-2">状态</th><th class="text-left py-2">源路径</th><th class="text-left py-2">耗时</th><th class="text-left py-2">时间</th></tr></thead><tbody id="syncLogTbody"><tr><td colspan="5" class="py-4 text-center text-slate-500">加载中...</td></tr></tbody></table>
|
||||
</div>
|
||||
<!-- Tab Content: SSH Sessions -->
|
||||
<div id="tabContentSSH" class="hidden p-6">
|
||||
<table class="w-full text-sm"><thead class="text-slate-500 text-xs uppercase"><tr><th class="text-left py-2">来源IP</th><th class="text-left py-2">状态</th><th class="text-left py-2">开始</th><th class="text-left py-2">结束</th></tr></thead><tbody id="sshSessTbody"><tr><td colspan="4" class="py-4 text-center text-slate-500">加载中...</td></tr></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-between text-xs text-slate-500"><span id="serverCount">共 -- 台</span><div class="flex gap-2"><button onclick="loadServers()" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 rounded-lg transition">刷新</button></div></div>
|
||||
|
||||
<!-- Add/Edit Server Modal -->
|
||||
<div id="serverModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
@@ -52,8 +107,9 @@
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script>
|
||||
let selectedServerId = null;
|
||||
let _serversCache = {};
|
||||
let selectedServerId=null;
|
||||
let _serversCache={};
|
||||
let _currentDetailTab='info';
|
||||
|
||||
async function loadServers(){
|
||||
try{
|
||||
@@ -66,19 +122,102 @@
|
||||
document.getElementById('serverCount').textContent=`共 ${total} 台`;
|
||||
const tbody=document.getElementById('serversTbody');
|
||||
if(!servers.length){tbody.innerHTML='<tr><td colspan="7" 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" onclick="selectServer(${s.id})" id="row-${s.id}">
|
||||
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"><span class="inline-block w-2.5 h-2.5 rounded-full ${s.is_online?'bg-green-400':'bg-red-400'} ${s.is_online?'':'animate-pulse'}"></span></td>
|
||||
<td class="px-4 py-3 text-white font-medium">${esc(s.name)}</td>
|
||||
<td class="px-4 py-3 text-slate-400">${esc(s.domain)}:${s.port||22}</td>
|
||||
<td class="px-4 py-3 text-slate-400">${s.platform_id||s.category||'--'}</td>
|
||||
<td class="px-4 py-3 text-slate-400">${s.category||'--'}</td>
|
||||
<td class="px-4 py-3 text-slate-400">${s.agent_version||'--'}</td>
|
||||
<td class="px-4 py-3 text-slate-500 text-xs">${fmtTime(s.last_heartbeat)}</td>
|
||||
<td class="px-4 py-3 text-right"><a href="/app/terminal.html?server_id=${s.id}" 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>
|
||||
<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('');
|
||||
}catch(e){document.getElementById('serversTbody').innerHTML='<tr><td colspan="7" class="px-4 py-8 text-center text-red-400">加载失败: '+esc(e.message)+'</td></tr>'}
|
||||
}catch(e){document.getElementById('serversTbody').innerHTML='<tr><td colspan="7" class="px-4 py-8 text-center text-red-400">加载失败</td></tr>'}
|
||||
}
|
||||
function selectServer(id){selectedServerId=id;document.querySelectorAll('tbody tr').forEach(r=>r.classList.remove('bg-brand/5'));document.getElementById('row-'+id)?.classList.add('bg-brand/5');document.getElementById('sshLink').href='/app/terminal.html?server_id='+id}
|
||||
async function deleteServer(id){if(!confirm('确定删除服务器 #'+id+'?'))return;await apiFetch(API+'/servers/'+id,{method:'DELETE'});loadServers()}
|
||||
|
||||
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)
|
||||
const sys=s.system_info;
|
||||
if(sys&&typeof sys==='object'){
|
||||
document.getElementById('siCPU').textContent=(sys.cpu_percent!=null?sys.cpu_percent.toFixed(1)+'%':'--');
|
||||
document.getElementById('siCPU').className='text-2xl font-bold '+(sys.cpu_percent>80?'text-red-400':sys.cpu_percent>60?'text-yellow-400':'text-green-400');
|
||||
document.getElementById('siMem').textContent=(sys.memory_percent!=null?sys.memory_percent.toFixed(1)+'%':'--');
|
||||
document.getElementById('siMem').className='text-2xl font-bold '+(sys.memory_percent>80?'text-red-400':sys.memory_percent>60?'text-yellow-400':'text-green-400');
|
||||
document.getElementById('siDisk').textContent=(sys.disk_percent!=null?sys.disk_percent.toFixed(1)+'%':'--');
|
||||
document.getElementById('siDisk').className='text-2xl font-bold '+(sys.disk_percent>80?'text-red-400':sys.disk_percent>60?'text-yellow-400':'text-green-400');
|
||||
document.getElementById('siOS').textContent=sys.os||sys.platform||'--';
|
||||
}else{
|
||||
document.getElementById('siCPU').textContent='--';
|
||||
document.getElementById('siMem').textContent='--';
|
||||
document.getElementById('siDisk').textContent='--';
|
||||
document.getElementById('siOS').textContent='--';
|
||||
}
|
||||
document.getElementById('siDesc').textContent=s.description||'--';
|
||||
document.getElementById('siTarget').textContent=s.target_path||'--';
|
||||
document.getElementById('siAuth').textContent=s.auth_method==='password'?'密码':'SSH密钥';
|
||||
}
|
||||
|
||||
async function loadSyncLogs(){
|
||||
if(!selectedServerId)return;
|
||||
try{
|
||||
const r=await apiFetch(API+'/servers/'+selectedServerId+'/logs?limit=20');
|
||||
if(!r)return;const logs=await r.json();
|
||||
const tbody=document.getElementById('syncLogTbody');
|
||||
if(!logs.length){tbody.innerHTML='<tr><td colspan="5" class="py-4 text-center text-slate-500">暂无同步记录</td></tr>';return}
|
||||
tbody.innerHTML=logs.map(l=>{
|
||||
const sc=l.status==='success'?'text-green-400':l.status==='failed'?'text-red-400':'text-yellow-400';
|
||||
return `<tr class="border-b border-slate-800/50"><td class="py-2 text-slate-300">${esc(l.sync_mode||'--')}</td><td class="py-2 ${sc}">${esc(l.status)}</td><td class="py-2 text-slate-500 text-xs max-w-xs truncate">${esc(l.source_path||'--')}</td><td class="py-2 text-slate-500">${l.duration_seconds!=null?l.duration_seconds+'s':'--'}</td><td class="py-2 text-slate-600 text-xs">${fmtTime(l.started_at)}</td></tr>`}).join('');
|
||||
}catch(e){document.getElementById('syncLogTbody').innerHTML='<tr><td colspan="5" class="py-4 text-center text-red-400">加载失败</td></tr>'}
|
||||
}
|
||||
|
||||
async function loadSSHSessions(){
|
||||
if(!selectedServerId)return;
|
||||
try{
|
||||
const r=await apiFetch(API+'/assets/ssh-sessions?server_id='+selectedServerId+'&limit=20');
|
||||
if(!r)return;const sessions=await r.json();
|
||||
const tbody=document.getElementById('sshSessTbody');
|
||||
if(!sessions.length){tbody.innerHTML='<tr><td colspan="4" class="py-4 text-center text-slate-500">暂无SSH会话</td></tr>';return}
|
||||
tbody.innerHTML=sessions.map(s=>{
|
||||
const sc=s.status==='active'?'text-green-400':s.status==='closed'?'text-slate-500':'text-yellow-400';
|
||||
return `<tr class="border-b border-slate-800/50"><td class="py-2 text-slate-300">${esc(s.remote_addr||'--')}</td><td class="py-2 ${sc}">${esc(s.status)}</td><td class="py-2 text-slate-500 text-xs">${fmtTime(s.started_at)}</td><td class="py-2 text-slate-600 text-xs">${fmtTime(s.closed_at)}</td></tr>`}).join('');
|
||||
}catch(e){document.getElementById('sshSessTbody').innerHTML='<tr><td colspan="4" class="py-4 text-center text-red-400">加载失败</td></tr>'}
|
||||
}
|
||||
|
||||
function showDetailTab(tab){
|
||||
_currentDetailTab=tab;
|
||||
['Info','Sync','SSH'].forEach(t=>{
|
||||
document.getElementById('tabContent'+t).classList.toggle('hidden',t.toLowerCase()!==tab);
|
||||
const btn=document.getElementById('tab'+t);
|
||||
if(t.toLowerCase()===tab){btn.className='px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition'}
|
||||
else{btn.className='px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition'}
|
||||
});
|
||||
if(tab==='sync')loadSyncLogs();
|
||||
if(tab==='ssh')loadSSHSessions();
|
||||
}
|
||||
|
||||
async function deleteServer(id){if(!confirm('确定删除服务器 #'+id+'?'))return;await apiFetch(API+'/servers/'+id,{method:'DELETE'});if(selectedServerId===id)hideDetail();loadServers()}
|
||||
function showAddServer(){document.getElementById('editServerId').value='';document.getElementById('modalTitle').textContent='添加服务器';['srvName','srvDomain','srvPassword','srvCategory','srvTarget','srvDesc'].forEach(id=>document.getElementById(id).value='');document.getElementById('srvPort').value='22';document.getElementById('srvUser').value='root';document.getElementById('srvAuth').value='key';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('srvCategory').value=s.category||'';document.getElementById('srvTarget').value=s.target_path||'';document.getElementById('srvDesc').value=s.description||'';document.getElementById('serverModal').classList.remove('hidden')}
|
||||
function hideServerModal(){document.getElementById('serverModal').classList.add('hidden')}
|
||||
@@ -89,7 +228,8 @@
|
||||
if(body.auth_method==='password'){body.password=document.getElementById('srvPassword').value}
|
||||
if(!body.name||!body.domain){alert('名称和地址必填');return}
|
||||
if(id){await apiFetch(API+'/servers/'+id,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify(body)})}else{await apiFetch(API+'/servers/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)})}
|
||||
hideServerModal();loadServers()
|
||||
hideServerModal();loadServers();
|
||||
if(selectedServerId&&id==selectedServerId)selectServer(parseInt(id));
|
||||
}
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
function fmtTime(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
|
||||
@@ -100,4 +240,4 @@
|
||||
async function loadUser(){try{const r=await apiFetch(API+'/auth/me');if(!r){doLogout();return}const u=await r.json();document.getElementById('sidebarUser').textContent=u.username}catch(e){}}
|
||||
loadUser();
|
||||
</script>
|
||||
</body></html>
|
||||
</body></html>
|
||||
|
||||
Reference in New Issue
Block a user