Files
ProxmoxDash/assets/traffic.html

48 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Карта трафика · Proxmox Dashboard</title>
<style>
:root{color-scheme:dark;--bg:#070a13;--panel:#101525;--line:#222a40;--text:#eef2ff;--muted:#8490ab;--blue:#55c9ff;--orange:#ffb05f;--pink:#fa79b7}*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 48% 40%,#10172a 0,transparent 62%),var(--bg);color:var(--text);font:14px/1.5 system-ui,sans-serif}a{color:var(--blue);text-decoration:none}button,select{font:inherit;color:var(--text);background:#141b2d;border:1px solid #303c59;border-radius:9px;padding:9px 12px}button{cursor:pointer}button:hover,select:hover{border-color:var(--blue)}button.active{background:#18334c;border-color:#55c9ff}header{padding:28px 32px 14px;display:flex;justify-content:space-between;align-items:center;gap:20px}.eyebrow{color:var(--blue);font-size:10px;letter-spacing:.2em;font-weight:700}h1{font-size:29px;letter-spacing:-.04em;margin:5px 0}header nav{display:flex;gap:15px;align-items:center}.live{font-size:11px;padding:5px 10px;border:1px solid #294c4a;border-radius:30px;color:#73ddb2}.live.stale{color:var(--orange);border-color:#6a4a2a}.toolbar{padding:12px 32px;display:flex;align-items:center;flex-wrap:wrap;gap:8px}.toolbar select{max-width:270px}.toolbar label{margin-left:8px;color:var(--muted)}.dashboard{display:grid;grid-template-columns:205px minmax(0,1fr) 330px;gap:18px;margin:12px 32px}.panel{background:#101525d9;border:1px solid var(--line);border-radius:16px;padding:18px;min-width:0}.panel h2{font-size:14px;font-weight:550;margin:0 0 16px}.metric{padding:17px 0;border-bottom:1px solid var(--line)}.metric:first-child{padding-top:0}.metric:last-child{border:0}.metric small{color:var(--muted);display:block;font-size:11px}.metric b{display:block;font-size:32px;letter-spacing:-.04em;margin:4px 0}.metric.request b{color:var(--blue)}.metric.alert b{color:var(--orange)}.metric.decision b{color:var(--pink)}.aside{display:flex;flex-direction:column;gap:16px}.ranking{display:flex;justify-content:space-between;gap:8px;padding:7px 0;color:#bac7de;font-size:12px}.ranking span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ranking b{font:12px ui-monospace,monospace;color:var(--blue)}.map-panel{position:relative;min-height:490px;display:flex;align-items:center;flex-direction:column;justify-content:center}.map-panel canvas{display:block;width:100%;aspect-ratio:1.85;touch-action:pan-y}.map-heading{position:absolute;top:8px;left:16px;right:16px;display:flex;justify-content:space-between;gap:12px;color:var(--muted);font-size:11px}.map-foot{width:100%;padding:16px;color:var(--muted);font-size:11px;display:flex;justify-content:space-between;gap:12px}.legend{display:flex;flex-wrap:wrap;gap:14px}.dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--blue);margin-right:6px}.dot.alert{background:var(--orange)}.dot.decision{background:var(--pink)}.feed{max-height:670px;overflow:auto}.event{padding:14px 0;border-top:1px solid var(--line)}.event-head{display:flex;justify-content:space-between;gap:9px;align-items:baseline}.event-head b{font:12px ui-monospace,monospace}.event-head time{font-size:10px;color:var(--muted)}.event .meta{color:#b0bdd4;font-size:11px;margin-top:5px;overflow-wrap:anywhere}.event .path{font:11px/1.6 ui-monospace,monospace;color:var(--muted);margin-top:5px;overflow-wrap:anywhere}.kind{font-size:10px;color:var(--blue)}.kind.alert{color:var(--orange)}.kind.decision{color:var(--pink)}.empty{color:var(--muted);font-size:13px;padding:24px 0}.bottom{margin:18px 32px;display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:18px}.chart{width:100%;height:100px;display:block}.note{font-size:11px;color:var(--muted);overflow-wrap:anywhere}.status-box{margin:0 32px;padding:10px 0;color:var(--muted);font-size:12px}.status-box.error{color:var(--orange)}footer{margin:25px 32px;font-size:11px;color:var(--muted);display:flex;gap:18px;flex-wrap:wrap}.tooltip{position:fixed;pointer-events:none;display:none;padding:10px 14px;border:1px solid #475578;border-radius:9px;background:#101725f5;font-size:12px;z-index:3}.now{font:13px ui-monospace,monospace;color:var(--muted)}@media(min-width:1700px){.dashboard{grid-template-columns:230px minmax(0,1fr) 360px}.map-panel{min-height:680px}.feed{max-height:780px}}@media(max-width:1100px){.dashboard{grid-template-columns:165px minmax(0,1fr)}.feed-panel{grid-column:1/-1}.feed{max-height:330px}.map-panel{min-height:380px}.bottom{grid-template-columns:1fr}}@media(max-width:650px){header{padding:20px 16px;align-items:flex-start;flex-direction:column}h1{font-size:25px}.toolbar{padding:0 16px}.dashboard{display:flex;flex-direction:column;margin:18px 16px}.aside{display:contents}.metrics{display:grid;grid-template-columns:1fr 1fr;gap:12px}.metric,.metric:first-child{padding:0;border:0}.metric b{font-size:27px}.rank-panel{order:3}.map-panel{min-height:260px;order:1}.feed-panel{order:2}.bottom,footer{margin:16px}.status-box{margin:0 16px}.map-heading{position:static;padding:0 0 10px}.map-foot{padding:8px 0;font-size:10px}.legend{gap:8px}.now{display:none}}
</style></head><body>
<header><div><div class="eyebrow">PROXMOX DASHBOARD / TRAFFIC OBSERVATORY</div><h1>Мир обращается к вашим сервисам</h1><span class="note">HTTP-запросы и события CrowdSec · последние 24 часа</span></div><nav><span id="live" class="live stale">Подключение…</span><span class="now" id="clock"></span><a href="/">← Dashboard</a><button id="fullscreen" aria-label="Полный экран">⛶</button></nav></header>
<div class="toolbar"><button class="active" data-kind="">Весь трафик</button><button data-kind="request">HTTP-запросы</button><button data-kind="alert">Срабатывания</button><button data-kind="decision">Решения CrowdSec</button><select id="domain" aria-label="Фильтр по домену"><option value="">Все домены</option></select><label><input type="checkbox" id="external"> Только внешние IP</label><button id="pause">Пауза</button></div>
<div class="status-box" id="status" role="status">Получаем данные выбранного агента…</div>
<section class="dashboard">
<aside class="aside"><div class="panel metrics"><div class="metric"><small>УНИКАЛЬНЫХ IP · HTTP</small><b id="unique">—</b></div><div class="metric request"><small>HTTP-ЗАПРОСОВ</small><b id="requests">—</b></div><div class="metric alert"><small>СРАБАТЫВАНИЙ CROWDSEC</small><b id="alerts">—</b></div><div class="metric decision"><small>РЕШЕНИЙ CROWDSEC</small><b id="decisions">—</b></div></div><div class="panel rank-panel"><h2>Страны · события</h2><div id="countries"></div></div></aside>
<div class="map-panel"><div class="map-heading"><span>ИСТОЧНИКИ ЗАПРОСОВ</span><span id="sourceName">—</span></div><canvas id="world" role="img" aria-label="Карта стран источников; те же данные доступны в списке стран и ленте событий"></canvas><div class="map-foot"><div class="legend"><span><i class="dot"></i>Запрос</span><span><i class="dot alert"></i>Срабатывание</span><span><i class="dot decision"></i>Решение</span></div><span id="unmapped">Без геолокации: —</span></div><div class="note">Точки обозначают страны, а не точное местоположение людей.</div></div>
<aside class="panel feed-panel"><h2>Лента событий <span class="note">· последние 100</span></h2><div class="feed" id="feed"><div class="empty">Ожидание первого события…</div></div></aside>
</section>
<section class="bottom"><div class="panel"><div style="display:flex;justify-content:space-between"><h2>HTTP-запросы в минуту · последний час</h2><span class="note"><b id="qps">—</b> req/s · среднее за 60 сек.</span></div><canvas class="chart" id="chart" role="img" aria-label="Количество HTTP-запросов за каждую минуту последнего часа"></canvas></div><div class="panel"><h2>IP · события</h2><div id="ips"></div></div></section>
<footer><span>Решение о бане ≠ подтверждённый заблокированный запрос. HTTP 401/403/502 не означают блокировку CrowdSec.</span><span>GeoIP: <a href="https://db-ip.com" target="_blank" rel="noopener">IP Geolocation by DB-IP</a> · <span id="geoDate"></span> · <a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a></span><span>Карта: <a href="https://www.naturalearthdata.com/">Natural Earth</a></span><a href="/#traffic-settings">Настройки источника</a></footer>
<div id="tooltip" class="tooltip"></div>
<script>
const el=id=>document.getElementById(id),safe=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])),num=n=>Number(n||0).toLocaleString('ru-RU');
let world=[],countryMap=new Map(),view=null,kind='',paused=false,busy=false,pulses=[],seen=new Set(),hitPoints=[],baseMap=null,lastPoll=0;
const colors={request:'#55c9ff',alert:'#ffb05f',decision:'#fa79b7'},kindLabels={request:'HTTP-запрос',alert:'Срабатывание',decision:'Решение CrowdSec'};
const countryNames=new Intl.DisplayNames(['ru'],{type:'region'});const countryName=code=>code==='LAN'?'Локальная сеть':countryMap.get(code)?.name||(code&&/^[A-Z]{2}$/.test(code)?countryNames.of(code):'Не определена');
function project(lon,lat,w,h){return [(lon+180)/360*w,(85-lat)/170*h]}
function mapBackground(w,h){const off=document.createElement('canvas');off.width=w;off.height=h;const ctx=off.getContext('2d'),mask=document.createElement('canvas');mask.width=w;mask.height=h;const m=mask.getContext('2d');m.fillStyle='#fff';world.forEach(c=>{const polygons=c.geometry.type==='Polygon'?[c.geometry.coordinates]:c.geometry.coordinates;polygons.forEach(poly=>{m.beginPath();poly.forEach(ring=>{ring.forEach(([lon,lat],i)=>{const [x,y]=project(lon,lat,w,h);if(i===0)m.moveTo(x,y);else m.lineTo(x,y)});m.closePath()});m.fill('evenodd')})});const pixels=m.getImageData(0,0,w,h).data;const step=Math.max(4,Math.round(w/210));ctx.fillStyle='#2a3355';for(let y=0,row=0;y<h;y+=step,row++){for(let x=(row%2)*step/2;x<w;x+=step){if(pixels[(Math.floor(y)*w+Math.floor(x))*4+3]){ctx.beginPath();ctx.arc(x,y,step*.32,0,Math.PI*2);ctx.fill()}}}return off}
function drawMap(now){const canvas=el('world'),w=Math.max(1,Math.floor(canvas.clientWidth)),h=Math.round(w/1.85),dpr=Math.min(devicePixelRatio||1,2);if(canvas.width!==Math.round(w*dpr)||canvas.height!==Math.round(h*dpr)){canvas.width=Math.round(w*dpr);canvas.height=Math.round(h*dpr);baseMap=mapBackground(w,h)}const ctx=canvas.getContext('2d');ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,w,h);if(baseMap)ctx.drawImage(baseMap,0,0);hitPoints=[];
if(view){const destination=countryMap.get(view.settings.serverCountry),end=destination?project(destination.lon,destination.lat,w,h):[w*.82,h*.88];
for(const c of view.countries){const country=countryMap.get(c.name);if(!country)continue;const [x,y]=project(country.lon,country.lat,w,h);ctx.fillStyle='#55c9ff';ctx.globalAlpha=.75;ctx.beginPath();ctx.arc(x,y,2+Math.min(5,Math.log2(c.count+1)),0,Math.PI*2);ctx.fill();hitPoints.push({x,y,text:country.name+' · '+num(c.count)+' событий'})}ctx.globalAlpha=1;
pulses=pulses.filter(p=>now-p.start<3200);for(const p of pulses){const c=countryMap.get(p.country);if(!c)continue;const start=project(c.lon,c.lat,w,h),t=Math.min(1,(now-p.start)/2400),control=[(start[0]+end[0])/2,(start[1]+end[1])/2-Math.min(h*.3,Math.abs(start[0]-end[0])*.3+30)],fade=Math.min(1,(3200-(now-p.start))/700);ctx.strokeStyle=colors[p.kind];ctx.globalAlpha=.25*fade;ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(...start);ctx.quadraticCurveTo(...control,...end);ctx.stroke();const x=(1-t)**2*start[0]+2*(1-t)*t*control[0]+t*t*end[0],y=(1-t)**2*start[1]+2*(1-t)*t*control[1]+t*t*end[1];ctx.fillStyle=colors[p.kind];ctx.globalAlpha=fade;ctx.shadowColor=colors[p.kind];ctx.shadowBlur=12;ctx.beginPath();ctx.arc(x,y,2.5,0,Math.PI*2);ctx.fill();ctx.shadowBlur=0}
ctx.globalAlpha=1;ctx.strokeStyle='#c8b5ff';ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(...end,7,0,Math.PI*2);ctx.stroke();ctx.fillStyle='#e7dcff';ctx.beginPath();ctx.arc(...end,3,0,Math.PI*2);ctx.fill();ctx.font='11px system-ui';ctx.textAlign='center';ctx.fillText(destination?'NPM · '+destination.name:'NPM · геопозиция не задана',end[0],end[1]+22);
}
if(!document.hidden&&!paused)requestAnimationFrame(drawMap)
}
function ranking(id,items,label){el(id).innerHTML=items.length?items.slice(0,7).map(c=>'<div class="ranking"><span>'+safe(label(c.name))+'</span><b>'+num(c.count)+'</b></div>').join(''):'<div class="note">Пока нет событий</div>'}
function drawChart(){const c=el('chart'),w=c.clientWidth,h=100,dpr=devicePixelRatio||1;c.width=w*dpr;c.height=h*dpr;const ctx=c.getContext('2d');ctx.scale(dpr,dpr);if(!view)return;const buckets=new Map(view.series.map(v=>[Number(v.name),v.count])),end=Math.floor(view.now/60)*60,values=Array.from({length:60},(_,i)=>buckets.get(end-(59-i)*60)||0),max=Math.max(1,...values);ctx.strokeStyle='#26314b';ctx.beginPath();ctx.moveTo(0,82);ctx.lineTo(w,82);ctx.stroke();ctx.fillStyle='#55c9ff';values.forEach((v,i)=>{const x=i*w/60,height=v/max*65;ctx.fillRect(x,82-height,Math.max(1,w/60-3),height)});ctx.fillStyle='#8490ab';ctx.font='10px system-ui';ctx.fillText('−60 мин',0,98);ctx.textAlign='right';ctx.fillText('сейчас',w,98);ctx.fillText('макс. '+num(max)+'/мин',w,12)}
function render(data){view=data;el('unique').textContent=num(data.uniqueIps);el('requests').textContent=num(data.requests);el('alerts').textContent=num(data.alerts);el('decisions').textContent=num(data.decisions);el('qps').textContent=Number(data.qps).toFixed(2);el('geoDate').textContent=data.geoDate;ranking('countries',data.countries,countryName);ranking('ips',data.ips,v=>v);
const src=data.sources[0],fresh=src&&data.now-src.lastSeen<20;el('live').textContent=paused?'ПАУЗА':fresh?'● В ЭФИРЕ':'ОЖИДАНИЕ АГЕНТА';el('live').classList.toggle('stale',!fresh||paused);el('sourceName').textContent=src?src.name:'Источник ещё не прислал данные';
const messages=[];if(!src)messages.push('Выбранный агент ещё не прислал данные. Нужен агент v0.20.0 или новее.');else{messages.push(src.name+' · '+src.status.files+' логов · сбор с '+new Date(src.status.startedAt*1000).toLocaleString('ru-RU'));if(!fresh)messages.push('Источник не обновляется; показана сохранённая история');if(src.status.logError)messages.push(src.status.logError);if(src.status.crowdsecError)messages.push('CrowdSec: '+src.status.crowdsecError);if(src.status.backlogBytes)messages.push('Ожидает чтения: '+num(src.status.backlogBytes)+' байт');if(src.status.crowdsecLimited)messages.push('CrowdSec: получены последние 50 срабатываний; более ранняя история может быть неполной');if(src.status.parseErrors)messages.push('Нераспознанных строк: '+src.status.parseErrors)}if(!data.geoReady)messages.push('База GeoIP недоступна');if(data.limited)messages.push('Достигнут лимит 200 000 событий: часть суточной истории удалена');el('status').textContent=messages.join(' · ');
el('status').classList.toggle('error',!fresh||!!src?.status.logError||!!src?.status.crowdsecError||data.limited);
const current=el('domain').value;if(!current&&!kind){el('domain').innerHTML='<option value="">Все домены</option>'+data.domains.filter(c=>c.name).map(c=>'<option value="'+safe(c.name)+'">'+safe(c.name)+'</option>').join('')}
const unmapped=data.countries.filter(c=>!countryMap.has(c.name)).reduce((n,c)=>n+c.count,0);el('unmapped').textContent='Локальные / без геолокации: '+num(unmapped);
el('feed').innerHTML=data.events.length?data.events.map(e=>'<article class="event"><div class="event-head"><b>'+safe(e.ip)+'</b><time>'+new Date(e.time*1000).toLocaleTimeString('ru-RU')+'</time></div><div class="meta">'+safe(countryName(e.country))+' · <span class="kind '+safe(e.kind)+'">'+kindLabels[e.kind]+'</span></div><div class="path">'+safe(e.kind==='request'?e.method+' '+e.domain+e.path+' · HTTP '+e.status:(e.domain?e.domain+' · ':'')+e.reason)+'</div></article>').join(''):'<div class="empty">Нет событий по выбранному фильтру. Новые HTTP-запросы появятся после записи в access-лог.</div>';
for(const e of data.events){if(!seen.has(e.sequence)&&e.time>=data.now-15&&pulses.length<70)pulses.push({country:e.country,kind:e.kind,start:performance.now()});seen.add(e.sequence)}if(seen.size>5000)seen=new Set(data.events.map(e=>e.sequence));drawChart();
}
async function poll(){if(paused||busy||document.hidden)return;busy=true;try{const q=new URLSearchParams({domain:el('domain').value,kind,external:el('external').checked?'1':'0'}),r=await fetch('/api/traffic?'+q);if(!r.ok){if(r.status===404){view=null;pulses=[];location.replace('/');return;}throw Error(r.status===404?'Карта отключена':'Не удалось обновить данные')}render(await r.json());lastPoll=Date.now()}catch(e){el('live').textContent='НЕТ ОБНОВЛЕНИЙ';el('live').classList.add('stale');el('status').textContent=e.message;el('status').classList.add('error')}finally{busy=false}}
document.querySelectorAll('[data-kind]').forEach(b=>b.onclick=()=>{kind=b.dataset.kind;document.querySelectorAll('[data-kind]').forEach(x=>x.classList.toggle('active',x===b));pulses=[];poll()});el('domain').onchange=()=>{pulses=[];poll()};el('external').onchange=()=>{pulses=[];poll()};el('pause').onclick=()=>{paused=!paused;el('pause').textContent=paused?'Продолжить':'Пауза';el('live').textContent=paused?'ПАУЗА':'Подключение…';if(!paused){poll();requestAnimationFrame(drawMap)}};el('fullscreen').onclick=()=>{if(document.fullscreenElement)document.exitFullscreen();else document.documentElement.requestFullscreen().catch(()=>{})};window.addEventListener('resize',drawChart);document.addEventListener('visibilitychange',()=>{if(!document.hidden&&!paused){poll();requestAnimationFrame(drawMap)}});
el('world').addEventListener('pointermove',event=>{const rect=event.currentTarget.getBoundingClientRect(),x=event.clientX-rect.left,y=event.clientY-rect.top,point=hitPoints.find(p=>Math.hypot(x-p.x,y-p.y)<14),tip=el('tooltip');tip.style.display=point?'block':'none';if(point){tip.textContent=point.text;tip.style.left=Math.min(innerWidth-240,event.clientX+12)+'px';tip.style.top=event.clientY+15+'px'}});el('world').addEventListener('pointerleave',()=>el('tooltip').style.display='none');
(async()=>{try{const r=await fetch('/api/traffic/world');if(!r.ok)throw Error();world=await r.json();countryMap=new Map(world.map(c=>[c.code,c]));requestAnimationFrame(drawMap);await poll();setInterval(poll,2000)}catch(e){el('status').textContent='Не удалось загрузить карту. Обновите страницу.'}})();setInterval(()=>{el('clock').textContent=new Date().toLocaleTimeString('ru-RU')},1000);
</script></body></html>