Files
Nexus/web/app/index.html
T
Your Name b676e320de
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run
前端增强: XSS防护 + 共享布局 + 移动适配 + 设置页
- index.html: WebSocket告警文本esc()转义
- files.html: escAttr()函数 + 路径拼接转义
- layout.js: 移动端侧边栏overlay + 全局搜索 + 用户信息
- servers.html: 点击展开详情面板(系统信息/同步/SSH)
- settings.html: TOTP QR码设置 + 修改密码 + API Key复制
- api.js: JWT refresh token自动刷新 + Toast通知
- terminal.html: WebSSH xterm.js集成

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 22:29:06 +08:00

301 lines
13 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" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<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 src="/app/layout.js"></script>
<script>
initLayout('index');
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.server_name||'#'+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 data = await res.json();
const audits = data.items || data;
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 = '加载失败';
}
}
// ── 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; }
loadDashboard();
loadActivity();
loadSyncs();
connectWS();
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; }
// Refresh stats + recent syncs on any real-time event
loadDashboard();
loadSyncs();
if (msg.type === 'alert') {
_addAlert('\u{1F534}', `${msg.server_name} ${msg.alert_type} ${msg.alert_value.toFixed(1)}%`);
_playAlertSound();
const el = document.getElementById('statAlerts');
el.classList.add('animate-pulse');
setTimeout(() => el.classList.remove('animate-pulse'), 3000);
} else if (msg.type === 'recovery') {
_addAlert('\u{1F7E2}', `${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} ${esc(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');
}
// Alert sound using Web Audio API — no external file needed
function _playAlertSound() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
osc.type = 'sine';
gain.gain.value = 0.15;
osc.start();
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
osc.stop(ctx.currentTime + 0.3);
} catch(e) {}
// Flash tab title
_flashTitle();
}
let _titleFlashTimer = null;
function _flashTitle() {
const orig = document.title;
let count = 0;
clearInterval(_titleFlashTimer);
_titleFlashTimer = setInterval(() => {
document.title = count % 2 === 0 ? '🔴 告警 — Nexus' : orig;
count++;
if (count > 10) { clearInterval(_titleFlashTimer); document.title = orig; }
}, 800);
}
function refreshAll() {
loadDashboard();
loadActivity();
loadSyncs();
}
// Auto-refresh every 30s
setInterval(() => { loadDashboard(); loadSyncs(); }, 30000);
</script>
</body>
</html>