P1+P2: 前端功能增强

- servers.html: 服务器详情面板(系统信息/同步日志/SSH会话三标签页)
- index.html: 仪表盘增强(分类分布图/最近同步日志/自动刷新30s)
- credentials.html: 密码预设标签页(对接/api/presets/ CRUD)
This commit is contained in:
Your Name
2026-05-22 09:03:18 +08:00
parent 305f8d5689
commit bfa50337e5
3 changed files with 382 additions and 84 deletions
+88 -20
View File
@@ -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
View File
@@ -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
View File
@@ -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>