@@ -299,55 +299,150 @@
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' ) ;
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 {
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>
const [ h , m ] = await Promise . all ( [
LS . api ( '/api/admin/health' ) ,
LS . api ( '/api/admin/metrics' ) . catch ( ( ) => null ) ,
] ) ;
renderHealth ( h , m ) ;
} catch ( e ) { const el = document . getElementById ( 'health-content' ) ; if ( el ) el . innerHTML = ` <div style="color:var(--pink)"> ${ esc ( e . message ) } </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 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 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="display:flex;height:9px;border-radius:5px;overflow:hidden;background:rgba(255,255,255,.06);margin-bottom:14px" >
${ seg ( sc [ '2xx' ] , '#4ade80' ) } ${ seg ( sc [ '3xx' ] , '#60a5fa' ) } ${ seg ( sc [ '4xx' ] , '#facc15' ) } ${ seg ( sc [ '5xx' ] , '#f87171' ) }
</div>
<div class="adm-panel" style="padding:18px;margin:0;text-align:center ">
<div style="font-size:1.3 rem;font-weight:8 00;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>
</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 style="display:grid;grid-template-columns:1fr 1fr;gap:16px ">
<div><div style="font-size:.72 rem;color:var(--text-3); font-weight:7 00;text-transform:uppercase;margin-bottom:4px">Самые медленные</div > ${ routeRows ( m . topSlow , r => r . avgMs . toFixed ( 0 ) + ' мс' ) } </div>
<div><div style="font-size:.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-bot tom :4px">Самые частые</div> ${ routeRows ( m . topBusy , r => r . count . toLocaleString ( 'ru' ) ) } </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> ` ;
} 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 ( ) ;
} ) ;
}
/* ════════════════════════════════════════════════