48 lines
19 KiB
HTML
48 lines
19 KiB
HTML
<!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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|