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:
@@ -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)
|
||||
════════════════════════════════════════════════ */
|
||||
|
||||
Reference in New Issue
Block a user