diff --git a/backend/src/utils/metrics.js b/backend/src/utils/metrics.js index 8c96fe4..7b1acd6 100644 --- a/backend/src/utils/metrics.js +++ b/backend/src/utils/metrics.js @@ -42,6 +42,39 @@ function _pct(sorted, p) { return sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))]; } +/* ── Сэмплинг для трендов (Level 3): раз в минуту в кольцевой буфер ─────── */ +const HISTORY_CAP = 1440; // ~24 часа при 1 сэмпле/мин +const samples = []; +let _lastErr5xx = 0, _lastTotal = 0; + +function sample() { + const now = Date.now(); + const cutoff = now - WINDOW_MS; + let rpm = 0; + for (let i = recentTs.length - 1; i >= 0 && recentTs[i] >= cutoff; i--) rpm++; + const sorted = [...latencies].sort((a, b) => a - b); + const cur5xx = statusClasses['5xx']; + const errDelta = Math.max(0, cur5xx - _lastErr5xx); _lastErr5xx = cur5xx; + const reqDelta = Math.max(0, total - _lastTotal); _lastTotal = total; + const mem = process.memoryUsage(); + samples.push({ + ts: now, + rss: mem.rss, + heapUsed: mem.heapUsed, + reqPerMin: rpm, + reqDelta, + err5xx: errDelta, + p95: _pct(sorted, 95), + }); + if (samples.length > HISTORY_CAP) samples.shift(); +} + +const _sampler = setInterval(sample, 60_000); +if (_sampler.unref) _sampler.unref(); // не держим процесс живым +sample(); // стартовая точка сразу + +function history(limit = 180) { return samples.slice(-limit); } + function snapshot() { const now = Date.now(); const cutoff = now - WINDOW_MS; @@ -61,7 +94,8 @@ function snapshot() { 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), + history: history(180), }; } -module.exports = { record, snapshot }; +module.exports = { record, snapshot, sample, history }; diff --git a/frontend/js/admin/admin.js b/frontend/js/admin/admin.js index 057057a..b024712 100644 --- a/frontend/js/admin/admin.js +++ b/frontend/js/admin/admin.js @@ -377,6 +377,17 @@ `; } + // ── секция трендов (Level 3) ── + let trendsHtml = ''; + if (m && m.history && m.history.length) { + const tc = (id, label) => `
${label}
`; + trendsHtml = `
+
Тренды (${m.history.length} точек · 1/мин)
+
+ ${tc('trend-mem','Память RSS')}${tc('trend-req','Запросы/мин')}${tc('trend-err','Ошибки 5xx')}${tc('trend-p95','Латентность p95')} +
`; + } + el.innerHTML = `
@@ -427,6 +438,8 @@ ${metricsHtml} + ${trendsHtml} +
Крупнейшие таблицы БД
${(h.db.tables||[]).map(t=>`
@@ -436,6 +449,18 @@
`).join('')}
`; + if (m && m.history && m.history.length) { + const draw = (id, key, color, fmt) => { + const c = document.getElementById(id); if (!c) return; + c.width = c.offsetWidth || 300; + drawTrend(c, m.history, key, color, fmt); + }; + draw('trend-mem', 'rss', '#9B5DE5', v => (v/1e6).toFixed(0)+' МБ'); + draw('trend-req', 'reqPerMin', '#34d399'); + draw('trend-err', 'err5xx', '#f87171'); + draw('trend-p95', 'p95', '#facc15', v => v.toFixed(0)+' мс'); + } + const btn = document.getElementById('health-live-btn'); if (btn) btn.addEventListener('click', () => { _healthLive = !_healthLive; @@ -445,6 +470,35 @@ }); } + // Мини-график тренда (canvas): линия + заливка + подписи макс/последнее. + function drawTrend(canvas, points, key, color, fmt) { + const ctx = canvas.getContext('2d'); + const W = canvas.width, H = canvas.height; + ctx.clearRect(0, 0, W, H); + const vals = points.map(p => p[key] || 0); + if (vals.length < 2) { + ctx.fillStyle = '#555'; ctx.font = '11px Manrope,sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('накопление данных…', W/2, H/2); return; + } + let max = Math.max(...vals), min = Math.min(...vals); + if (max === min) max += 1; + const pad = 4, range = (max - min) || 1; + const X = i => pad + i/(vals.length-1)*(W-pad*2); + const Y = v => H-pad - (v-min)/range*(H-pad*2-10); + ctx.beginPath(); ctx.moveTo(X(0), H); + vals.forEach((v,i)=>ctx.lineTo(X(i), Y(v))); + ctx.lineTo(X(vals.length-1), H); ctx.closePath(); + ctx.fillStyle = color + '22'; ctx.fill(); + ctx.beginPath(); vals.forEach((v,i)=> i?ctx.lineTo(X(i),Y(v)):ctx.moveTo(X(i),Y(v))); + ctx.strokeStyle = color; ctx.lineWidth = 1.6; ctx.lineJoin = 'round'; ctx.stroke(); + const lastV = vals[vals.length-1]; + ctx.fillStyle = color; ctx.beginPath(); ctx.arc(X(vals.length-1), Y(lastV), 2.5, 0, Math.PI*2); ctx.fill(); + ctx.font = '10px Manrope,sans-serif'; ctx.textBaseline = 'top'; + ctx.fillStyle = '#888'; ctx.textAlign = 'left'; ctx.fillText('макс ' + (fmt?fmt(max):max), pad, 1); + ctx.fillStyle = color; ctx.textAlign = 'right'; ctx.fillText(fmt?fmt(lastV):lastV, W-pad, 1); + } + /* ════════════════════════════════════════════════ ОНЛАЙН-УРОКИ (classroom admin) ════════════════════════════════════════════════ */