添加鉴权逻辑
This commit is contained in:
@@ -55,6 +55,22 @@
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
:root.light-theme {
|
||||
--bg-primary: #f5f7fb;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-card: rgba(255, 255, 255, 0.9);
|
||||
--bg-card-hover: rgba(255, 255, 255, 1);
|
||||
--bg-input: #ffffff;
|
||||
--border-color: rgba(99, 102, 241, 0.1);
|
||||
--border-hover: rgba(99, 102, 241, 0.25);
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--text-muted: #94a3b8;
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* ---- Reset & Base ---- */
|
||||
*, *::before, *::after {
|
||||
margin: 0;
|
||||
@@ -503,8 +519,8 @@ body {
|
||||
|
||||
.gauge {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.gauge svg {
|
||||
@@ -549,7 +565,7 @@ body {
|
||||
}
|
||||
|
||||
.gauge-label {
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
|
||||
@@ -47,6 +47,12 @@
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="clock" id="clock"></div>
|
||||
<button class="btn-icon theme-toggle" id="themeToggle" title="切换主题">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="sun-icon"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>
|
||||
</button>
|
||||
<div id="userSection">
|
||||
<button class="btn btn-login" id="btnLogin">登录</button>
|
||||
</div>
|
||||
<button class="btn-settings" id="btnSettings" title="配置管理">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
@@ -237,7 +243,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>状态</th>
|
||||
<th>服务器</th>
|
||||
<th>Job / 实例</th>
|
||||
<th>数据源</th>
|
||||
<th>CPU</th>
|
||||
<th>内存</th>
|
||||
@@ -303,6 +309,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Modal -->
|
||||
<div class="modal-overlay" id="loginModalOverlay">
|
||||
<div class="modal" style="max-width: 400px;">
|
||||
<div class="modal-header">
|
||||
<h2>用户登录</h2>
|
||||
<button class="modal-close" id="closeLoginModal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="loginForm">
|
||||
<div class="form-group">
|
||||
<label for="username">用户名</label>
|
||||
<input type="text" id="username" placeholder="请输入用户名" required>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top: 16px;">
|
||||
<label for="password">密码</label>
|
||||
<input type="password" id="password" placeholder="请输入密码" required>
|
||||
</div>
|
||||
<div id="loginError" style="color: var(--accent-rose); font-size: 0.8rem; margin-top: 10px; display: none;"></div>
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%; margin-top: 24px; background: var(--gradient-primary); color: white; border: none; padding: 12px; border-radius: 8px; cursor: pointer;">登 录</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/utils.js"></script>
|
||||
<script src="/js/chart.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
|
||||
@@ -125,10 +125,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="init-form" id="promForm" style="display: none;">
|
||||
<div class="init-form" id="adminForm" style="display: none;">
|
||||
<div class="init-header" style="margin-bottom: 20px;">
|
||||
<h2 style="font-size: 20px;">创建管理员账户</h2>
|
||||
<p>请设置系统的第一个管理员账号和密码</p>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group form-group-wide">
|
||||
<label for="promName">数据源名称 (Name)</label>
|
||||
<label for="adminUsername">管理员用户名</label>
|
||||
<input type="text" id="adminUsername" placeholder="请输入用户名" value="admin" autocomplete="username">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group form-group-wide">
|
||||
<label for="adminPassword">登录密码</label>
|
||||
<input type="password" id="adminPassword" placeholder="请输入密码" autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group form-group-wide">
|
||||
<label for="adminPasswordConfirm">确认密码</label>
|
||||
<input type="password" id="adminPasswordConfirm" placeholder="请再次输入密码" autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-message" id="adminMessageBox"></div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-add" id="btnAdminCreate">创建账户</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="init-form" id="promForm" style="display: none;">
|
||||
<div class="init-header" style="margin-bottom: 20px;">
|
||||
<h2 style="font-size: 20px;">设置数据源 (可选)</h2>
|
||||
<p>您可以现在添加一个 Prometheus 数据源,或者稍后在系统中配置</p>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group form-group-wide">
|
||||
<label for="promName">数据源名称</label>
|
||||
<input type="text" id="promName" placeholder="例如:生产环境" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
157
public/js/app.js
157
public/js/app.js
@@ -42,12 +42,21 @@
|
||||
btnTest: document.getElementById('btnTest'),
|
||||
btnAdd: document.getElementById('btnAdd'),
|
||||
formMessage: document.getElementById('formMessage'),
|
||||
sourceItems: document.getElementById('sourceItems')
|
||||
sourceItems: document.getElementById('sourceItems'),
|
||||
// Auth & Theme elements
|
||||
themeToggle: document.getElementById('themeToggle'),
|
||||
userSection: document.getElementById('userSection'),
|
||||
btnLogin: document.getElementById('btnLogin'),
|
||||
loginModal: document.getElementById('loginModalOverlay'),
|
||||
closeLoginModal: document.getElementById('closeLoginModal'),
|
||||
loginForm: document.getElementById('loginForm'),
|
||||
loginError: document.getElementById('loginError')
|
||||
};
|
||||
|
||||
// ---- State ----
|
||||
let previousMetrics = null;
|
||||
let networkChart = null;
|
||||
let user = null; // Currently logged in user
|
||||
|
||||
// ---- Initialize ----
|
||||
function init() {
|
||||
@@ -58,6 +67,12 @@
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
|
||||
// Theme initialization
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme === 'light') {
|
||||
document.documentElement.classList.add('light-theme');
|
||||
}
|
||||
|
||||
// Network chart
|
||||
networkChart = new AreaChart(dom.networkCanvas);
|
||||
|
||||
@@ -70,11 +85,26 @@
|
||||
dom.btnTest.addEventListener('click', testConnection);
|
||||
dom.btnAdd.addEventListener('click', addSource);
|
||||
|
||||
// Auth & Theme listeners
|
||||
dom.themeToggle.addEventListener('click', toggleTheme);
|
||||
dom.btnLogin.addEventListener('click', openLoginModal);
|
||||
dom.closeLoginModal.addEventListener('click', closeLoginModal);
|
||||
dom.loginForm.addEventListener('submit', handleLogin);
|
||||
dom.loginModal.addEventListener('click', (e) => {
|
||||
if (e.target === dom.loginModal) closeLoginModal();
|
||||
});
|
||||
|
||||
// Keyboard shortcut
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeSettings();
|
||||
if (e.key === 'Escape') {
|
||||
closeSettings();
|
||||
closeLoginModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Check auth status
|
||||
checkAuthStatus();
|
||||
|
||||
// Start data fetching
|
||||
fetchMetrics();
|
||||
fetchNetworkHistory();
|
||||
@@ -82,6 +112,88 @@
|
||||
setInterval(fetchNetworkHistory, NETWORK_HISTORY_INTERVAL);
|
||||
}
|
||||
|
||||
// ---- Theme Switching ----
|
||||
function toggleTheme() {
|
||||
const isLight = document.documentElement.classList.toggle('light-theme');
|
||||
localStorage.setItem('theme', isLight ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
// ---- Auth Logic ----
|
||||
async function checkAuthStatus() {
|
||||
try {
|
||||
const resp = await fetch('/api/auth/status');
|
||||
const data = await resp.json();
|
||||
if (data.authenticated) {
|
||||
updateUserUI(data.username);
|
||||
} else {
|
||||
updateUserUI(null);
|
||||
}
|
||||
} catch (err) {
|
||||
updateUserUI(null);
|
||||
}
|
||||
}
|
||||
|
||||
function updateUserUI(username) {
|
||||
if (username) {
|
||||
user = username;
|
||||
dom.userSection.innerHTML = `
|
||||
<div class="user-info">
|
||||
<span class="username">${escapeHtml(username)}</span>
|
||||
<button class="btn btn-logout" id="btnLogout">退出</button>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('btnLogout').addEventListener('click', handleLogout);
|
||||
} else {
|
||||
user = null;
|
||||
dom.userSection.innerHTML = `<button class="btn btn-login" id="btnLogin">登录</button>`;
|
||||
document.getElementById('btnLogin').addEventListener('click', openLoginModal);
|
||||
}
|
||||
}
|
||||
|
||||
function openLoginModal() {
|
||||
dom.loginModal.classList.add('active');
|
||||
dom.loginError.style.display = 'none';
|
||||
}
|
||||
|
||||
function closeLoginModal() {
|
||||
dom.loginModal.classList.remove('active');
|
||||
dom.loginForm.reset();
|
||||
}
|
||||
|
||||
async function handleLogin(e) {
|
||||
e.preventDefault();
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
updateUserUI(data.username);
|
||||
closeLoginModal();
|
||||
} else {
|
||||
dom.loginError.textContent = data.error || '登录失败';
|
||||
dom.loginError.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
dom.loginError.textContent = '服务器错误';
|
||||
dom.loginError.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
updateUserUI(null);
|
||||
} catch (err) {
|
||||
console.error('Logout failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Add SVG Gradient Defs ----
|
||||
function addGaugeSvgDefs() {
|
||||
const svgs = document.querySelectorAll('.gauge svg');
|
||||
@@ -97,7 +209,7 @@
|
||||
grad.setAttribute('id', gradients[i].id);
|
||||
grad.setAttribute('x1', '0%');
|
||||
grad.setAttribute('y1', '0%');
|
||||
grad.setAttribute('x2', '100%');
|
||||
grad.setAttribute('x1', '100%');
|
||||
grad.setAttribute('y2', '100%');
|
||||
|
||||
gradients[i].colors.forEach((color, ci) => {
|
||||
@@ -150,13 +262,13 @@
|
||||
dom.diskDetail.textContent = `${formatBytes(data.disk.used)} / ${formatBytes(data.disk.total)}`;
|
||||
|
||||
// Bandwidth
|
||||
dom.totalBandwidth.textContent = formatBandwidth(data.network.totalBandwidth);
|
||||
dom.totalBandwidth.textContent = formatBandwidth(data.network.total || 0);
|
||||
dom.bandwidthDetail.textContent = `↓ ${formatBandwidth(data.network.rx)} ↑ ${formatBandwidth(data.network.tx)}`;
|
||||
|
||||
// 24h traffic
|
||||
dom.traffic24hRx.textContent = formatBytes(data.traffic24h.rx);
|
||||
dom.traffic24hTx.textContent = formatBytes(data.traffic24h.tx);
|
||||
dom.traffic24hTotal.textContent = formatBytes(data.traffic24h.total);
|
||||
dom.traffic24hTotal.textContent = formatBytes(data.traffic24h.total || (data.traffic24h.rx + data.traffic24h.tx));
|
||||
|
||||
// Update gauges
|
||||
updateGauge(dom.gaugeCpuFill, dom.gaugeCpuValue, cpuPct);
|
||||
@@ -186,10 +298,6 @@
|
||||
const offset = CIRCUMFERENCE - (clamped / 100) * CIRCUMFERENCE;
|
||||
fillEl.style.strokeDashoffset = offset;
|
||||
valueEl.textContent = formatPercent(clamped);
|
||||
|
||||
// Change color based on usage
|
||||
const color = getUsageColor(clamped);
|
||||
// We keep gradient but could override for critical
|
||||
}
|
||||
|
||||
// ---- Server Table ----
|
||||
@@ -218,7 +326,10 @@
|
||||
<td>
|
||||
<span class="status-dot ${server.up ? 'status-dot-online' : 'status-dot-offline'}"></span>
|
||||
</td>
|
||||
<td style="color: var(--text-primary); font-weight: 500;">${escapeHtml(server.instance)}</td>
|
||||
<td>
|
||||
<div style="color: var(--text-primary); font-weight: 600; font-family: var(--font-sans);">${escapeHtml(server.job)}</div>
|
||||
<div style="font-size: 0.65rem; color: var(--text-muted); opacity: 0.8;">${escapeHtml(server.instance)}</div>
|
||||
</td>
|
||||
<td>${escapeHtml(server.source)}</td>
|
||||
<td>
|
||||
<div class="usage-bar">
|
||||
@@ -330,7 +441,12 @@
|
||||
if (data.status === 'ok') {
|
||||
showMessage(`连接成功!Prometheus 版本: ${data.version}`, 'success');
|
||||
} else {
|
||||
showMessage(`连接失败: ${data.message}`, 'error');
|
||||
if (response.status === 401) {
|
||||
showMessage('请先登录', 'error');
|
||||
openLoginModal();
|
||||
} else {
|
||||
showMessage(`连接失败: ${data.message || data.error}`, 'error');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
showMessage(`连接失败: ${err.message}`, 'error');
|
||||
@@ -342,6 +458,12 @@
|
||||
|
||||
// ---- Add Source ----
|
||||
async function addSource() {
|
||||
if (!user) {
|
||||
showMessage('请先登录后操作', 'error');
|
||||
openLoginModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const name = dom.sourceName.value.trim();
|
||||
const url = dom.sourceUrl.value.trim();
|
||||
const description = dom.sourceDesc.value.trim();
|
||||
@@ -367,12 +489,12 @@
|
||||
dom.sourceUrl.value = '';
|
||||
dom.sourceDesc.value = '';
|
||||
loadSources();
|
||||
// Refresh metrics immediately
|
||||
fetchMetrics();
|
||||
fetchNetworkHistory();
|
||||
} else {
|
||||
const err = await response.json();
|
||||
showMessage(`添加失败: ${err.error}`, 'error');
|
||||
if (response.status === 401) openLoginModal();
|
||||
}
|
||||
} catch (err) {
|
||||
showMessage(`添加失败: ${err.message}`, 'error');
|
||||
@@ -384,6 +506,11 @@
|
||||
|
||||
// ---- Delete Source ----
|
||||
window.deleteSource = async function (id) {
|
||||
if (!user) {
|
||||
showMessage('请先登录后操作', 'error');
|
||||
openLoginModal();
|
||||
return;
|
||||
}
|
||||
if (!confirm('确定要删除这个数据源吗?')) return;
|
||||
|
||||
try {
|
||||
@@ -392,6 +519,8 @@
|
||||
loadSources();
|
||||
fetchMetrics();
|
||||
fetchNetworkHistory();
|
||||
} else {
|
||||
if (response.status === 401) openLoginModal();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting source:', err);
|
||||
@@ -412,7 +541,7 @@
|
||||
// ---- Escape HTML ----
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
div.textContent = text || '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
@@ -421,7 +550,7 @@
|
||||
try {
|
||||
const response = await fetch('/api/sources');
|
||||
const sources = await response.json();
|
||||
dom.sourceCount.textContent = `${sources.length} 个数据源`;
|
||||
dom.sourceCount.textContent = `${Array.isArray(sources) ? sources.length : 0} 个数据源`;
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -21,16 +21,58 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const btnPromAdd = document.getElementById('btnPromAdd');
|
||||
const promMessageBox = document.getElementById('promMessageBox');
|
||||
|
||||
const adminForm = document.getElementById('adminForm');
|
||||
const adminUsername = document.getElementById('adminUsername');
|
||||
const adminPassword = document.getElementById('adminPassword');
|
||||
const adminPasswordConfirm = document.getElementById('adminPasswordConfirm');
|
||||
const btnAdminCreate = document.getElementById('btnAdminCreate');
|
||||
const adminMessageBox = document.getElementById('adminMessageBox');
|
||||
|
||||
function showMessage(msg, isError = false) {
|
||||
messageBox.textContent = msg;
|
||||
messageBox.className = 'form-message ' + (isError ? 'error' : 'success');
|
||||
}
|
||||
|
||||
function showAdminMessage(msg, isError = false) {
|
||||
adminMessageBox.textContent = msg;
|
||||
adminMessageBox.className = 'form-message ' + (isError ? 'error' : 'success');
|
||||
}
|
||||
|
||||
function showPromMessage(msg, isError = false) {
|
||||
promMessageBox.textContent = msg;
|
||||
promMessageBox.className = 'form-message ' + (isError ? 'error' : 'success');
|
||||
}
|
||||
|
||||
// --- Step Controller ---
|
||||
async function checkStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/setup/status');
|
||||
const data = await res.json();
|
||||
|
||||
initForm.style.display = 'none';
|
||||
adminForm.style.display = 'none';
|
||||
promForm.style.display = 'none';
|
||||
|
||||
if (!data.initialized) {
|
||||
initForm.style.display = 'block';
|
||||
initHeaderTitle.textContent = '数据库初始化';
|
||||
initHeaderDesc.textContent = '请配置您的 MySQL 数据库连接信息以完成首次设置';
|
||||
} else if (data.needsAdmin) {
|
||||
adminForm.style.display = 'block';
|
||||
initHeaderTitle.textContent = '创建管理员账户';
|
||||
initHeaderDesc.textContent = '请设置系统的第一个管理员账号和密码';
|
||||
} else {
|
||||
promForm.style.display = 'block';
|
||||
initHeaderTitle.textContent = '配置 Prometheus';
|
||||
initHeaderDesc.textContent = '配置您的第一个 Prometheus 数据源监控连接';
|
||||
}
|
||||
} catch (err) {
|
||||
initForm.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
checkStatus();
|
||||
|
||||
btnTest.addEventListener('click', async () => {
|
||||
btnTest.disabled = true;
|
||||
const oldText = btnTest.textContent;
|
||||
@@ -79,12 +121,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showMessage('数据库初始化成功!进入下一步...');
|
||||
setTimeout(() => {
|
||||
initForm.style.display = 'none';
|
||||
promForm.style.display = 'block';
|
||||
initHeaderTitle.textContent = '配置 Prometheus';
|
||||
initHeaderDesc.textContent = '配置您的第一个 Prometheus 数据源监控连接';
|
||||
}, 1000);
|
||||
setTimeout(checkStatus, 1000);
|
||||
} else {
|
||||
showMessage('初始化失败: ' + (data.error || '未知错误'), true);
|
||||
btnInit.disabled = false;
|
||||
@@ -97,6 +134,39 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
btnAdminCreate.addEventListener('click', async () => {
|
||||
const username = adminUsername.value.trim();
|
||||
const password = adminPassword.value;
|
||||
const confirm = adminPasswordConfirm.value;
|
||||
|
||||
if (!username || !password) return showAdminMessage('请填写用户名和密码', true);
|
||||
if (password !== confirm) return showAdminMessage('两次输入的密码不一致', true);
|
||||
|
||||
btnAdminCreate.disabled = true;
|
||||
btnAdminCreate.textContent = '创建中...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/setup/admin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
showAdminMessage('管理员账户创建成功!');
|
||||
setTimeout(checkStatus, 1000);
|
||||
} else {
|
||||
showAdminMessage('创建失败: ' + (data.error || '未知错误'), true);
|
||||
btnAdminCreate.disabled = false;
|
||||
btnAdminCreate.textContent = '创建账户';
|
||||
}
|
||||
} catch (err) {
|
||||
showAdminMessage('请求失败: ' + err.message, true);
|
||||
btnAdminCreate.disabled = false;
|
||||
btnAdminCreate.textContent = '创建账户';
|
||||
}
|
||||
});
|
||||
|
||||
btnPromTest.addEventListener('click', async () => {
|
||||
const url = promUrl.value.trim();
|
||||
if (!url) return showPromMessage('请输入 Prometheus URL', true);
|
||||
@@ -144,6 +214,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(() => window.location.href = '/', 1500);
|
||||
} else {
|
||||
const err = await res.json();
|
||||
// If 401, it means we somehow bypassed auth on this step, but it might be okay during init
|
||||
showPromMessage(`添加失败: ${err.error || '未知错误'}`, true);
|
||||
btnPromAdd.disabled = false;
|
||||
btnPromAdd.textContent = oldText;
|
||||
|
||||
145
server/index.js
145
server/index.js
@@ -11,24 +11,80 @@ const HOST = process.env.HOST || '0.0.0.0';
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
|
||||
let isDbInitialized = false;
|
||||
const sessions = new Map(); // Simple session store: sessionId -> {userId, username}
|
||||
|
||||
// Middleware: Check Auth
|
||||
function requireAuth(req, res, next) {
|
||||
const sessionId = getCookie(req, 'session_id');
|
||||
if (sessionId && sessions.has(sessionId)) {
|
||||
req.user = sessions.get(sessionId);
|
||||
return next();
|
||||
}
|
||||
res.status(401).json({ error: 'Auth required' });
|
||||
}
|
||||
|
||||
// Helper: Get Cookie
|
||||
function getCookie(req, name) {
|
||||
const matches = req.headers.cookie && req.headers.cookie.match(new RegExp('(?:^|; )' + name.replace(/([\.$?*|{}\(\)\[\]\\\/\+^])/g, '\\$1') + '=([^;]*)'));
|
||||
return matches ? decodeURIComponent(matches[1]) : undefined;
|
||||
}
|
||||
|
||||
async function checkDb() {
|
||||
try {
|
||||
const [rows] = await db.query("SHOW TABLES LIKE 'prometheus_sources'");
|
||||
if (rows.length > 0) {
|
||||
isDbInitialized = true;
|
||||
} else {
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync(path.join(__dirname, '..', '.env'))) {
|
||||
isDbInitialized = false;
|
||||
return;
|
||||
}
|
||||
const [rows] = await db.query("SHOW TABLES LIKE 'prometheus_sources'");
|
||||
isDbInitialized = rows.length > 0;
|
||||
} catch (err) {
|
||||
isDbInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
checkDb();
|
||||
// --- Auth API ---
|
||||
app.post('/api/auth/login', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
try {
|
||||
const [rows] = await db.query('SELECT * FROM users WHERE username = ?', [username]);
|
||||
if (rows.length === 0) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
|
||||
const user = rows[0];
|
||||
const hash = crypto.pbkdf2Sync(password, user.salt, 1000, 64, 'sha512').toString('hex');
|
||||
|
||||
if (hash === user.password) {
|
||||
const sessionId = crypto.randomBytes(32).toString('hex');
|
||||
sessions.set(sessionId, { id: user.id, username: user.username });
|
||||
res.setHeader('Set-Cookie', `session_id=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`);
|
||||
res.json({ success: true, username: user.username });
|
||||
} else {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (req, res) => {
|
||||
const sessionId = getCookie(req, 'session_id');
|
||||
if (sessionId) sessions.delete(sessionId);
|
||||
res.setHeader('Set-Cookie', 'session_id=; Path=/; HttpOnly; Max-Age=0');
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/api/auth/status', (req, res) => {
|
||||
const sessionId = getCookie(req, 'session_id');
|
||||
if (sessionId && sessions.has(sessionId)) {
|
||||
res.json({ authenticated: true, username: sessions.get(sessionId).username });
|
||||
} else {
|
||||
res.json({ authenticated: false });
|
||||
}
|
||||
});
|
||||
|
||||
// Setup API Routes
|
||||
app.post('/api/setup/test', async (req, res) => {
|
||||
@@ -78,6 +134,16 @@ app.post('/api/setup/init', async (req, res) => {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL UNIQUE,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
salt VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await connection.end();
|
||||
|
||||
// Save to .env
|
||||
@@ -110,18 +176,71 @@ REFRESH_INTERVAL=${process.env.REFRESH_INTERVAL || 5000}
|
||||
}
|
||||
});
|
||||
|
||||
// Middleware to protect routes
|
||||
app.use((req, res, next) => {
|
||||
if (!isDbInitialized) {
|
||||
if (req.path.startsWith('/api/setup') || req.path === '/init.html' || req.path.startsWith('/css/') || req.path.startsWith('/js/') || req.path.startsWith('/fonts/')) {
|
||||
return next();
|
||||
// Setup Status Check
|
||||
app.get('/api/setup/status', async (req, res) => {
|
||||
try {
|
||||
if (!isDbInitialized) {
|
||||
return res.json({ initialized: false, step: 'db' });
|
||||
}
|
||||
const [rows] = await db.query('SELECT COUNT(*) as count FROM users');
|
||||
if (rows[0].count === 0) {
|
||||
return res.json({ initialized: true, needsAdmin: true, step: 'admin' });
|
||||
}
|
||||
res.json({ initialized: true, needsAdmin: false, step: 'prom' });
|
||||
} catch (err) {
|
||||
console.error('Status check error:', err);
|
||||
res.json({ initialized: false, step: 'db' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create First Admin
|
||||
app.post('/api/setup/admin', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) return res.status(400).json({ error: 'Username and password are required' });
|
||||
|
||||
try {
|
||||
const [rows] = await db.query('SELECT COUNT(*) as count FROM users');
|
||||
if (rows[0].count > 0) return res.status(403).json({ error: 'Admin already exists' });
|
||||
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
|
||||
|
||||
await db.query('INSERT INTO users (username, password, salt) VALUES (?, ?, ?)', [username, hash, salt]);
|
||||
res.json({ success: true, message: 'Admin account created' });
|
||||
} catch (err) {
|
||||
console.error('Admin creation error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Middleware to protect routes & enforce setup
|
||||
app.use(async (req, res, next) => {
|
||||
// Allow system files and setup APIs
|
||||
if (req.path.startsWith('/api/setup') || req.path === '/init.html' || req.path.startsWith('/css/') || req.path.startsWith('/js/') || req.path.startsWith('/fonts/')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Enforce DB setup
|
||||
if (!isDbInitialized) {
|
||||
if (req.path.startsWith('/api/')) {
|
||||
return res.status(503).json({ error: 'Database not initialized', needSetup: true });
|
||||
}
|
||||
return res.redirect('/init.html');
|
||||
}
|
||||
|
||||
// Enforce User setup
|
||||
try {
|
||||
const [rows] = await db.query('SELECT COUNT(*) as count FROM users');
|
||||
if (rows[0].count === 0) {
|
||||
if (req.path.startsWith('/api/')) {
|
||||
return res.status(503).json({ error: 'Admin not configured', needAdmin: true });
|
||||
}
|
||||
return res.redirect('/init.html?step=admin');
|
||||
}
|
||||
} catch (err) {
|
||||
// If table doesn't exist, it's a DB initialization issue
|
||||
}
|
||||
|
||||
if (req.path === '/init.html') {
|
||||
return res.redirect('/');
|
||||
}
|
||||
@@ -153,7 +272,7 @@ app.get('/api/sources', async (req, res) => {
|
||||
});
|
||||
|
||||
// Add a new Prometheus source
|
||||
app.post('/api/sources', async (req, res) => {
|
||||
app.post('/api/sources', requireAuth, async (req, res) => {
|
||||
let { name, url, description } = req.body;
|
||||
if (!name || !url) {
|
||||
return res.status(400).json({ error: 'Name and URL are required' });
|
||||
@@ -173,7 +292,7 @@ app.post('/api/sources', async (req, res) => {
|
||||
});
|
||||
|
||||
// Update a Prometheus source
|
||||
app.put('/api/sources/:id', async (req, res) => {
|
||||
app.put('/api/sources/:id', requireAuth, async (req, res) => {
|
||||
let { name, url, description } = req.body;
|
||||
if (url && !/^https?:\/\//i.test(url)) url = 'http://' + url;
|
||||
try {
|
||||
@@ -190,7 +309,7 @@ app.put('/api/sources/:id', async (req, res) => {
|
||||
});
|
||||
|
||||
// Delete a Prometheus source
|
||||
app.delete('/api/sources/:id', async (req, res) => {
|
||||
app.delete('/api/sources/:id', requireAuth, async (req, res) => {
|
||||
try {
|
||||
await db.query('DELETE FROM prometheus_sources WHERE id = ?', [req.params.id]);
|
||||
res.json({ message: 'Source deleted' });
|
||||
|
||||
@@ -24,6 +24,18 @@ async function initDatabase() {
|
||||
|
||||
await connection.query(`USE \`${dbName}\``);
|
||||
|
||||
// Create users table
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL UNIQUE,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
salt VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
console.log(' ✅ Table "users" ready');
|
||||
|
||||
// Create prometheus_sources table
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS prometheus_sources (
|
||||
@@ -32,8 +44,8 @@ async function initDatabase() {
|
||||
url VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
console.log(' ✅ Table "prometheus_sources" ready');
|
||||
|
||||
|
||||
@@ -178,10 +178,12 @@ async function getOverviewMetrics(url, sourceName) {
|
||||
// Build per-instance data map
|
||||
const instances = new Map();
|
||||
|
||||
const getOrCreate = (instance) => {
|
||||
if (!instances.has(instance)) {
|
||||
instances.set(instance, {
|
||||
instance,
|
||||
const getOrCreate = (metric) => {
|
||||
const key = metric.instance;
|
||||
if (!instances.has(key)) {
|
||||
instances.set(key, {
|
||||
instance: key,
|
||||
job: metric.job || 'Unknown',
|
||||
source: sourceName,
|
||||
cpuPercent: 0,
|
||||
cpuCores: 0,
|
||||
@@ -194,54 +196,54 @@ async function getOverviewMetrics(url, sourceName) {
|
||||
up: false
|
||||
});
|
||||
}
|
||||
return instances.get(instance);
|
||||
return instances.get(key);
|
||||
};
|
||||
|
||||
// Parse UP status
|
||||
for (const r of upResult) {
|
||||
const inst = getOrCreate(r.metric.instance);
|
||||
const inst = getOrCreate(r.metric);
|
||||
inst.up = parseFloat(r.value[1]) === 1;
|
||||
}
|
||||
|
||||
// Parse CPU usage
|
||||
for (const r of cpuResult) {
|
||||
const inst = getOrCreate(r.metric.instance);
|
||||
const inst = getOrCreate(r.metric);
|
||||
inst.cpuPercent = parseFloat(r.value[1]) || 0;
|
||||
}
|
||||
|
||||
// Parse CPU count
|
||||
for (const r of cpuCountResult) {
|
||||
const inst = getOrCreate(r.metric.instance);
|
||||
const inst = getOrCreate(r.metric);
|
||||
inst.cpuCores = parseFloat(r.value[1]) || 0;
|
||||
}
|
||||
|
||||
// Parse memory
|
||||
for (const r of memTotalResult) {
|
||||
const inst = getOrCreate(r.metric.instance);
|
||||
const inst = getOrCreate(r.metric);
|
||||
inst.memTotal = parseFloat(r.value[1]) || 0;
|
||||
}
|
||||
for (const r of memAvailResult) {
|
||||
const inst = getOrCreate(r.metric.instance);
|
||||
const inst = getOrCreate(r.metric);
|
||||
inst.memUsed = inst.memTotal - (parseFloat(r.value[1]) || 0);
|
||||
}
|
||||
|
||||
// Parse disk
|
||||
for (const r of diskTotalResult) {
|
||||
const inst = getOrCreate(r.metric.instance);
|
||||
const inst = getOrCreate(r.metric);
|
||||
inst.diskTotal = parseFloat(r.value[1]) || 0;
|
||||
}
|
||||
for (const r of diskFreeResult) {
|
||||
const inst = getOrCreate(r.metric.instance);
|
||||
const inst = getOrCreate(r.metric);
|
||||
inst.diskUsed = inst.diskTotal - (parseFloat(r.value[1]) || 0);
|
||||
}
|
||||
|
||||
// Parse network rates
|
||||
for (const r of netRxResult) {
|
||||
const inst = getOrCreate(r.metric.instance);
|
||||
const inst = getOrCreate(r.metric);
|
||||
inst.netRx = parseFloat(r.value[1]) || 0;
|
||||
}
|
||||
for (const r of netTxResult) {
|
||||
const inst = getOrCreate(r.metric.instance);
|
||||
const inst = getOrCreate(r.metric);
|
||||
inst.netTx = parseFloat(r.value[1]) || 0;
|
||||
}
|
||||
|
||||
@@ -290,7 +292,8 @@ async function getOverviewMetrics(url, sourceName) {
|
||||
},
|
||||
network: {
|
||||
rx: totalNetRx,
|
||||
tx: totalNetTx
|
||||
tx: totalNetTx,
|
||||
total: totalNetRx + totalNetTx
|
||||
},
|
||||
traffic24h: {
|
||||
rx: totalTraffic24hRx,
|
||||
|
||||
Reference in New Issue
Block a user