diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index d251794..c1f97b1 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -610,47 +610,105 @@ function clearErrorLog(req, res) { const os = require('os'); const path = require('path'); const fs = require('fs'); -const { DB_PATH, UPLOADS_DIR } = require('../config'); +const { execSync } = require('child_process'); +const { monitorEventLoopDelay } = require('perf_hooks'); +const sse = require('../sse'); +const { DB_PATH, UPLOADS_DIR, NODE_ENV } = require('../config'); + +// Монитор лага event-loop (включается один раз при загрузке модуля). +const _eluMonitor = monitorEventLoopDelay({ resolution: 20 }); +_eluMonitor.enable(); + +// Версия приложения + git-commit (кэшируются один раз). +const _appVersion = (() => { try { return require('../../package.json').version; } catch { return '?'; } })(); +const _gitCommit = (() => { + try { return execSync('git rev-parse --short HEAD', { cwd: __dirname, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } + catch { return null; } +})(); + +function _dirSize(dir) { + let total = 0; + try { for (const f of fs.readdirSync(dir)) { try { total += fs.statSync(path.join(dir, f)).size; } catch {} } } catch {} + return total; +} + +// Топ таблиц БД по числу строк (исключая служебные sqlite_* и _migrations). +function _dbTables() { + try { + const tables = db.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '\\_%' ESCAPE '\\'" + ).all(); + const rows = tables.map(t => { + let n = 0; + try { n = db.prepare(`SELECT COUNT(*) AS n FROM "${t.name}"`).get().n; } catch {} + return { name: t.name, rows: n }; + }); + rows.sort((a, b) => b.rows - a.rows); + return rows.slice(0, 12); + } catch { return []; } +} function getHealth(_req, res) { const uptimeSec = process.uptime(); - let dbSizeBytes = 0; - try { dbSizeBytes = fs.statSync(DB_PATH).size; } catch {} - let uploadsSizeBytes = 0; - try { - const files = fs.readdirSync(UPLOADS_DIR); - for (const f of files) { - try { uploadsSizeBytes += fs.statSync(path.join(UPLOADS_DIR, f)).size; } catch {} - } - } catch {} + const mem = process.memoryUsage(); + const dbSizeBytes = (() => { try { return fs.statSync(DB_PATH).size; } catch { return 0; } })(); + const walSizeBytes = (() => { try { return fs.statSync(DB_PATH + '-wal').size; } catch { return 0; } })(); + const uploadsSizeBytes = _dirSize(UPLOADS_DIR); - const totalUsers = db.prepare('SELECT COUNT(*) AS n FROM users').get().n; - const todaySessions = db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE started_at >= date('now')").get().n; - const totalSessions = db.prepare('SELECT COUNT(*) AS n FROM test_sessions').get().n; + // Свободное место на разделе, где лежит БД. + let disk = null; + try { const s = fs.statfsSync(path.dirname(path.resolve(DB_PATH))); disk = { freeBytes: s.bavail * s.bsize, totalBytes: s.blocks * s.bsize }; } catch {} + + const totalMem = os.totalmem(), freeMem = os.freemem(); + const memPercent = totalMem ? (totalMem - freeMem) / totalMem : 0; + const eventLoopLagMs = Number.isFinite(_eluMonitor.mean) ? _eluMonitor.mean / 1e6 : 0; + + const totalUsers = db.prepare('SELECT COUNT(*) AS n FROM users').get().n; + const todaySessions = db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE started_at >= date('now')").get().n; + const totalSessions = db.prepare('SELECT COUNT(*) AS n FROM test_sessions').get().n; const totalQuestions = db.prepare('SELECT COUNT(*) AS n FROM questions').get().n; - const recentErrors = db.prepare("SELECT COUNT(*) AS n FROM error_log WHERE created_at >= datetime('now', '-24 hours')").get().n; + const recentErrors = db.prepare("SELECT COUNT(*) AS n FROM error_log WHERE created_at >= datetime('now', '-24 hours')").get().n; + + let sseStats = { users: 0, guests: 0, connections: 0 }; + try { sseStats = sse.stats(); } catch {} + + // Вердикт здоровья по порогам. + const reasons = []; + let status = 'ok'; + const warn = (m) => { reasons.push(m); if (status === 'ok') status = 'warning'; }; + const crit = (m) => { reasons.push(m); status = 'critical'; }; + if (memPercent > 0.92) crit(`Память ${Math.round(memPercent * 100)}%`); + else if (memPercent > 0.80) warn(`Память ${Math.round(memPercent * 100)}%`); + if (disk) { + if (disk.freeBytes < 500e6) crit('Мало места на диске (<500 МБ)'); + else if (disk.freeBytes < 2e9) warn('Места на диске <2 ГБ'); + } + if (recentErrors > 50) crit(`${recentErrors} ошибок за 24ч`); + else if (recentErrors > 5) warn(`${recentErrors} ошибок за 24ч`); + if (eventLoopLagMs > 200) crit(`Лаг event-loop ${eventLoopLagMs.toFixed(0)} мс`); + else if (eventLoopLagMs > 70) warn(`Лаг event-loop ${eventLoopLagMs.toFixed(0)} мс`); + if (dbSizeBytes > 1.5e9) warn('БД >1.5 ГБ'); res.json({ + status, reasons, uptime: uptimeSec, - memory: { - rss: process.memoryUsage().rss, - heapUsed: process.memoryUsage().heapUsed, - }, - db: { - sizeBytes: dbSizeBytes, - totalUsers, - totalSessions, - todaySessions, - totalQuestions, - }, - uploads: { - sizeBytes: uploadsSizeBytes, - }, + startedAt: new Date(Date.now() - uptimeSec * 1000).toISOString(), + memory: { rss: mem.rss, heapUsed: mem.heapUsed, heapTotal: mem.heapTotal }, + memPercent, eventLoopLagMs, + loadavg: os.loadavg(), + disk, + db: { sizeBytes: dbSizeBytes, walBytes: walSizeBytes, totalUsers, totalSessions, todaySessions, totalQuestions, tables: _dbTables() }, + uploads: { sizeBytes: uploadsSizeBytes }, + sse: sseStats, node: process.version, platform: os.platform(), + arch: os.arch(), cpus: os.cpus().length, - freeMem: os.freemem(), - totalMem: os.totalmem(), + freeMem, totalMem, + pid: process.pid, + env: NODE_ENV, + version: _appVersion, + commit: _gitCommit, recentErrors, }); } diff --git a/backend/src/sse.js b/backend/src/sse.js index 7234839..9b2f4c0 100644 --- a/backend/src/sse.js +++ b/backend/src/sse.js @@ -79,7 +79,17 @@ function getOnlineUserIds() { return [...clients.keys()]; } +/* Сводка SSE-соединений для мониторинга: онлайн-пользователи, гости и + суммарное число открытых стримов. */ +function stats() { + let conns = 0; + for (const set of clients.values()) conns += set.size; + let guestConns = 0; + for (const set of guestClients.values()) guestConns += set.size; + return { users: clients.size, guests: guestClients.size, connections: conns + guestConns }; +} + module.exports = { - addClient, removeClient, emit, emitToClass, getOnlineUserIds, + addClient, removeClient, emit, emitToClass, getOnlineUserIds, stats, addGuestClient, removeGuestClient, emitToGuests, }; diff --git a/frontend/js/admin/admin.js b/frontend/js/admin/admin.js index 71637f3..17cd0d8 100644 --- a/frontend/js/admin/admin.js +++ b/frontend/js/admin/admin.js @@ -299,55 +299,112 @@ window.clearErrorLog = clearErrorLog; /* ═══ SYSTEM HEALTH ════════════════════════════════════════════════ */ + let _healthLive = false, _healthTimer = null; + async function loadHealth() { const el = document.getElementById('health-content'); el.innerHTML = LS.skeleton(3, 'row'); - try { - const h = await LS.api('/api/admin/health'); - const fmtBytes = b => b > 1e9 ? (b/1e9).toFixed(1)+' GB' : b > 1e6 ? (b/1e6).toFixed(1)+' MB' : (b/1e3).toFixed(0)+' KB'; - const fmtUp = s => { const d=Math.floor(s/86400), hr=Math.floor(s%86400/3600), m=Math.floor(s%3600/60); return d>0?`${d}d ${hr}h`:hr>0?`${hr}h ${m}m`:`${m}m`; }; - el.innerHTML = ` -
-
-
${fmtUp(h.uptime)}
-
Uptime
-
-
-
${fmtBytes(h.db.sizeBytes)}
-
База данных
-
-
-
${fmtBytes(h.uploads.sizeBytes)}
-
Файлы
-
-
-
${h.recentErrors}
-
Ошибок за 24ч
-
+ await refreshHealth(); + setupHealthTimer(); + } + + function setupHealthTimer() { + if (_healthTimer) { clearInterval(_healthTimer); _healthTimer = null; } + if (_healthLive) { + _healthTimer = setInterval(() => { + const el = document.getElementById('health-content'); + if (!el || !el.offsetParent) { clearInterval(_healthTimer); _healthTimer = null; return; } + refreshHealth(); + }, 5000); + } + } + + async function refreshHealth() { + try { renderHealth(await LS.api('/api/admin/health')); } + catch (e) { const el = document.getElementById('health-content'); if (el) el.innerHTML = `
${esc(e.message)}
`; } + } + + function renderHealth(h) { + const el = document.getElementById('health-content'); + if (!el) return; + const fmtBytes = b => !b ? '0' : b > 1e9 ? (b/1e9).toFixed(1)+' GB' : b > 1e6 ? (b/1e6).toFixed(1)+' MB' : (b/1e3).toFixed(0)+' KB'; + const fmtUp = s => { const d=Math.floor(s/86400), hr=Math.floor(s%86400/3600), m=Math.floor(s%3600/60); return d>0?`${d}d ${hr}h`:hr>0?`${hr}h ${m}m`:`${m}m`; }; + const stColor = h.status==='critical'?'var(--pink)':h.status==='warning'?'#facc15':'var(--green)'; + const stLabel = h.status==='critical'?'Критическое состояние':h.status==='warning'?'Требует внимания':'Всё в норме'; + const memPct = Math.round((h.memPercent||0)*100); + const memCol = memPct>92?'var(--pink)':memPct>80?'#facc15':'var(--green)'; + const lag = h.eventLoopLagMs||0; + const lagCol = lag>200?'var(--pink)':lag>70?'#facc15':'var(--green)'; + const card = (val, label, col) => `
+
${val}
+
${label}
`; + const maxRows = Math.max(1, ...(h.db.tables||[]).map(t=>t.rows)); + + el.innerHTML = ` +
+
+
+
${stLabel}
+ ${h.reasons&&h.reasons.length?`
${h.reasons.map(esc).join(' · ')}
`:`
Все показатели в пределах нормы
`}
-
-
-
Платформа
- - - - - - - -
Node.js${h.node}
OS${h.platform}
CPU ядра${h.cpus}
RAM использовано${fmtBytes(h.memory.rss)}
RAM heap${fmtBytes(h.memory.heapUsed)}
RAM свободно${fmtBytes(h.freeMem)} / ${fmtBytes(h.totalMem)}
-
-
-
Данные
- - - - - -
Пользователей${h.db.totalUsers}
Всего сессий${h.db.totalSessions}
Сессий сегодня${h.db.todaySessions}
Вопросов в базе${h.db.totalQuestions}
-
-
`; - } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } + +
+ +
+ ${card(fmtUp(h.uptime), 'Uptime', 'var(--green)')} + ${card(fmtBytes(h.db.sizeBytes), 'База данных', 'var(--violet)')} + ${card(fmtBytes(h.uploads.sizeBytes), 'Файлы')} + ${card(h.recentErrors, 'Ошибок 24ч', h.recentErrors>0?'var(--pink)':'var(--green)')} + ${card((h.sse?h.sse.connections:0), 'SSE онлайн', 'var(--green)')} + ${card(memPct+'%', 'Память', memCol)} + ${card(lag.toFixed(0)+' мс', 'Event-loop', lagCol)} + ${card(h.disk?fmtBytes(h.disk.freeBytes)+' своб.':'—', 'Диск')} +
+ +
+
+
Платформа
+ + + + + + + + + ${h.disk?``:''} +
Версия${esc(h.version||'?')} ${h.commit?`${esc(h.commit)}`:''}
Окружение${esc(h.env||'?')} · PID ${h.pid||'?'}
Node.js${esc(h.node)}
OS / арх${esc(h.platform)} ${esc(h.arch||'')}
CPU ядра · load${h.cpus} · ${(h.loadavg||[0]).map(x=>x.toFixed(2)).join(' / ')}
RAM rss / heap${fmtBytes(h.memory.rss)} / ${fmtBytes(h.memory.heapUsed)}
RAM система${fmtBytes(h.totalMem-h.freeMem)} / ${fmtBytes(h.totalMem)}
Диск${fmtBytes(h.disk.freeBytes)} своб. / ${fmtBytes(h.disk.totalBytes)}
+
+
+
Данные и активность
+ + + + + + + + +
Пользователей${h.db.totalUsers}
Онлайн (SSE)${h.sse?h.sse.users:0} польз. · ${h.sse?h.sse.connections:0} соед.
Всего сессий${h.db.totalSessions}
Сессий сегодня${h.db.todaySessions}
Вопросов в базе${h.db.totalQuestions}
WAL${fmtBytes(h.db.walBytes||0)}
Запущен${h.startedAt?esc(new Date(h.startedAt).toLocaleString('ru'))+'':'—'}
+
+
+ +
+
Крупнейшие таблицы БД
+ ${(h.db.tables||[]).map(t=>`
+
${esc(t.name)}
+
+
${t.rows.toLocaleString('ru')}
+
`).join('')} +
`; + + const btn = document.getElementById('health-live-btn'); + if (btn) btn.addEventListener('click', () => { + _healthLive = !_healthLive; + btn.textContent = _healthLive ? '● Live' : '○ Авто-обновление'; + btn.style.color = _healthLive ? 'var(--green)' : 'var(--text-3)'; + setupHealthTimer(); + }); } /* ════════════════════════════════════════════════