diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js
index 75648ed..9c6ce00 100644
--- a/backend/src/controllers/adminController.js
+++ b/backend/src/controllers/adminController.js
@@ -713,6 +713,12 @@ function getHealth(_req, res) {
});
}
+/* ── GET /api/admin/metrics — метрики HTTP-запросов (Level 2) ──────────── */
+const metrics = require('../utils/metrics');
+function getMetrics(_req, res) {
+ res.json(metrics.snapshot());
+}
+
/* ── Topics CRUD ─────────────────────────────────────────────────────── */
function getTopics(req, res) {
const { subject_id } = req.query;
@@ -802,7 +808,7 @@ module.exports = {
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
- getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth,
+ getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getMetrics,
getTopics, createTopic, updateTopic, deleteTopic,
broadcast,
};
diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js
index 3875227..b78d83c 100644
--- a/backend/src/routes/admin.js
+++ b/backend/src/routes/admin.js
@@ -38,6 +38,7 @@ router.delete('/error-log', ctrl.clearErrorLog);
/* System health */
router.get('/health', ctrl.getHealth);
+router.get('/metrics', ctrl.getMetrics);
/* Topics CRUD */
router.get('/topics', ctrl.getTopics);
diff --git a/backend/src/server.js b/backend/src/server.js
index 3fb3b07..b3f6b55 100644
--- a/backend/src/server.js
+++ b/backend/src/server.js
@@ -138,6 +138,19 @@ const { requireFeature } = require('./middleware/features');
app.use('/api/classroom', rateLimit({ windowMs: 60_000, max: 6000, message: 'Слишком много запросов' }));
app.use('/api', rateLimit({ windowMs: 60_000, max: 600, message: 'Слишком много запросов, подождите минуту' }));
+/* ── Request metrics (System Health Level 2) ── */
+const metrics = require('./utils/metrics');
+app.use((req, res, next) => {
+ if (!req.originalUrl.startsWith('/api')) return next();
+ const start = process.hrtime.bigint();
+ res.on('finish', () => {
+ const ms = Number(process.hrtime.bigint() - start) / 1e6;
+ const route = (req.baseUrl || '') + (req.route && req.route.path ? req.route.path : '');
+ metrics.record(req.method, route || req.path || '(unmatched)', res.statusCode, ms);
+ });
+ next();
+});
+
/* ── Routes ── */
app.use('/api/auth', authRoutes);
app.use('/api/subjects', subjectRoutes);
diff --git a/backend/src/utils/metrics.js b/backend/src/utils/metrics.js
new file mode 100644
index 0000000..8c96fe4
--- /dev/null
+++ b/backend/src/utils/metrics.js
@@ -0,0 +1,67 @@
+'use strict';
+/*
+ * Лёгкие in-memory метрики HTTP-запросов для System Health (Level 2).
+ * Сбрасываются при перезапуске процесса. Память ограничена кольцевыми буферами.
+ * Группировка по шаблону маршрута (req.route.path), а не по конкретному URL —
+ * чтобы /api/biochem/molecules/:id не плодил тысячи ключей.
+ */
+const MAX_LAT = 3000; // окно последних латентностей для перцентилей
+const WINDOW_MS = 60_000; // окно для req/min
+
+let total = 0;
+let totalServerErrors = 0;
+const statusClasses = { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 };
+const routeStats = new Map(); // "METHOD /path" -> { count, totalMs, maxMs, errors }
+const latencies = []; // последние латентности (мс)
+const recentTs = []; // метки времени последних запросов (для req/min)
+const startedAt = Date.now();
+
+function record(method, route, status, ms) {
+ total++;
+ const cls = Math.floor(status / 100) + 'xx';
+ if (statusClasses[cls] !== undefined) statusClasses[cls]++;
+ if (status >= 500) totalServerErrors++;
+
+ const key = method + ' ' + route;
+ let r = routeStats.get(key);
+ if (!r) { r = { count: 0, totalMs: 0, maxMs: 0, errors: 0 }; routeStats.set(key, r); }
+ r.count++; r.totalMs += ms; if (ms > r.maxMs) r.maxMs = ms;
+ if (status >= 400) r.errors++;
+
+ latencies.push(ms);
+ if (latencies.length > MAX_LAT) latencies.shift();
+
+ const now = Date.now();
+ recentTs.push(now);
+ const cutoff = now - WINDOW_MS;
+ while (recentTs.length && recentTs[0] < cutoff) recentTs.shift();
+}
+
+function _pct(sorted, p) {
+ if (!sorted.length) return 0;
+ return sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))];
+}
+
+function snapshot() {
+ const now = Date.now();
+ const cutoff = now - WINDOW_MS;
+ let reqPerMin = 0;
+ for (let i = recentTs.length - 1; i >= 0 && recentTs[i] >= cutoff; i--) reqPerMin++;
+ const sorted = [...latencies].sort((a, b) => a - b);
+ const avg = latencies.length ? latencies.reduce((s, x) => s + x, 0) / latencies.length : 0;
+ const routes = [...routeStats.entries()].map(([k, r]) => ({
+ route: k, count: r.count, avgMs: r.totalMs / r.count, maxMs: r.maxMs, errors: r.errors,
+ }));
+ return {
+ total, totalServerErrors, statusClasses,
+ reqPerMin,
+ avgMs: avg, p50: _pct(sorted, 50), p95: _pct(sorted, 95), p99: _pct(sorted, 99),
+ routeCount: routeStats.size,
+ sinceMs: now - startedAt,
+ topBusy: [...routes].sort((a, b) => b.count - a.count).slice(0, 8),
+ topSlow: [...routes].sort((a, b) => b.avgMs - a.avgMs).slice(0, 8),
+ topErrors: routes.filter(r => r.errors > 0).sort((a, b) => b.errors - a.errors).slice(0, 8),
+ };
+}
+
+module.exports = { record, snapshot };
diff --git a/frontend/js/admin/admin.js b/frontend/js/admin/admin.js
index 71637f3..057057a 100644
--- a/frontend/js/admin/admin.js
+++ b/frontend/js/admin/admin.js
@@ -299,55 +299,150 @@
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');
+ 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 {
- 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
+ const [h, m] = await Promise.all([
+ LS.api('/api/admin/health'),
+ LS.api('/api/admin/metrics').catch(() => null),
+ ]);
+ renderHealth(h, m);
+ } catch (e) { const el = document.getElementById('health-content'); if (el) el.innerHTML = `
${esc(e.message)}
`; }
+ }
+
+ function renderHealth(h, m) {
+ 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), mm=Math.floor(s%3600/60); return d>0?`${d}d ${hr}h`:hr>0?`${hr}h ${mm}m`:`${mm}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, 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));
+
+ // ── секция метрик запросов (Level 2) ──
+ let metricsHtml = '';
+ if (m) {
+ const sc = m.statusClasses||{}, tot = Math.max(1, (sc['2xx']||0)+(sc['3xx']||0)+(sc['4xx']||0)+(sc['5xx']||0));
+ const seg = (n,col)=> n>0?`
`:'';
+ const routeRows = (arr, valFn, valLabel) => (arr&&arr.length)? arr.map(r=>`
+
${esc(r.route)}
+
${valFn(r)}
`).join('')
+ : `
нет данных
`;
+ const lagP = (v)=> (v||0)>200?'var(--pink)':(v||0)>70?'#facc15':'var(--text-1)';
+ metricsHtml = `
+
+
Метрики запросов (с рестарта · ${fmtUp(Math.floor((m.sinceMs||0)/1000))})
+
+ ${card(m.reqPerMin, 'Req/min', 'var(--green)')}
+ ${card((m.total||0).toLocaleString('ru'), 'Всего')}
+ ${card((m.avgMs||0).toFixed(0)+' мс', 'Средн.')}
+ ${card((m.p95||0).toFixed(0)+' мс', 'p95', lagP(m.p95))}
+ ${card((m.p99||0).toFixed(0)+' мс', 'p99', lagP(m.p99))}
+ ${card(sc['5xx']||0, '5xx', (sc['5xx']||0)>0?'var(--pink)':'var(--green)')}
-
-
${fmtBytes(h.db.sizeBytes)}
-
База данных
+
Статусы: 2xx ${sc['2xx']||0} · 3xx ${sc['3xx']||0} · 4xx ${sc['4xx']||0} · 5xx ${sc['5xx']||0}
+
+ ${seg(sc['2xx'],'#4ade80')}${seg(sc['3xx'],'#60a5fa')}${seg(sc['4xx'],'#facc15')}${seg(sc['5xx'],'#f87171')}
-
-
${fmtBytes(h.uploads.sizeBytes)}
-
Файлы
-
-
-
${h.recentErrors}
-
Ошибок за 24ч
-
-
-
-
-
Платформа
-
- | 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} |
-
+
+
Самые медленные
${routeRows(m.topSlow, r=>r.avgMs.toFixed(0)+' мс')}
+
Самые частые
${routeRows(m.topBusy, r=>r.count.toLocaleString('ru'))}
+ ${(m.topErrors&&m.topErrors.length)?`
Маршруты с ошибками
${routeRows(m.topErrors, r=>r.errors+' ош. / '+r.count)}
`:''}
`;
- } catch (e) { el.innerHTML = `
${esc(e.message)}
`; }
+ }
+
+ el.innerHTML = `
+
+
+
+
${stLabel}
+
${h.reasons&&h.reasons.length?h.reasons.map(esc).join(' · '):'Все показатели в пределах нормы'}
+
+
+
+
+
+ ${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)} |
+
+
+
+
+ ${metricsHtml}
+
+
+
Крупнейшие таблицы БД
+ ${(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();
+ });
}
/* ════════════════════════════════════════════════