b676e320de
- 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>
301 lines
13 KiB
HTML
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>
|