feat(admin/health): System Health Level 1 — живой мониторинг + вердикт
getHealth обогащён: вердикт здоровья (ok/warning/critical) по порогам (память %, диск, ошибки/24ч, лаг event-loop, размер БД) + причины; реальный % памяти, лаг event-loop (perf_hooks), load average, свободное место на диске (statfs), PID/NODE_ENV, версия+git-commit, число активных SSE-соединений, размер WAL, разбивка БД по крупнейшим таблицам. sse.js: экспорт stats() (онлайн-пользователи/гости/соединения). admin.js loadHealth: светофор-баннер вердикта с причинами, тумблер авто-обновления (live, поллинг 5с с самоостановкой при уходе с вкладки), 8 карточек (uptime/БД/файлы/ошибки/SSE/память/event-loop/диск), панели платформы и активности, горизонтальные бары крупнейших таблиц БД. Проверено: getHealth собирает полный payload, вердикт срабатывает (диск<2ГБ → warning), NaN-лаг защищён. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -610,47 +610,105 @@ function clearErrorLog(req, res) {
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { DB_PATH, UPLOADS_DIR } = require('../config');
|
||||
const { execSync } = require('child_process');
|
||||
const { monitorEventLoopDelay } = require('perf_hooks');
|
||||
const sse = require('../sse');
|
||||
const { DB_PATH, UPLOADS_DIR, NODE_ENV } = require('../config');
|
||||
|
||||
// Монитор лага event-loop (включается один раз при загрузке модуля).
|
||||
const _eluMonitor = monitorEventLoopDelay({ resolution: 20 });
|
||||
_eluMonitor.enable();
|
||||
|
||||
// Версия приложения + git-commit (кэшируются один раз).
|
||||
const _appVersion = (() => { try { return require('../../package.json').version; } catch { return '?'; } })();
|
||||
const _gitCommit = (() => {
|
||||
try { return execSync('git rev-parse --short HEAD', { cwd: __dirname, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); }
|
||||
catch { return null; }
|
||||
})();
|
||||
|
||||
function _dirSize(dir) {
|
||||
let total = 0;
|
||||
try { for (const f of fs.readdirSync(dir)) { try { total += fs.statSync(path.join(dir, f)).size; } catch {} } } catch {}
|
||||
return total;
|
||||
}
|
||||
|
||||
// Топ таблиц БД по числу строк (исключая служебные sqlite_* и _migrations).
|
||||
function _dbTables() {
|
||||
try {
|
||||
const tables = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '\\_%' ESCAPE '\\'"
|
||||
).all();
|
||||
const rows = tables.map(t => {
|
||||
let n = 0;
|
||||
try { n = db.prepare(`SELECT COUNT(*) AS n FROM "${t.name}"`).get().n; } catch {}
|
||||
return { name: t.name, rows: n };
|
||||
});
|
||||
rows.sort((a, b) => b.rows - a.rows);
|
||||
return rows.slice(0, 12);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function getHealth(_req, res) {
|
||||
const uptimeSec = process.uptime();
|
||||
let dbSizeBytes = 0;
|
||||
try { dbSizeBytes = fs.statSync(DB_PATH).size; } catch {}
|
||||
let uploadsSizeBytes = 0;
|
||||
try {
|
||||
const files = fs.readdirSync(UPLOADS_DIR);
|
||||
for (const f of files) {
|
||||
try { uploadsSizeBytes += fs.statSync(path.join(UPLOADS_DIR, f)).size; } catch {}
|
||||
}
|
||||
} catch {}
|
||||
const mem = process.memoryUsage();
|
||||
const dbSizeBytes = (() => { try { return fs.statSync(DB_PATH).size; } catch { return 0; } })();
|
||||
const walSizeBytes = (() => { try { return fs.statSync(DB_PATH + '-wal').size; } catch { return 0; } })();
|
||||
const uploadsSizeBytes = _dirSize(UPLOADS_DIR);
|
||||
|
||||
const totalUsers = db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
|
||||
const todaySessions = db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE started_at >= date('now')").get().n;
|
||||
const totalSessions = db.prepare('SELECT COUNT(*) AS n FROM test_sessions').get().n;
|
||||
// Свободное место на разделе, где лежит БД.
|
||||
let disk = null;
|
||||
try { const s = fs.statfsSync(path.dirname(path.resolve(DB_PATH))); disk = { freeBytes: s.bavail * s.bsize, totalBytes: s.blocks * s.bsize }; } catch {}
|
||||
|
||||
const totalMem = os.totalmem(), freeMem = os.freemem();
|
||||
const memPercent = totalMem ? (totalMem - freeMem) / totalMem : 0;
|
||||
const eventLoopLagMs = Number.isFinite(_eluMonitor.mean) ? _eluMonitor.mean / 1e6 : 0;
|
||||
|
||||
const totalUsers = db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
|
||||
const todaySessions = db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE started_at >= date('now')").get().n;
|
||||
const totalSessions = db.prepare('SELECT COUNT(*) AS n FROM test_sessions').get().n;
|
||||
const totalQuestions = db.prepare('SELECT COUNT(*) AS n FROM questions').get().n;
|
||||
const recentErrors = db.prepare("SELECT COUNT(*) AS n FROM error_log WHERE created_at >= datetime('now', '-24 hours')").get().n;
|
||||
const recentErrors = db.prepare("SELECT COUNT(*) AS n FROM error_log WHERE created_at >= datetime('now', '-24 hours')").get().n;
|
||||
|
||||
let sseStats = { users: 0, guests: 0, connections: 0 };
|
||||
try { sseStats = sse.stats(); } catch {}
|
||||
|
||||
// Вердикт здоровья по порогам.
|
||||
const reasons = [];
|
||||
let status = 'ok';
|
||||
const warn = (m) => { reasons.push(m); if (status === 'ok') status = 'warning'; };
|
||||
const crit = (m) => { reasons.push(m); status = 'critical'; };
|
||||
if (memPercent > 0.92) crit(`Память ${Math.round(memPercent * 100)}%`);
|
||||
else if (memPercent > 0.80) warn(`Память ${Math.round(memPercent * 100)}%`);
|
||||
if (disk) {
|
||||
if (disk.freeBytes < 500e6) crit('Мало места на диске (<500 МБ)');
|
||||
else if (disk.freeBytes < 2e9) warn('Места на диске <2 ГБ');
|
||||
}
|
||||
if (recentErrors > 50) crit(`${recentErrors} ошибок за 24ч`);
|
||||
else if (recentErrors > 5) warn(`${recentErrors} ошибок за 24ч`);
|
||||
if (eventLoopLagMs > 200) crit(`Лаг event-loop ${eventLoopLagMs.toFixed(0)} мс`);
|
||||
else if (eventLoopLagMs > 70) warn(`Лаг event-loop ${eventLoopLagMs.toFixed(0)} мс`);
|
||||
if (dbSizeBytes > 1.5e9) warn('БД >1.5 ГБ');
|
||||
|
||||
res.json({
|
||||
status, reasons,
|
||||
uptime: uptimeSec,
|
||||
memory: {
|
||||
rss: process.memoryUsage().rss,
|
||||
heapUsed: process.memoryUsage().heapUsed,
|
||||
},
|
||||
db: {
|
||||
sizeBytes: dbSizeBytes,
|
||||
totalUsers,
|
||||
totalSessions,
|
||||
todaySessions,
|
||||
totalQuestions,
|
||||
},
|
||||
uploads: {
|
||||
sizeBytes: uploadsSizeBytes,
|
||||
},
|
||||
startedAt: new Date(Date.now() - uptimeSec * 1000).toISOString(),
|
||||
memory: { rss: mem.rss, heapUsed: mem.heapUsed, heapTotal: mem.heapTotal },
|
||||
memPercent, eventLoopLagMs,
|
||||
loadavg: os.loadavg(),
|
||||
disk,
|
||||
db: { sizeBytes: dbSizeBytes, walBytes: walSizeBytes, totalUsers, totalSessions, todaySessions, totalQuestions, tables: _dbTables() },
|
||||
uploads: { sizeBytes: uploadsSizeBytes },
|
||||
sse: sseStats,
|
||||
node: process.version,
|
||||
platform: os.platform(),
|
||||
arch: os.arch(),
|
||||
cpus: os.cpus().length,
|
||||
freeMem: os.freemem(),
|
||||
totalMem: os.totalmem(),
|
||||
freeMem, totalMem,
|
||||
pid: process.pid,
|
||||
env: NODE_ENV,
|
||||
version: _appVersion,
|
||||
commit: _gitCommit,
|
||||
recentErrors,
|
||||
});
|
||||
}
|
||||
|
||||
+11
-1
@@ -79,7 +79,17 @@ function getOnlineUserIds() {
|
||||
return [...clients.keys()];
|
||||
}
|
||||
|
||||
/* Сводка SSE-соединений для мониторинга: онлайн-пользователи, гости и
|
||||
суммарное число открытых стримов. */
|
||||
function stats() {
|
||||
let conns = 0;
|
||||
for (const set of clients.values()) conns += set.size;
|
||||
let guestConns = 0;
|
||||
for (const set of guestClients.values()) guestConns += set.size;
|
||||
return { users: clients.size, guests: guestClients.size, connections: conns + guestConns };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
addClient, removeClient, emit, emitToClass, getOnlineUserIds,
|
||||
addClient, removeClient, emit, emitToClass, getOnlineUserIds, stats,
|
||||
addGuestClient, removeGuestClient, emitToGuests,
|
||||
};
|
||||
|
||||
+102
-45
@@ -299,55 +299,112 @@
|
||||
window.clearErrorLog = clearErrorLog;
|
||||
|
||||
/* ═══ SYSTEM HEALTH ════════════════════════════════════════════════ */
|
||||
let _healthLive = false, _healthTimer = null;
|
||||
|
||||
async function loadHealth() {
|
||||
const el = document.getElementById('health-content');
|
||||
el.innerHTML = LS.skeleton(3, 'row');
|
||||
try {
|
||||
const h = await LS.api('/api/admin/health');
|
||||
const fmtBytes = b => 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), m=Math.floor(s%3600/60); return d>0?`${d}d ${hr}h`:hr>0?`${hr}h ${m}m`:`${m}m`; };
|
||||
el.innerHTML = `
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px;margin-bottom:24px">
|
||||
<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: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>
|
||||
</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:var(--violet)">${fmtBytes(h.db.sizeBytes)}</div>
|
||||
<div style="font-size:0.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-top:4px">База данных</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">${fmtBytes(h.uploads.sizeBytes)}</div>
|
||||
<div style="font-size:0.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-top:4px">Файлы</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>
|
||||
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 { renderHealth(await LS.api('/api/admin/health')); }
|
||||
catch (e) { const el = document.getElementById('health-content'); if (el) el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
|
||||
}
|
||||
|
||||
function renderHealth(h) {
|
||||
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), m=Math.floor(s%3600/60); return d>0?`${d}d ${hr}h`:hr>0?`${hr}h ${m}m`:`${m}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;
|
||||
const 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));
|
||||
|
||||
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>
|
||||
${h.reasons&&h.reasons.length?`<div style="font-size:.78rem;color:var(--text-3);margin-top:2px">${h.reasons.map(esc).join(' · ')}</div>`:`<div style="font-size:.78rem;color:var(--text-3);margin-top:2px">Все показатели в пределах нормы</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>`;
|
||||
} catch (e) { el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</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;margin-bottom: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>
|
||||
<tr><td style="color:var(--text-3);padding:3px 0">Запущен</td><td style="font-weight:600">${h.startedAt?esc(new Date(h.startedAt).toLocaleString('ru'))+'':'—'}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="adm-panel" style="margin: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