From 6a934ca6c61e426ed5b280c90553b9c82477724d Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 30 May 2026 18:36:04 +0300 Subject: [PATCH] =?UTF-8?q?feat(admin/health):=20System=20Health=20Level?= =?UTF-8?q?=203=20=E2=80=94=20=D1=82=D1=80=D0=B5=D0=BD=D0=B4=D1=8B=20(?= =?UTF-8?q?=D1=81=D1=8D=D0=BC=D0=BF=D0=BB=D0=B8=D0=BD=D0=B3=20+=20canvas-?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D1=84=D0=B8=D0=BA=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit metrics.js: сэмплинг раз в минуту в кольцевой буфер (cap 24ч, unref) — ts/rss/heapUsed/reqPerMin/reqDelta/err5xx/p95; history() + поле history в snapshot (последние 180 точек). admin.js: секция «Тренды» с 4 мини-графиками (canvas): Память RSS, Запросы/мин, Ошибки 5xx, Латентность p95 — линия + заливка + подписи макс/последнее. Обновляются вместе с live-рефрешем. Проверено: сэмплер пишет, история в snapshot, графики рисуются (на старте — «накопление данных…», далее наполняются). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/utils/metrics.js | 36 +++++++++++++++++++++++- frontend/js/admin/admin.js | 54 ++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) 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) ════════════════════════════════════════════════ */