bfa50337e5
- servers.html: 服务器详情面板(系统信息/同步日志/SSH会话三标签页) - index.html: 仪表盘增强(分类分布图/最近同步日志/自动刷新30s) - credentials.html: 密码预设标签页(对接/api/presets/ CRUD)
312 lines
15 KiB
HTML
312 lines
15 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Nexus — 仪表盘</title>
|
|
<script src="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">
|
|
|
|
<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 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">
|
|
<a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium transition">🏠 仪表盘</a>
|
|
<a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🖥 服务器</a>
|
|
<a href="/app/files.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📁 文件管理</a>
|
|
<a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📤 推送</a>
|
|
<a href="/app/scripts.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📜 脚本库</a>
|
|
<a href="/app/credentials.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔑 凭据</a>
|
|
<a href="/app/schedules.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⏰ 调度</a>
|
|
<a href="/app/retries.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔄 重试队列</a>
|
|
<div class="border-t border-slate-800 my-2"></div>
|
|
<a href="/app/audit.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📋 审计日志</a>
|
|
<a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⚙️ 设置</a>
|
|
</nav>
|
|
<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">
|
|
<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>
|
|
|
|
<main class="flex-1 overflow-y-auto p-6 space-y-6">
|
|
|
|
<!-- 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>
|
|
</div>
|
|
<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-green-400" id="statOnline">--</div>
|
|
</div>
|
|
<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-red-400" id="statOffline">--</div>
|
|
</div>
|
|
<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-yellow-400" id="statAlerts">--</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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>
|
|
|
|
<script src="/app/api.js"></script>
|
|
<script>
|
|
const MAX_ALERTS = 20;
|
|
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;
|
|
_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=8');
|
|
if (!res) return;
|
|
const audits = await res.json();
|
|
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 = '加载失败';
|
|
}
|
|
}
|
|
|
|
// ── User Info ──
|
|
async function loadUser() {
|
|
try {
|
|
const res = await apiFetch(API + '/auth/me');
|
|
if (!res) { doLogout(); return; }
|
|
const user = await res.json();
|
|
document.getElementById('sidebarUser').textContent = user.username;
|
|
document.getElementById('headerUser').textContent = user.username;
|
|
} 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.onmessage = (e) => {
|
|
try {
|
|
const msg = JSON.parse(e.data);
|
|
if (msg.type === 'ping') { _ws.send('pong'); return; }
|
|
loadDashboard();
|
|
if (msg.type === 'alert') {
|
|
_addAlert('🔴', `${msg.server_name} ${msg.alert_type} ${msg.alert_value.toFixed(1)}%`);
|
|
} else if (msg.type === 'recovery') {
|
|
_addAlert('🟢', `${msg.server_name} ${msg.metric} 恢复正常 (${msg.value.toFixed(1)}%)`);
|
|
} else if (msg.type === 'system') {
|
|
_addAlert('⚠️', msg.message);
|
|
}
|
|
} catch(ex) {}
|
|
};
|
|
_ws.onclose = () => {
|
|
const delay = Math.min(1000 * Math.pow(2, _wsRetry), 30000);
|
|
_wsRetry++;
|
|
setTimeout(connectWS, delay);
|
|
};
|
|
_ws.onerror = () => { _ws.close(); };
|
|
}
|
|
|
|
function _addAlert(icon, text) {
|
|
_alerts.unshift({ icon, text, time: new Date() });
|
|
if (_alerts.length > MAX_ALERTS) _alerts.pop();
|
|
renderAlerts();
|
|
}
|
|
|
|
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('');
|
|
}
|
|
|
|
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>
|