From 4a424505a807c64670236dff22581721e480749f Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 30 May 2026 18:27:58 +0300 Subject: [PATCH] =?UTF-8?q?feat(admin/health):=20System=20Health=20Level?= =?UTF-8?q?=202=20=E2=80=94=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA=D0=B8?= =?UTF-8?q?=20HTTP-=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backend/src/utils/metrics.js: лёгкие in-memory метрики (сброс при рестарте) — всего запросов, req/min (скользящее окно), латентность avg/p50/p95/p99, разбивка по статусам 2xx/3xx/4xx/5xx, топ маршрутов по частоте/латентности/ ошибкам (группировка по шаблону route.path, не по URL). server.js: middleware (на /api, по res 'finish') пишет латентность и статус. adminController.getMetrics + GET /api/admin/metrics (под admin-auth). admin.js: health-страница переведена на refreshHealth/renderHealth (Level 1) + секция «Метрики запросов»: карточки req/min/всего/avg/p95/p99/5xx, цветная полоса статусов, топ медленных/частых/ошибочных маршрутов. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/controllers/adminController.js | 8 +- backend/src/routes/admin.js | 1 + backend/src/server.js | 13 ++ backend/src/utils/metrics.js | 67 ++++++++ frontend/js/admin/admin.js | 177 ++++++++++++++++----- 5 files changed, 224 insertions(+), 42 deletions(-) create mode 100644 backend/src/utils/metrics.js 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) => `
+
${val}
+
${label}
`; + 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)+' своб.':'—', 'Диск')} +
+ +
+
+
Платформа
+ + + + + + + + + ${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)}
+
+
+ + ${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(); + }); } /* ════════════════════════════════════════════════