32 lines
21 KiB
HTML
32 lines
21 KiB
HTML
<!doctype html>
|
||
<html lang="ru">
|
||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>RFB · Состояние сервера</title>
|
||
<style>
|
||
:root{color-scheme:dark;--bg:#090f1b;--panel:#101a2b;--line:#243149;--muted:#91a2bd;--text:#eaf0fa;--teal:#4cdec2;--blue:#7caeff;--orange:#ffcc79;--red:#ff818e}*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 90% 0,#14303a55,transparent 45%),var(--bg);font:14px/1.5 "Segoe UI",system-ui,sans-serif;color:var(--text)}main{max-width:1440px;margin:auto;padding:30px 36px 38px}header{display:flex;justify-content:space-between;gap:20px;align-items:center}.brand{font-size:12px;font-weight:700;letter-spacing:2.5px;color:var(--teal)}h1{font-size:28px;line-height:1.2;margin:9px 0 7px;letter-spacing:-.7px}h2{font-size:16px;margin:0}.muted,.sub{color:var(--muted)}.sub{font-size:12px}.buttons{display:flex;gap:8px;flex-wrap:wrap}button,a.button{border:1px solid var(--line);background:#142036;color:var(--text);border-radius:9px;padding:9px 13px;cursor:pointer;text-decoration:none;font:inherit;font-size:12px}button:hover,a.button:hover{border-color:var(--teal)}.pill{display:inline-flex;align-items:center;gap:7px;border:1px solid var(--line);border-radius:30px;padding:5px 10px;font-size:12px}.dot{width:7px;height:7px;border-radius:50%;background:currentColor}.ok{color:var(--teal)}.warn{color:var(--orange)}.bad{color:var(--red)}.meta{display:flex;gap:16px;flex-wrap:wrap;margin:19px 0;color:var(--muted);font-size:12px}.meta strong{color:var(--text);font-weight:500}.notice{border:1px solid #296359;border-radius:12px;background:#102e2d80;padding:18px 20px;display:flex;align-items:center;gap:16px;margin:18px 0}.notice.warn{border-color:#675135;background:#342c1d80}.notice.bad{border-color:#68323e;background:#391a2580}.notice svg{flex-shrink:0}.notice strong{display:block;font-size:16px;color:var(--text);margin-bottom:3px}.notice p{margin:0;font-size:12px;color:#bac8db}.cards{display:grid;grid-template-columns:repeat(6,1fr);gap:12px}.card,.panel{background:linear-gradient(150deg,#132035,#101929);border:1px solid var(--line);border-radius:12px}.card{padding:16px}.label{text-transform:uppercase;font-size:10px;letter-spacing:1.2px;color:var(--muted);font-weight:600}.value{font-size:26px;line-height:1.2;letter-spacing:-.7px;margin:10px 0 7px;font-variant-numeric:tabular-nums}.unit{font-size:12px;font-weight:400;color:var(--muted);margin-left:4px}.charts{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:14px 0 22px}.panel{padding:19px 20px}.chart-header,.panel-header{display:flex;align-items:center;justify-content:space-between;gap:12px}.chart{width:100%;height:130px;display:block;margin-top:10px}.panel-header{margin-bottom:16px}.table-wrap{overflow:auto}table{border-collapse:collapse;width:100%;font-size:12px;white-space:nowrap}th{text-align:left;font-weight:500;color:var(--muted);padding:9px 12px;border-bottom:1px solid var(--line)}td{padding:11px 12px;border-bottom:1px solid #24314980;font-variant-numeric:tabular-nums}td:first-child{font-family:Consolas,monospace;color:#c6d7ed}tr:last-child td{border-bottom:0}td.message{white-space:normal;min-width:180px;max-width:350px;color:var(--muted)}.bottom{display:grid;grid-template-columns:minmax(0,2.4fr) minmax(230px,1fr);gap:14px;margin-top:16px}.filters{display:flex;gap:8px;flex-wrap:wrap}input,select{background:#0b1423;border:1px solid var(--line);color:var(--text);border-radius:7px;padding:7px 9px;font:inherit;font-size:12px}input{width:170px}.log{height:335px;overflow:auto;border-top:1px solid var(--line);margin-top:14px;padding-top:7px}.event{display:grid;grid-template-columns:67px 64px 1fr;gap:9px;padding:8px 0;border-bottom:1px solid #24314970;font:11px/1.65 Consolas,monospace}.event time{color:var(--muted)}.event .message{white-space:pre-wrap;overflow-wrap:anywhere}.side-item{border-bottom:1px solid var(--line);padding:13px 0}.side-item:last-child{border:0}.side-item strong{font-size:13px}.stats-line{display:flex;justify-content:space-between;gap:10px;font-size:12px;color:var(--muted);margin-top:7px}.stats-line b{color:var(--text);font-weight:500}.footer{display:flex;justify-content:space-between;gap:15px;margin-top:18px;font-size:11px;color:var(--muted)}.toast{margin-top:8px;font-size:12px;color:var(--orange)}.empty{padding:35px 0;text-align:center;color:var(--muted)}@media(max-width:1100px){.cards{grid-template-columns:repeat(3,1fr)}main{padding:24px}.bottom{grid-template-columns:2fr 1fr}}@media(max-width:760px){main{padding:18px 14px}header{align-items:flex-start;flex-direction:column}h1{font-size:24px}.cards{grid-template-columns:repeat(2,1fr)}.charts,.bottom{grid-template-columns:1fr}.meta{gap:8px 14px}.panel{padding:15px}.panel-header{align-items:flex-start;flex-wrap:wrap}.footer{flex-direction:column}.event{grid-template-columns:60px 50px 1fr;gap:6px}.value{font-size:23px}}
|
||
</style></head>
|
||
<body><main>
|
||
<header><div><div class="brand">RFB / OPERATIONS</div><h1>Состояние сервера <span id="host">—</span></h1><div class="sub" id="context">Подключение к локальной диагностике…</div></div><div class="buttons"><span class="pill ok" id="connection"><span class="dot"></span><span>Подключение</span></span><button id="pause">Пауза</button><button id="refresh">Обновить</button><a class="button" href="/status" target="_blank" rel="noopener">Статус матча ↗</a></div></header>
|
||
<div class="meta"><span>Версия <strong id="version">—</strong></span><span>Установлено <strong id="deployed">—</strong></span><span>Запуск <strong id="started">—</strong></span><span>В работе <strong id="uptime">—</strong></span></div>
|
||
<div class="notice warn" id="banner"><svg width="27" height="27" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 7v6m0 3v1"/></svg><div><strong id="headline">Получаем состояние</strong><p id="explanation">Эта страница читает локальные данные и не опрашивает API RFB.</p></div></div>
|
||
<section class="cards"><div class="card"><div class="label">Режим работы</div><div class="value" id="mode">—</div><div class="sub" id="game">Матч —</div></div><div class="card"><div class="label">API · за 60 секунд</div><div class="value"><span id="api-count">—</span><span class="unit" id="api-limit"></span></div><div class="sub" id="api-note">Общий бюджет машины</div></div><div class="card"><div class="label">Входящие · за 60 секунд</div><div class="value"><span id="http-count">—</span><span class="unit">запросов</span></div><div class="sub" id="http-note">От vMix и других клиентов</div></div><div class="card"><div class="label">CPU приложения</div><div class="value"><span id="cpu">—</span><span class="unit">%</span></div><div class="sub">От мощности одного ядра</div></div><div class="card"><div class="label">Память приложения</div><div class="value"><span id="ram">—</span><span class="unit">МБ</span></div><div class="sub" id="ram-note">Свободная RAM машины —</div></div><div class="card"><div class="label">Кэш ответов</div><div class="value"><span id="cache">—</span><span class="unit">%</span></div><div class="sub" id="cache-note">Повторные расчёты сэкономлены</div></div></section>
|
||
<section class="charts"><div class="panel"><div class="chart-header"><h2>Запросы к API</h2><span class="sub">за 60 с</span></div><svg class="chart" id="chart-api" viewBox="0 0 400 125" role="img" aria-label="График запросов к API"></svg></div><div class="panel"><div class="chart-header"><h2>Входящий поток</h2><span class="sub">за 60 с</span></div><svg class="chart" id="chart-http" viewBox="0 0 400 125" role="img" aria-label="График входящих запросов"></svg></div><div class="panel"><div class="chart-header"><h2>Нагрузка CPU</h2><span class="sub">%</span></div><svg class="chart" id="chart-cpu" viewBox="0 0 400 125" role="img" aria-label="График CPU"></svg></div></section>
|
||
<section class="panel"><div class="panel-header"><div><h2>Источники данных</h2><div class="sub">Состояние запроса и давность последних принятых данных</div></div><span class="pill" id="source-summary">—</span></div><div class="table-wrap"><table><thead><tr><th>Источник</th><th>Состояние</th><th>Давность</th><th>HTTP</th><th>Время ответа</th><th>Успех / ошибки</th><th>Комментарий</th></tr></thead><tbody id="sources"><tr><td colspan="7">Ожидание диагностики…</td></tr></tbody></table></div></section>
|
||
<section class="bottom"><div class="panel"><div class="panel-header"><div><h2>Журнал событий</h2><div class="sub">Последние 400 событий</div></div><div class="filters"><select id="level" aria-label="Уровень лога"><option value="ALL">Все уровни</option><option value="WARNING">Предупреждения</option><option value="ERROR">Ошибки</option><option value="INFO">Информация</option></select><input id="search" placeholder="Поиск по журналу" aria-label="Поиск"><button id="download">Скачать</button></div></div><div class="log" id="events"><div class="empty">События появятся после подключения</div></div></div><aside class="panel"><h2>Службы и ресурсы</h2><div class="side-item"><strong>Публикация JSON</strong><div class="sub" id="publication-state">Ожидает данных</div></div><div class="side-item"><strong>Telegram <span id="tg-state" class="muted">—</span></strong><div class="sub" id="tg-message">—</div><div class="sub" id="tg-time" style="margin-top:5px">—</div><div class="stats-line"><span>Доставлено / ошибки / очередь</span><b id="tg-counts">—</b></div><div style="margin-top:10px"><button id="telegram-test">Проверить доставку</button></div><div class="toast" id="tg-test-result"></div></div><div class="side-item"><strong>Excel / NAS</strong><div class="sub" id="excel-state">—</div><div class="sub" id="assets-state">—</div></div><div class="side-item"><strong>Запросы клиентов</strong><div class="stats-line"><span>Всего</span><b id="http-total">—</b></div><div class="stats-line"><span>Ответы 503 / 500</span><b id="http-errors">—</b></div><div class="sub" style="margin-top:7px">503 до прогрева означает, что данные ещё не готовы.</div></div><div class="side-item"><strong>Сборка</strong><div class="stats-line"><span>Собрана</span><b id="built">—</b></div><div class="stats-line"><span>Python / PID</span><b id="process">—</b></div><div class="sub" id="fingerprint" style="margin-top:7px">—</div></div></aside></section>
|
||
<div class="footer"><span id="updated">Обновление каждые 5 секунд · время МСК</span><span>Диагностика не создаёт запросов к API RFB · график за последние 15 минут</span></div>
|
||
</main><script>
|
||
'use strict';
|
||
const el=id=>document.getElementById(id), esc=x=>String(x??'—').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
const number=x=>x==null?'—':Number(x).toLocaleString('ru-RU',{maximumFractionDigits:1});
|
||
const date=x=>x?new Date(typeof x==='number'?x*1000:x).toLocaleString('ru-RU',{timeZone:'Europe/Moscow',day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'}):'не зафиксировано';
|
||
const age=x=>x==null?'—':x<60?`${Math.round(x)} с`:x<3600?`${Math.floor(x/60)} мин`:`${Math.floor(x/3600)} ч ${Math.floor(x%3600/60)} мин`;
|
||
const modes={waiting:'Ожидание',prestart:'Подготовка',live:'В эфире',finished:'Завершение',starting:'Запуск'};
|
||
const states={ok:['Готов','ok'],error:['Ошибка','bad'],stale:['Устарело','warn'],cached:['Из кэша','muted'],idle:['Ожидание','muted'],pending:['Ожидает загрузки','muted'],updating:['Обновляется','ok'],not_ready:['API ещё не готов','warn'],held:['Сохранены данные','warn']};
|
||
let paused=false,timer=null,loading=false,lastId=0,events=[],state=null;
|
||
function chart(id,history,key,color,limit=0){const points=history.map(x=>Number(x[key]??0));const max=Math.max(1,limit,...points)*1.15;let markup='';for(let i=0;i<3;i++){let y=12+i*41;markup+=`<line x1="30" y1="${y}" x2="395" y2="${y}" stroke="#26354d"/><text x="0" y="${y+4}" fill="#8195b0" font-size="9">${Math.round(max*(1-i/2))}</text>`;}if(limit){let y=94-limit/max*82;markup+=`<line x1="30" y1="${y}" x2="395" y2="${y}" stroke="#ffcc7970" stroke-dasharray="4 4"/>`;}if(points.length){let coords=points.map((v,i)=>`${30+i/Math.max(1,points.length-1)*365},${94-v/max*82}`);markup+=`<polygon points="30,94 ${coords.join(' ')} 395,94" fill="${color}" opacity=".08"/><polyline points="${coords.join(' ')}" fill="none" stroke="${color}" stroke-width="2" stroke-linejoin="round"/>`;markup+=`<text x="30" y="120" fill="#8195b0" font-size="9">${esc(date(history[0].ts).split(' ').pop())}</text><text x="395" y="120" text-anchor="end" fill="#8195b0" font-size="9">сейчас</text>`;}el(id).innerHTML=markup;}
|
||
function renderEvents(){const level=el('level').value,search=el('search').value.toLowerCase();const rows=events.filter(x=>(level==='ALL'||x.level===level||(level==='ERROR'&&x.level==='CRITICAL'))&&x.message.toLowerCase().includes(search)).slice(-200);const box=el('events'),atBottom=box.scrollTop+box.clientHeight>=box.scrollHeight-30;box.innerHTML=rows.length?rows.map(e=>`<div class="event"><time>${esc(date(e.ts).split(' ').pop())}</time><span class="${e.level==='ERROR'||e.level==='CRITICAL'?'bad':e.level==='WARNING'?'warn':'ok'}">${esc(e.level)}</span><span class="message">${esc(e.message)}</span></div>`).join(''):'<div class="empty">Подходящих событий нет</div>';if(atBottom)box.scrollTop=box.scrollHeight;}
|
||
function render(d){if(state&&(state.session_id!==d.session_id)){events=[];lastId=0;}state=d;el('host').textContent=d.hostname;el('context').textContent=`${d.league?.toUpperCase()??'RFB'} · ${d.team} · автономный сборщик`;el('version').textContent=d.release?.version??'—';el('deployed').textContent=d.deployment?.deployed_at?date(d.deployment.deployed_at):`обнаружено ${date(d.deployment?.first_seen_at)}`;el('started').textContent=date(d.started_at);el('uptime').textContent=age(d.uptime_seconds);const overall=d.overall??{severity:'warn',title:'Запуск диагностики',detail:'Ожидание первого измерения'};el('banner').className=`notice ${overall.severity}`;el('headline').textContent=overall.title;el('explanation').textContent=overall.detail;el('mode').textContent=modes[d.mode]??'Запуск';el('game').textContent=`Матч ${d.game_id??'не выбран'}`;el('api-count').textContent=number(d.api?.requests_last_minute);el('api-limit').textContent=`/ ${d.api?.limit_per_minute??'—'}`;let pause=Math.max(d.api?.pause_seconds??0,d.api?.network_pause_seconds??0);el('api-note').textContent=pause>0?`Общая пауза ${age(pause)}`:`Всего ${number(d.api?.total_requests)}`;el('http-count').textContent=number(d.http?.last_minute?.requests);el('http-note').textContent=`Ошибки за 60 с: ${number(d.http?.last_minute?.errors??0)}`;el('cpu').textContent=number(d.machine?.cpu_percent);el('ram').textContent=number(d.machine?.rss_mb);el('ram-note').textContent=`Свободно ${number(d.machine?.ram_available_mb)} / ${number(d.machine?.ram_total_mb)} МБ`;const hits=d.cache?.hits??0,misses=d.cache?.misses??0;el('cache').textContent=hits+misses?number(100*hits/(hits+misses)):'0';el('cache-note').textContent=`${number(hits)} ответов без пересчёта`;const hist=d.history??[];chart('chart-api',hist,'api','#4cdec2',d.api?.limit_per_minute??0);chart('chart-http',hist,'http','#7caeff');chart('chart-cpu',hist,'cpu','#c9a0ff',100);const sources=d.sources??[];el('source-summary').textContent=`${sources.filter(x=>x.active).length} активных / ${sources.length}`;el('sources').innerHTML=sources.map(r=>{const [label,color]=states[r.state]??['Неизвестно','muted'];let note=r.message||(r.state==='cached'?'Частый опрос выключен':r.state==='idle'?'Данные сейчас не требуются':'');if(r.retry_seconds>0)note+=` · повтор через ${age(r.retry_seconds)}`;return `<tr><td>${esc(r.name)}</td><td><span class="pill ${color}"><span class="dot"></span>${esc(label)}</span></td><td>${esc(age(r.age_seconds))}</td><td>${esc(r.http_status??'—')}</td><td>${r.duration_ms==null?'—':`${esc(number(r.duration_ms))} мс`}</td><td>${number(r.successes)} / ${number(r.errors)}</td><td class="message">${esc(note)}</td></tr>`;}).join('');const tg=d.telegram??{};const tgState=tg.state??(!tg.enabled?'disabled':tg.retry_seconds>0?'backoff':tg.last_success?'ready':'waiting');el('tg-state').textContent=({disabled:'выключен',backoff:'повтор с паузой',error:'ошибка доставки',ready:'доставка работает',waiting:'ожидает отправки'})[tgState]??'—';el('tg-state').className=tgState==='disabled'?'muted':tgState==='error'?'bad':tgState==='backoff'?'warn':'ok';el('tg-message').textContent=tg.message??'—';el('tg-time').textContent=`Доставка: ${date(tg.last_success)}${tg.last_error&&tgState!=='ready'?` · ошибка: ${date(tg.last_error)}`:''}`;el('tg-counts').textContent=`${number(tg.sent)} / ${number(tg.failed)} / ${number(tg.queue)}`;el('telegram-test').disabled=!tg.enabled;const pub=d.components?.publication;el('publication-state').textContent=pub?.message??'Ожидает данных';el('publication-state').className=pub?.ok===false?'sub warn':'sub';el('excel-state').textContent=`Excel: ${d.components?.excel?.message??'ожидает загрузки'}`;el('assets-state').textContent=`Иконки: ${d.components?.assets?.message??'ожидают загрузки'}`;el('http-total').textContent=number(d.http?.total);el('http-errors').textContent=`${number(d.http?.statuses?.['503']??0)} / ${number(d.http?.statuses?.['500']??0)}`;el('built').textContent=date(d.release?.built_at);el('process').textContent=`${d.python} / ${d.pid}`;el('fingerprint').textContent=`Код ${(d.deployment?.fingerprint??'—').slice(0,12)}`;for(const e of d.events??[]){if(e.id>lastId){events.push(e);lastId=e.id;}}events=events.slice(-400);renderEvents();el('connection').className='pill ok';el('connection').lastElementChild.textContent=paused?'Пауза':'На связи';el('updated').textContent=`Обновлено ${date(d.now)} · каждые 5 секунд · МСК`;}
|
||
async function refresh(){if(loading)return;loading=true;const controller=new AbortController(),timeout=setTimeout(()=>controller.abort(),4500);try{const response=await fetch(`/logs/data?after=${lastId}&session=${encodeURIComponent(state?.session_id??'')}`,{cache:'no-store',signal:controller.signal});if(!response.ok)throw new Error(`HTTP ${response.status}`);render(await response.json());}catch(e){el('connection').className='pill bad';el('connection').lastElementChild.textContent='Нет связи';el('banner').className='notice bad';el('headline').textContent='Не отвечает ваше приложение';el('explanation').textContent=`Не удалось обновить /logs/data (${e.name==='AbortError'?'тайм-аут':e.message}). На странице остаётся последнее полученное состояние.`;}finally{clearTimeout(timeout);loading=false;}}
|
||
async function tick(){clearTimeout(timer);if(!paused&&!document.hidden)await refresh();timer=setTimeout(tick,document.hidden?20000:5000);}
|
||
el('pause').onclick=()=>{paused=!paused;el('pause').textContent=paused?'Продолжить':'Пауза';el('connection').lastElementChild.textContent=paused?'Пауза':'На связи';if(!paused)tick();};el('refresh').onclick=()=>refresh();el('level').onchange=renderEvents;el('search').oninput=renderEvents;document.addEventListener('visibilitychange',()=>{if(!document.hidden&&!paused)tick();});el('download').onclick=()=>{const text=events.map(e=>`${e.ts} ${e.level} ${e.message}`).join('\n');const url=URL.createObjectURL(new Blob([text],{type:'text/plain;charset=utf-8'}));const link=document.createElement('a');link.href=url;link.download=`rfb-${state?.hostname??'server'}-${new Date().toISOString().slice(0,10)}.log`;link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};el('telegram-test').onclick=async()=>{el('telegram-test').disabled=true;try{const response=await fetch('/logs/telegram-test',{method:'POST'}),payload=await response.json();el('tg-test-result').textContent=payload.message??payload.detail??`HTTP ${response.status}`;}catch(e){el('tg-test-result').textContent='Не удалось отправить запрос';}finally{el('telegram-test').disabled=false;refresh();}};tick();
|
||
</script></body></html>
|