添加鉴权逻辑

This commit is contained in:
CN-JS-HuiBai
2026-04-04 17:49:00 +08:00
parent a2a477d3fb
commit 2bad8978a4
8 changed files with 474 additions and 57 deletions

View File

@@ -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;

View File

@@ -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">&times;</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>

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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;

View File

@@ -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' });

View File

@@ -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');

View File

@@ -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,