Files
Nexus/web/app/index.html
T

312 lines
15 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
2026-05-22 09:03:18 +08:00
<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>
2026-05-22 09:03:18 +08:00
<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">
2026-05-22 09:03:18 +08:00
<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">
2026-05-22 09:03:18 +08:00
<!-- 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>
2026-05-22 09:03:18 +08:00
<!-- 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>
2026-05-22 08:19:56 +08:00
</div>
</div>
2026-05-22 09:03:18 +08:00
<!-- 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>
2026-05-22 08:19:56 +08:00
const MAX_ALERTS = 20;
let _alerts = [];
let _ws = null;
let _wsRetry = 0;
2026-05-22 09:03:18 +08:00
let _stats = null;
2026-05-22 08:19:56 +08:00
// ── Dashboard Stats ──
async function loadDashboard() {
try {
const res = await apiFetch(API + '/servers/stats');
if (!res) return;
2026-05-22 09:03:18 +08:00
_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 || {});
2026-05-22 08:19:56 +08:00
} catch(e) {}
}
2026-05-22 09:03:18 +08:00
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 = '加载失败';
}
}
2026-05-22 08:19:56 +08:00
// ── Recent Activity ──
async function loadActivity() {
try {
2026-05-22 09:03:18 +08:00
const res = await apiFetch(API + '/audit/?limit=8');
2026-05-22 08:19:56 +08:00
if (!res) return;
const audits = await res.json();
2026-05-22 09:03:18 +08:00
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) {}
}
2026-05-22 09:03:18 +08:00
// ── 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) {}
}
}
2026-05-22 08:19:56 +08:00
// ── 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}`);
2026-05-22 09:03:18 +08:00
_ws.onopen = () => { _wsRetry = 0; };
2026-05-22 08:19:56 +08:00
_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');
2026-05-22 09:03:18 +08:00
document.getElementById('noAlertBanner').classList.add('hidden');
2026-05-22 08:19:56 +08:00
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('');
}
2026-05-22 09:03:18 +08:00
function clearAlerts() {
_alerts = [];
document.getElementById('alertBanner').classList.add('hidden');
document.getElementById('noAlertBanner').classList.remove('hidden');
}
function refreshAll() {
loadDashboard();
loadActivity();
loadSyncs();
}
// ── Helpers ──
2026-05-22 09:03:18 +08:00
function fmtTime(t) { if (!t) return ''; return new Date(t + 'Z').toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit' }); }
2026-05-22 08:19:56 +08:00
function fmtTimeObj(d) { return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); }
2026-05-22 09:03:18 +08:00
function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
2026-05-22 09:03:18 +08:00
loadBrand();
loadUser();
loadDashboard();
2026-05-22 08:19:56 +08:00
loadActivity();
2026-05-22 09:03:18 +08:00
loadSyncs();
2026-05-22 08:19:56 +08:00
connectWS();
2026-05-22 09:03:18 +08:00
// Auto-refresh every 30s
setInterval(() => { loadDashboard(); loadSyncs(); }, 30000);
</script>
</body>
2026-05-22 09:03:18 +08:00
</html>