feat(admin/health): System Health Level 1 — живой мониторинг + вердикт
getHealth обогащён: вердикт здоровья (ok/warning/critical) по порогам (память %, диск, ошибки/24ч, лаг event-loop, размер БД) + причины; реальный % памяти, лаг event-loop (perf_hooks), load average, свободное место на диске (statfs), PID/NODE_ENV, версия+git-commit, число активных SSE-соединений, размер WAL, разбивка БД по крупнейшим таблицам. sse.js: экспорт stats() (онлайн-пользователи/гости/соединения). admin.js loadHealth: светофор-баннер вердикта с причинами, тумблер авто-обновления (live, поллинг 5с с самоостановкой при уходе с вкладки), 8 карточек (uptime/БД/файлы/ошибки/SSE/память/event-loop/диск), панели платформы и активности, горизонтальные бары крупнейших таблиц БД. Проверено: getHealth собирает полный payload, вердикт срабатывает (диск<2ГБ → warning), NaN-лаг защищён. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
+11
-1
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user