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) => `
`;
+ 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)
════════════════════════════════════════════════ */