feat(admin/health): System Health Level 3 — тренды (сэмплинг + canvas-графики)

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) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-30 18:36:04 +03:00
parent 13cbbacc1f
commit 6a934ca6c6
2 changed files with 89 additions and 1 deletions
+54
View File
@@ -377,6 +377,17 @@
</div>`;
}
// ── секция трендов (Level 3) ──
let trendsHtml = '';
if (m && m.history && m.history.length) {
const tc = (id, label) => `<div><div style="font-size:.74rem;color:var(--text-3);margin-bottom:3px">${label}</div><canvas id="${id}" height="64" style="width:100%;height:64px;display:block"></canvas></div>`;
trendsHtml = `<div class="adm-panel" style="margin:14px 0 0">
<div class="adm-panel-title">Тренды <span style="color:var(--text-3);font-weight:400;font-size:.78rem">(${m.history.length} точек · 1/мин)</span></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:8px">
${tc('trend-mem','Память RSS')}${tc('trend-req','Запросы/мин')}${tc('trend-err','Ошибки 5xx')}${tc('trend-p95','Латентность p95')}
</div></div>`;
}
el.innerHTML = `
<div class="adm-panel" style="margin:0 0 16px;padding:14px 18px;display:flex;align-items:center;gap:14px;border-left:4px solid ${stColor}">
<div style="width:13px;height:13px;border-radius:50%;background:${stColor};box-shadow:0 0 12px ${stColor};flex-shrink:0"></div>
@@ -427,6 +438,8 @@
${metricsHtml}
${trendsHtml}
<div class="adm-panel" style="margin:14px 0 0">
<div class="adm-panel-title">Крупнейшие таблицы БД</div>
${(h.db.tables||[]).map(t=>`<div style="display:flex;align-items:center;gap:10px;margin:4px 0">
@@ -436,6 +449,18 @@
</div>`).join('')}
</div>`;
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)
════════════════════════════════════════════════ */