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) => `
`;
+ 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)+' своб.':'—', 'Диск')}
+
+
+
+
+
Платформа
+
+ | Версия | ${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)} |
+ ${h.disk?`| Диск | ${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();
+ });
}
/* ════════════════════════════════════════════════