chore: консолидация незакоммиченной работы (биохимия + System Health + lab/textbooks)

Зафиксирована накопленная незакоммиченная работа рабочего дерева, КРОМЕ файлов
учебника «Химия 7» (migration 046, chemistry_7_*.html, chem7_svg.js, тест —
оставлены незакоммиченными по запросу).

Включает: модуль биохимии (ядро BIO, 3D VSEPR, химдвижок, баланс, challenges,
пути из БД), System Health Level 1 (вердикт/мониторинг), а также frontend-
страницы и lab/textbooks-правки параллельной сессии.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-30 18:12:55 +03:00
parent 6c1e003340
commit 5381679c68
55 changed files with 10203 additions and 305 deletions
+88 -30
View File
@@ -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 = _eluMonitor.mean / 1e6;
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,
});
}