feat(admin/health): System Health Level 2 — метрики HTTP-запросов
backend/src/utils/metrics.js: лёгкие in-memory метрики (сброс при рестарте) — всего запросов, req/min (скользящее окно), латентность avg/p50/p95/p99, разбивка по статусам 2xx/3xx/4xx/5xx, топ маршрутов по частоте/латентности/ ошибкам (группировка по шаблону route.path, не по URL). server.js: middleware (на /api, по res 'finish') пишет латентность и статус. adminController.getMetrics + GET /api/admin/metrics (под admin-auth). admin.js: health-страница переведена на refreshHealth/renderHealth (Level 1) + секция «Метрики запросов»: карточки req/min/всего/avg/p95/p99/5xx, цветная полоса статусов, топ медленных/частых/ошибочных маршрутов. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -713,6 +713,12 @@ function getHealth(_req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── GET /api/admin/metrics — метрики HTTP-запросов (Level 2) ──────────── */
|
||||||
|
const metrics = require('../utils/metrics');
|
||||||
|
function getMetrics(_req, res) {
|
||||||
|
res.json(metrics.snapshot());
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Topics CRUD ─────────────────────────────────────────────────────── */
|
/* ── Topics CRUD ─────────────────────────────────────────────────────── */
|
||||||
function getTopics(req, res) {
|
function getTopics(req, res) {
|
||||||
const { subject_id } = req.query;
|
const { subject_id } = req.query;
|
||||||
@@ -802,7 +808,7 @@ module.exports = {
|
|||||||
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
|
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
|
||||||
clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
|
clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
|
||||||
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
|
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
|
||||||
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth,
|
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getMetrics,
|
||||||
getTopics, createTopic, updateTopic, deleteTopic,
|
getTopics, createTopic, updateTopic, deleteTopic,
|
||||||
broadcast,
|
broadcast,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ router.delete('/error-log', ctrl.clearErrorLog);
|
|||||||
|
|
||||||
/* System health */
|
/* System health */
|
||||||
router.get('/health', ctrl.getHealth);
|
router.get('/health', ctrl.getHealth);
|
||||||
|
router.get('/metrics', ctrl.getMetrics);
|
||||||
|
|
||||||
/* Topics CRUD */
|
/* Topics CRUD */
|
||||||
router.get('/topics', ctrl.getTopics);
|
router.get('/topics', ctrl.getTopics);
|
||||||
|
|||||||
@@ -138,6 +138,19 @@ const { requireFeature } = require('./middleware/features');
|
|||||||
app.use('/api/classroom', rateLimit({ windowMs: 60_000, max: 6000, message: 'Слишком много запросов' }));
|
app.use('/api/classroom', rateLimit({ windowMs: 60_000, max: 6000, message: 'Слишком много запросов' }));
|
||||||
app.use('/api', rateLimit({ windowMs: 60_000, max: 600, message: 'Слишком много запросов, подождите минуту' }));
|
app.use('/api', rateLimit({ windowMs: 60_000, max: 600, message: 'Слишком много запросов, подождите минуту' }));
|
||||||
|
|
||||||
|
/* ── Request metrics (System Health Level 2) ── */
|
||||||
|
const metrics = require('./utils/metrics');
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (!req.originalUrl.startsWith('/api')) return next();
|
||||||
|
const start = process.hrtime.bigint();
|
||||||
|
res.on('finish', () => {
|
||||||
|
const ms = Number(process.hrtime.bigint() - start) / 1e6;
|
||||||
|
const route = (req.baseUrl || '') + (req.route && req.route.path ? req.route.path : '');
|
||||||
|
metrics.record(req.method, route || req.path || '(unmatched)', res.statusCode, ms);
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
/* ── Routes ── */
|
/* ── Routes ── */
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/subjects', subjectRoutes);
|
app.use('/api/subjects', subjectRoutes);
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
'use strict';
|
||||||
|
/*
|
||||||
|
* Лёгкие in-memory метрики HTTP-запросов для System Health (Level 2).
|
||||||
|
* Сбрасываются при перезапуске процесса. Память ограничена кольцевыми буферами.
|
||||||
|
* Группировка по шаблону маршрута (req.route.path), а не по конкретному URL —
|
||||||
|
* чтобы /api/biochem/molecules/:id не плодил тысячи ключей.
|
||||||
|
*/
|
||||||
|
const MAX_LAT = 3000; // окно последних латентностей для перцентилей
|
||||||
|
const WINDOW_MS = 60_000; // окно для req/min
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
let totalServerErrors = 0;
|
||||||
|
const statusClasses = { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 };
|
||||||
|
const routeStats = new Map(); // "METHOD /path" -> { count, totalMs, maxMs, errors }
|
||||||
|
const latencies = []; // последние латентности (мс)
|
||||||
|
const recentTs = []; // метки времени последних запросов (для req/min)
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
function record(method, route, status, ms) {
|
||||||
|
total++;
|
||||||
|
const cls = Math.floor(status / 100) + 'xx';
|
||||||
|
if (statusClasses[cls] !== undefined) statusClasses[cls]++;
|
||||||
|
if (status >= 500) totalServerErrors++;
|
||||||
|
|
||||||
|
const key = method + ' ' + route;
|
||||||
|
let r = routeStats.get(key);
|
||||||
|
if (!r) { r = { count: 0, totalMs: 0, maxMs: 0, errors: 0 }; routeStats.set(key, r); }
|
||||||
|
r.count++; r.totalMs += ms; if (ms > r.maxMs) r.maxMs = ms;
|
||||||
|
if (status >= 400) r.errors++;
|
||||||
|
|
||||||
|
latencies.push(ms);
|
||||||
|
if (latencies.length > MAX_LAT) latencies.shift();
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
recentTs.push(now);
|
||||||
|
const cutoff = now - WINDOW_MS;
|
||||||
|
while (recentTs.length && recentTs[0] < cutoff) recentTs.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _pct(sorted, p) {
|
||||||
|
if (!sorted.length) return 0;
|
||||||
|
return sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot() {
|
||||||
|
const now = Date.now();
|
||||||
|
const cutoff = now - WINDOW_MS;
|
||||||
|
let reqPerMin = 0;
|
||||||
|
for (let i = recentTs.length - 1; i >= 0 && recentTs[i] >= cutoff; i--) reqPerMin++;
|
||||||
|
const sorted = [...latencies].sort((a, b) => a - b);
|
||||||
|
const avg = latencies.length ? latencies.reduce((s, x) => s + x, 0) / latencies.length : 0;
|
||||||
|
const routes = [...routeStats.entries()].map(([k, r]) => ({
|
||||||
|
route: k, count: r.count, avgMs: r.totalMs / r.count, maxMs: r.maxMs, errors: r.errors,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
total, totalServerErrors, statusClasses,
|
||||||
|
reqPerMin,
|
||||||
|
avgMs: avg, p50: _pct(sorted, 50), p95: _pct(sorted, 95), p99: _pct(sorted, 99),
|
||||||
|
routeCount: routeStats.size,
|
||||||
|
sinceMs: now - startedAt,
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { record, snapshot };
|
||||||
+136
-41
@@ -299,55 +299,150 @@
|
|||||||
window.clearErrorLog = clearErrorLog;
|
window.clearErrorLog = clearErrorLog;
|
||||||
|
|
||||||
/* ═══ SYSTEM HEALTH ════════════════════════════════════════════════ */
|
/* ═══ SYSTEM HEALTH ════════════════════════════════════════════════ */
|
||||||
|
let _healthLive = false, _healthTimer = null;
|
||||||
|
|
||||||
async function loadHealth() {
|
async function loadHealth() {
|
||||||
const el = document.getElementById('health-content');
|
const el = document.getElementById('health-content');
|
||||||
el.innerHTML = LS.skeleton(3, 'row');
|
el.innerHTML = LS.skeleton(3, 'row');
|
||||||
|
await refreshHealth();
|
||||||
|
setupHealthTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupHealthTimer() {
|
||||||
|
if (_healthTimer) { clearInterval(_healthTimer); _healthTimer = null; }
|
||||||
|
if (_healthLive) {
|
||||||
|
_healthTimer = setInterval(() => {
|
||||||
|
const el = document.getElementById('health-content');
|
||||||
|
if (!el || !el.offsetParent) { clearInterval(_healthTimer); _healthTimer = null; return; }
|
||||||
|
refreshHealth();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshHealth() {
|
||||||
try {
|
try {
|
||||||
const h = await LS.api('/api/admin/health');
|
const [h, m] = await Promise.all([
|
||||||
const fmtBytes = b => b > 1e9 ? (b/1e9).toFixed(1)+' GB' : b > 1e6 ? (b/1e6).toFixed(1)+' MB' : (b/1e3).toFixed(0)+' KB';
|
LS.api('/api/admin/health'),
|
||||||
const fmtUp = s => { const d=Math.floor(s/86400), hr=Math.floor(s%86400/3600), m=Math.floor(s%3600/60); return d>0?`${d}d ${hr}h`:hr>0?`${hr}h ${m}m`:`${m}m`; };
|
LS.api('/api/admin/metrics').catch(() => null),
|
||||||
el.innerHTML = `
|
]);
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px;margin-bottom:24px">
|
renderHealth(h, m);
|
||||||
<div class="adm-panel" style="padding:18px;margin:0;text-align:center">
|
} catch (e) { const el = document.getElementById('health-content'); if (el) el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
|
||||||
<div style="font-size:1.3rem;font-weight:800;font-family:'Unbounded',sans-serif;color:var(--green)">${fmtUp(h.uptime)}</div>
|
}
|
||||||
<div style="font-size:0.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-top:4px">Uptime</div>
|
|
||||||
|
function renderHealth(h, m) {
|
||||||
|
const el = document.getElementById('health-content');
|
||||||
|
if (!el) return;
|
||||||
|
const fmtBytes = b => !b ? '0' : b > 1e9 ? (b/1e9).toFixed(1)+' GB' : b > 1e6 ? (b/1e6).toFixed(1)+' MB' : (b/1e3).toFixed(0)+' KB';
|
||||||
|
const fmtUp = s => { const d=Math.floor(s/86400), hr=Math.floor(s%86400/3600), mm=Math.floor(s%3600/60); return d>0?`${d}d ${hr}h`:hr>0?`${hr}h ${mm}m`:`${mm}m`; };
|
||||||
|
const stColor = h.status==='critical'?'var(--pink)':h.status==='warning'?'#facc15':'var(--green)';
|
||||||
|
const stLabel = h.status==='critical'?'Критическое состояние':h.status==='warning'?'Требует внимания':'Всё в норме';
|
||||||
|
const memPct = Math.round((h.memPercent||0)*100);
|
||||||
|
const memCol = memPct>92?'var(--pink)':memPct>80?'#facc15':'var(--green)';
|
||||||
|
const lag = h.eventLoopLagMs||0, lagCol = lag>200?'var(--pink)':lag>70?'#facc15':'var(--green)';
|
||||||
|
const card = (val, label, col) => `<div class="adm-panel" style="padding:16px;margin:0;text-align:center">
|
||||||
|
<div style="font-size:1.25rem;font-weight:800;font-family:'Unbounded',sans-serif;color:${col||'var(--text-1)'}">${val}</div>
|
||||||
|
<div style="font-size:0.68rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-top:4px">${label}</div></div>`;
|
||||||
|
const maxRows = Math.max(1, ...(h.db.tables||[]).map(t=>t.rows));
|
||||||
|
|
||||||
|
// ── секция метрик запросов (Level 2) ──
|
||||||
|
let metricsHtml = '';
|
||||||
|
if (m) {
|
||||||
|
const sc = m.statusClasses||{}, tot = Math.max(1, (sc['2xx']||0)+(sc['3xx']||0)+(sc['4xx']||0)+(sc['5xx']||0));
|
||||||
|
const seg = (n,col)=> n>0?`<div style="width:${(n/tot*100).toFixed(1)}%;background:${col}" title="${n}"></div>`:'';
|
||||||
|
const routeRows = (arr, valFn, valLabel) => (arr&&arr.length)? arr.map(r=>`<div style="display:flex;align-items:center;gap:8px;margin:3px 0;font-size:.78rem">
|
||||||
|
<div style="flex:1;color:var(--text-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(r.route)}</div>
|
||||||
|
<div style="width:90px;text-align:right;font-weight:600;color:var(--text-3)">${valFn(r)}</div></div>`).join('')
|
||||||
|
: `<div style="color:var(--text-3);font-size:.78rem">нет данных</div>`;
|
||||||
|
const lagP = (v)=> (v||0)>200?'var(--pink)':(v||0)>70?'#facc15':'var(--text-1)';
|
||||||
|
metricsHtml = `
|
||||||
|
<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">(с рестарта · ${fmtUp(Math.floor((m.sinceMs||0)/1000))})</span></div>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:10px;margin:8px 0 14px">
|
||||||
|
${card(m.reqPerMin, 'Req/min', 'var(--green)')}
|
||||||
|
${card((m.total||0).toLocaleString('ru'), 'Всего')}
|
||||||
|
${card((m.avgMs||0).toFixed(0)+' мс', 'Средн.')}
|
||||||
|
${card((m.p95||0).toFixed(0)+' мс', 'p95', lagP(m.p95))}
|
||||||
|
${card((m.p99||0).toFixed(0)+' мс', 'p99', lagP(m.p99))}
|
||||||
|
${card(sc['5xx']||0, '5xx', (sc['5xx']||0)>0?'var(--pink)':'var(--green)')}
|
||||||
</div>
|
</div>
|
||||||
<div class="adm-panel" style="padding:18px;margin:0;text-align:center">
|
<div style="font-size:.72rem;color:var(--text-3);margin-bottom:5px">Статусы: 2xx ${sc['2xx']||0} · 3xx ${sc['3xx']||0} · 4xx ${sc['4xx']||0} · 5xx ${sc['5xx']||0}</div>
|
||||||
<div style="font-size:1.3rem;font-weight:800;font-family:'Unbounded',sans-serif;color:var(--violet)">${fmtBytes(h.db.sizeBytes)}</div>
|
<div style="display:flex;height:9px;border-radius:5px;overflow:hidden;background:rgba(255,255,255,.06);margin-bottom:14px">
|
||||||
<div style="font-size:0.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-top:4px">База данных</div>
|
${seg(sc['2xx'],'#4ade80')}${seg(sc['3xx'],'#60a5fa')}${seg(sc['4xx'],'#facc15')}${seg(sc['5xx'],'#f87171')}
|
||||||
</div>
|
</div>
|
||||||
<div class="adm-panel" style="padding:18px;margin:0;text-align:center">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
|
||||||
<div style="font-size:1.3rem;font-weight:800;font-family:'Unbounded',sans-serif">${fmtBytes(h.uploads.sizeBytes)}</div>
|
<div><div style="font-size:.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-bottom:4px">Самые медленные</div>${routeRows(m.topSlow, r=>r.avgMs.toFixed(0)+' мс')}</div>
|
||||||
<div style="font-size:0.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-top:4px">Файлы</div>
|
<div><div style="font-size:.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-bottom:4px">Самые частые</div>${routeRows(m.topBusy, r=>r.count.toLocaleString('ru'))}</div>
|
||||||
</div>
|
|
||||||
<div class="adm-panel" style="padding:18px;margin:0;text-align:center">
|
|
||||||
<div style="font-size:1.3rem;font-weight:800;font-family:'Unbounded',sans-serif;color:${h.recentErrors>0?'var(--pink)':'var(--green)'}">${h.recentErrors}</div>
|
|
||||||
<div style="font-size:0.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-top:4px">Ошибок за 24ч</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
|
|
||||||
<div class="adm-panel" style="margin:0">
|
|
||||||
<div class="adm-panel-title">Платформа</div>
|
|
||||||
<table style="width:100%;font-size:0.88rem">
|
|
||||||
<tr><td style="color:var(--text-3);padding:4px 0">Node.js</td><td style="font-weight:600">${h.node}</td></tr>
|
|
||||||
<tr><td style="color:var(--text-3);padding:4px 0">OS</td><td style="font-weight:600">${h.platform}</td></tr>
|
|
||||||
<tr><td style="color:var(--text-3);padding:4px 0">CPU ядра</td><td style="font-weight:600">${h.cpus}</td></tr>
|
|
||||||
<tr><td style="color:var(--text-3);padding:4px 0">RAM использовано</td><td style="font-weight:600">${fmtBytes(h.memory.rss)}</td></tr>
|
|
||||||
<tr><td style="color:var(--text-3);padding:4px 0">RAM heap</td><td style="font-weight:600">${fmtBytes(h.memory.heapUsed)}</td></tr>
|
|
||||||
<tr><td style="color:var(--text-3);padding:4px 0">RAM свободно</td><td style="font-weight:600">${fmtBytes(h.freeMem)} / ${fmtBytes(h.totalMem)}</td></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="adm-panel" style="margin:0">
|
|
||||||
<div class="adm-panel-title">Данные</div>
|
|
||||||
<table style="width:100%;font-size:0.88rem">
|
|
||||||
<tr><td style="color:var(--text-3);padding:4px 0">Пользователей</td><td style="font-weight:600">${h.db.totalUsers}</td></tr>
|
|
||||||
<tr><td style="color:var(--text-3);padding:4px 0">Всего сессий</td><td style="font-weight:600">${h.db.totalSessions}</td></tr>
|
|
||||||
<tr><td style="color:var(--text-3);padding:4px 0">Сессий сегодня</td><td style="font-weight:600;color:var(--violet)">${h.db.todaySessions}</td></tr>
|
|
||||||
<tr><td style="color:var(--text-3);padding:4px 0">Вопросов в базе</td><td style="font-weight:600">${h.db.totalQuestions}</td></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
${(m.topErrors&&m.topErrors.length)?`<div style="margin-top:12px"><div style="font-size:.72rem;color:var(--pink);font-weight:700;text-transform:uppercase;margin-bottom:4px">Маршруты с ошибками</div>${routeRows(m.topErrors, r=>r.errors+' ош. / '+r.count)}</div>`:''}
|
||||||
</div>`;
|
</div>`;
|
||||||
} catch (e) { el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</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>
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<div style="font-weight:800;font-family:'Unbounded',sans-serif;color:${stColor}">${stLabel}</div>
|
||||||
|
<div style="font-size:.78rem;color:var(--text-3);margin-top:2px">${h.reasons&&h.reasons.length?h.reasons.map(esc).join(' · '):'Все показатели в пределах нормы'}</div>
|
||||||
|
</div>
|
||||||
|
<button id="health-live-btn" style="height:30px;padding:0 14px;border-radius:8px;border:1.5px solid rgba(255,255,255,.14);background:rgba(255,255,255,.05);color:${_healthLive?'var(--green)':'var(--text-3)'};font-weight:700;font-size:.78rem;cursor:pointer;white-space:nowrap">${_healthLive?'● Live':'○ Авто-обновление'}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;margin-bottom:18px">
|
||||||
|
${card(fmtUp(h.uptime), 'Uptime', 'var(--green)')}
|
||||||
|
${card(fmtBytes(h.db.sizeBytes), 'База данных', 'var(--violet)')}
|
||||||
|
${card(fmtBytes(h.uploads.sizeBytes), 'Файлы')}
|
||||||
|
${card(h.recentErrors, 'Ошибок 24ч', h.recentErrors>0?'var(--pink)':'var(--green)')}
|
||||||
|
${card((h.sse?h.sse.connections:0), 'SSE онлайн', 'var(--green)')}
|
||||||
|
${card(memPct+'%', 'Память', memCol)}
|
||||||
|
${card(lag.toFixed(0)+' мс', 'Event-loop', lagCol)}
|
||||||
|
${card(h.disk?fmtBytes(h.disk.freeBytes)+' своб.':'—', 'Диск')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
|
||||||
|
<div class="adm-panel" style="margin:0">
|
||||||
|
<div class="adm-panel-title">Платформа</div>
|
||||||
|
<table style="width:100%;font-size:0.86rem">
|
||||||
|
<tr><td style="color:var(--text-3);padding:3px 0">Версия</td><td style="font-weight:600">${esc(h.version||'?')} ${h.commit?`<span style="color:var(--text-3);font-family:monospace">${esc(h.commit)}</span>`:''}</td></tr>
|
||||||
|
<tr><td style="color:var(--text-3);padding:3px 0">Окружение</td><td style="font-weight:600">${esc(h.env||'?')} · PID ${h.pid||'?'}</td></tr>
|
||||||
|
<tr><td style="color:var(--text-3);padding:3px 0">Node.js</td><td style="font-weight:600">${esc(h.node)}</td></tr>
|
||||||
|
<tr><td style="color:var(--text-3);padding:3px 0">OS / арх</td><td style="font-weight:600">${esc(h.platform)} ${esc(h.arch||'')}</td></tr>
|
||||||
|
<tr><td style="color:var(--text-3);padding:3px 0">CPU ядра · load</td><td style="font-weight:600">${h.cpus} · ${(h.loadavg||[0]).map(x=>x.toFixed(2)).join(' / ')}</td></tr>
|
||||||
|
<tr><td style="color:var(--text-3);padding:3px 0">RAM rss / heap</td><td style="font-weight:600">${fmtBytes(h.memory.rss)} / ${fmtBytes(h.memory.heapUsed)}</td></tr>
|
||||||
|
<tr><td style="color:var(--text-3);padding:3px 0">RAM система</td><td style="font-weight:600">${fmtBytes(h.totalMem-h.freeMem)} / ${fmtBytes(h.totalMem)}</td></tr>
|
||||||
|
${h.disk?`<tr><td style="color:var(--text-3);padding:3px 0">Диск</td><td style="font-weight:600">${fmtBytes(h.disk.freeBytes)} своб. / ${fmtBytes(h.disk.totalBytes)}</td></tr>`:''}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="adm-panel" style="margin:0">
|
||||||
|
<div class="adm-panel-title">Данные и активность</div>
|
||||||
|
<table style="width:100%;font-size:0.86rem">
|
||||||
|
<tr><td style="color:var(--text-3);padding:3px 0">Пользователей</td><td style="font-weight:600">${h.db.totalUsers}</td></tr>
|
||||||
|
<tr><td style="color:var(--text-3);padding:3px 0">Онлайн (SSE)</td><td style="font-weight:600;color:var(--green)">${h.sse?h.sse.users:0} польз. · ${h.sse?h.sse.connections:0} соед.</td></tr>
|
||||||
|
<tr><td style="color:var(--text-3);padding:3px 0">Всего сессий</td><td style="font-weight:600">${h.db.totalSessions}</td></tr>
|
||||||
|
<tr><td style="color:var(--text-3);padding:3px 0">Сессий сегодня</td><td style="font-weight:600;color:var(--violet)">${h.db.todaySessions}</td></tr>
|
||||||
|
<tr><td style="color:var(--text-3);padding:3px 0">Вопросов в базе</td><td style="font-weight:600">${h.db.totalQuestions}</td></tr>
|
||||||
|
<tr><td style="color:var(--text-3);padding:3px 0">WAL</td><td style="font-weight:600">${fmtBytes(h.db.walBytes||0)}</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${metricsHtml}
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<div style="width:170px;font-size:.8rem;color:var(--text-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(t.name)}</div>
|
||||||
|
<div style="flex:1;height:7px;background:rgba(255,255,255,.06);border-radius:4px;overflow:hidden"><div style="height:100%;width:${Math.round(t.rows/maxRows*100)}%;background:var(--violet);border-radius:4px"></div></div>
|
||||||
|
<div style="width:60px;text-align:right;font-size:.78rem;font-weight:600">${t.rows.toLocaleString('ru')}</div>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const btn = document.getElementById('health-live-btn');
|
||||||
|
if (btn) btn.addEventListener('click', () => {
|
||||||
|
_healthLive = !_healthLive;
|
||||||
|
btn.textContent = _healthLive ? '● Live' : '○ Авто-обновление';
|
||||||
|
btn.style.color = _healthLive ? 'var(--green)' : 'var(--text-3)';
|
||||||
|
setupHealthTimer();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════
|
/* ════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user