Plot city-level traffic activity from local GeoIP coordinates

This commit is contained in:
Maxim
2026-10-15 20:48:04 +03:00
parent 03b6c1f289
commit 46bd6e8be6
6 changed files with 192 additions and 26 deletions

View File

@@ -32,7 +32,7 @@ html[data-theme="light"] dialog,html[data-theme="light"] .tooltip,html[data-them
<div class="toolbar"><div class="segments"><button class="active" data-kind="">Весь трафик</button><button data-kind="request">HTTP</button><button data-kind="alert">Срабатывания</button><button data-kind="decision">Решения</button></div><select id="domain" aria-label="Фильтр по домену"><option value="">Все домены</option></select><a id="networkLink" href="/network" style="font-size:10px;white-space:nowrap" hidden>TCP / UDP ↗</a><label id="externalControl"><input type="checkbox" id="external"> Только внешние IP</label><label id="monitoringControl"><input type="checkbox" id="hideMonitoring"> Скрыть мониторинг</label><label id="networkOverlayControl" hidden><input type="checkbox" id="networkOverlay" checked> Сеть на карте</label><button id="pause">Пауза</button></div>
<section class="metrics" aria-label="Статистика за последние 24 часа"><button class="metric" data-metric="unique"><div><small>Уникальные IP · 24 ч</small><b id="unique">—</b></div><i aria-hidden="true">⌁</i></button><button class="metric request" data-metric="requests"><div><small>HTTP-запросы · 24 ч</small><b id="requests">—</b></div><i aria-hidden="true">↗</i></button><button class="metric alert" data-metric="alerts"><div><small>Срабатывания · 24 ч</small><b id="alerts">—</b></div><i aria-hidden="true">ϟ</i></button><button class="metric decision" data-metric="decisions"><div><small>Решения CrowdSec · 24 ч</small><b id="decisions">—</b></div><i aria-hidden="true">◇</i></button></section>
<nav id="mobileTabs" aria-label="Раздел карты"><button class="active" data-mobile-tab="map">Карта</button><button data-mobile-tab="feed">События</button><button data-mobile-tab="stats">Статистика</button></nav>
<section class="dashboard"><div class="panel map-panel"><div class="map-heading"><span class="section-label">Глобальная активность</span><button class="source-status" id="sourceDetails" aria-label="Состояние источника"><span id="sourceName">Источник…</span><span id="status" class="status-box" role="status">Подключение</span><span>ⓘ</span></button></div><div class="map-stage"><button id="networkSummary" hidden aria-label="Сетевая активность на карте"></button><div class="map-controls"><button id="zoomOut" aria-label="Отдалить карту">−</button><output id="zoomLevel">100%</output><button id="zoomIn" aria-label="Приблизить карту">+</button><button id="zoomReset" aria-label="Сбросить масштаб карты">↺</button></div><canvas tabindex="0" id="world" role="img" aria-label="Карта стран источников; данные доступны также в статистике и ленте событий"></canvas></div><div class="map-foot"><div class="legend"><span><i class="dot"></i>HTTP</span><span><i class="dot alert"></i>Срабатывание</span><span><i class="dot decision"></i>Решение</span></div><span id="unmapped">Без геолокации: —</span></div></div>
<section class="dashboard"><div class="panel map-panel"><div class="map-heading"><span class="section-label">Глобальная активность</span><button class="source-status" id="sourceDetails" aria-label="Состояние источника"><span id="sourceName">Источник…</span><span id="status" class="status-box" role="status">Подключение</span><span>ⓘ</span></button></div><div class="map-stage"><button id="networkSummary" hidden aria-label="Сетевая активность на карте"></button><div class="map-controls"><button id="zoomOut" aria-label="Отдалить карту">−</button><output id="zoomLevel">100%</output><button id="zoomIn" aria-label="Приблизить карту">+</button><button id="zoomReset" aria-label="Сбросить масштаб карты">↺</button></div><canvas tabindex="0" id="world" role="img" aria-label="Карта стран источников; данные доступны также в статистике и ленте событий"></canvas></div><div class="map-foot"><div class="legend"><span><i class="dot"></i>HTTP</span><span><i class="dot alert"></i>Срабатывание</span><span><i class="dot decision"></i>Решение</span></div><span id="cityLayerNote" title="Города по приблизительным координатам GeoIP"></span><span id="unmapped">Без геолокации: —</span></div></div>
<aside class="panel feed-panel"><div class="feed-heading"><h2>Поток событий</h2><span class="note" id="feedMode">LIVE / 100</span></div><div class="feed" id="feed"><div class="empty">Ожидание первого события…</div></div><div class="pager"><button id="feedPrev" aria-label="Предыдущая страница событий">←</button><span id="feedPage">—</span><button id="feedNext" aria-label="Следующая страница событий">→</button><button id="latest">К новым ↗</button></div></aside></section>
<section class="bottom"><div class="panel chart-panel"><div class="bottom-head"><h2>Ритм запросов <span class="note">/ 60 мин</span></h2><span class="note"><b id="qps" style="color:var(--blue)">—</b> req/s</span></div><canvas class="chart" id="chart" role="img" aria-label="HTTP-запросы в минуту за последний час; req/s — среднее за последние 60 секунд"></canvas></div><div class="panel"><div class="bottom-head"><h2>География</h2><span class="note">ТОП 7 / события</span></div><div id="countries"></div></div><div class="panel"><div class="bottom-head"><h2>Источники</h2><span class="note">ТОП 7 / IP</span></div><div id="ips"></div></div></section>
<footer><div class="credits"><span><a href="https://db-ip.com" target="_blank" rel="noopener">IP Geolocation by DB-IP</a> · <span id="geoDate"></span></span><a class="license-credit" href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a><a class="natural-credit" href="https://www.naturalearthdata.com/">Natural Earth</a></div><button class="footer-help" id="about">Как читать данные ⓘ</button><a href="/#traffic-settings">Настройки ↗</a></footer>
@@ -52,9 +52,11 @@ function networkVisible(){return !!view?.network&&el('networkOverlay').checked&&
function networkFresh(){return networkVisible()&&view.network.receivedAt>0&&view.now+(Date.now()-lastPoll)/1000-view.network.receivedAt<25}
function drawMap(now){const canvas=el('world'),w=Math.max(1,Math.floor(canvas.clientWidth)),h=Math.max(1,Math.floor(canvas.clientHeight)),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);const cacheKey=[w,h,dpr,mapZoom,mapX,mapY,isLight(),world.length].join(':');if(!baseMap||baseMapKey!==cacheKey){baseMap=mapBackground(w,h);baseMapKey=cacheKey}ctx.drawImage(baseMap,0,0,w,h);ctx.save();ctx.translate(mapX,mapY);ctx.scale(mapZoom,mapZoom);hitPoints=[];drawCountryHighlight(ctx,w,h);
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=colors.request;ctx.globalAlpha=.75;ctx.beginPath();ctx.arc(x,y,(2+Math.min(5,Math.log2(c.count+1)))/mapZoom,0,Math.PI*2);ctx.fill();hitPoints.push({x,y,text:country.name+' · '+num(c.count)+' событий'})}ctx.globalAlpha=1;
const cityLayer=mapZoom>=2&&view.cityReady,cityCounts=new Map();el("cityLayerNote").textContent=view.cityReady?(cityLayer?"Города: "+(view.cities||[]).length+(view.citiesLimited?" (топ 500)":""):"Города — от 200%") : "Города: нужна GeoIP-база";
if(cityLayer){const labels=[];for(const c of view.cities||[]){const total=c.requests+c.alerts+c.decisions;cityCounts.set(c.country,(cityCounts.get(c.country)||0)+total);const [x,y]=project(c.longitude,c.latitude,w,h),sx=x*mapZoom+mapX,sy=y*mapZoom+mapY;if(sx<0||sx>w||sy<0||sy>h)continue;ctx.fillStyle=c.requests?colors.request:c.alerts?colors.alert:colors.decision;ctx.beginPath();ctx.arc(x,y,(3+Math.min(3,Math.log2(total+1)/2))/mapZoom,0,Math.PI*2);ctx.fill();hitPoints.push({x,y,city:c});ctx.font=(11/mapZoom)+'px system-ui';ctx.textAlign='left';const width=ctx.measureText(c.city).width*mapZoom;if(!labels.some(p=>Math.abs(sx-p.x)<Math.max(width,p.width)+12&&Math.abs(sy-p.y)<18)){ctx.fillStyle=isLight()?'#203d4c':'#e1edf4';ctx.fillText(c.city,x+9/mapZoom,y-5/mapZoom);labels.push({x:sx,y:sy,width})}}}
for(const c of view.countries){const country=countryMap.get(c.name),count=Math.max(0,c.count-(cityCounts.get(c.name)||0));if(!country||!count)continue;const [x,y]=project(country.lon,country.lat,w,h);ctx.fillStyle=colors.request;ctx.globalAlpha=.75;ctx.beginPath();ctx.arc(x,y,(2+Math.min(5,Math.log2(count+1)))/mapZoom,0,Math.PI*2);ctx.fill();hitPoints.push({x,y,text:country.name+' · '+num(count)+' событий'})}ctx.globalAlpha=1;
if(networkFresh()){for(const p of view.network.points){const c=countryMap.get(p.country);if(!c)continue;const [x,y]=project(c.lon,c.lat,w,h),radius=(8+['tcp','udp','icmp'].indexOf(p.protocol)*3)/mapZoom;ctx.strokeStyle=colors[p.protocol];ctx.lineWidth=1.4/mapZoom;ctx.globalAlpha=.8;ctx.beginPath();ctx.arc(x,y,radius,0,Math.PI*2);ctx.stroke();const text=p.protocol.toUpperCase()+' '+(p.direction==='out'?'← исходящие':'→ входящие')+': '+num(p.count)+' потоков';const existing=hitPoints.find(h=>Math.hypot(h.x-x,h.y-y)<1);if(existing)existing.text+=' · '+text;else hitPoints.push({x,y,text:c.name+' · '+text})}ctx.globalAlpha=1}
pulses=pulses.filter(p=>now-p.start<3200&&(!p.network||networkFresh()));if(matchMedia('(prefers-reduced-motion: reduce)').matches)pulses=[];for(const p of pulses){const c=countryMap.get(p.country);if(!c)continue;const remote=project(c.lon,c.lat,w,h),start=p.direction==='out'?end:remote,finish=p.direction==='out'?remote:end,t=Math.min(1,(now-p.start)/2400),control=[(start[0]+finish[0])/2,(start[1]+finish[1])/2-Math.min(h*.3,Math.abs(start[0]-finish[0])*.3+30)],fade=Math.min(1,(3200-(now-p.start))/700);ctx.strokeStyle=colors[p.kind];ctx.globalAlpha=.4*fade;ctx.lineWidth=1/mapZoom;ctx.beginPath();ctx.moveTo(...start);ctx.quadraticCurveTo(...control,...finish);ctx.stroke();const x=(1-t)**2*start[0]+2*(1-t)*t*control[0]+t*t*finish[0],y=(1-t)**2*start[1]+2*(1-t)*t*control[1]+t*t*finish[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/mapZoom,0,Math.PI*2);ctx.fill();ctx.shadowBlur=0}
pulses=pulses.filter(p=>now-p.start<3200&&(!p.network||networkFresh()));if(matchMedia('(prefers-reduced-motion: reduce)').matches)pulses=[];for(const p of pulses){const c=countryMap.get(p.country);if(!c)continue;const remote=cityLayer&&p.location?.located?project(p.location.longitude,p.location.latitude,w,h):project(c.lon,c.lat,w,h),start=p.direction==='out'?end:remote,finish=p.direction==='out'?remote:end,t=Math.min(1,(now-p.start)/2400),control=[(start[0]+finish[0])/2,(start[1]+finish[1])/2-Math.min(h*.3,Math.abs(start[0]-finish[0])*.3+30)],fade=Math.min(1,(3200-(now-p.start))/700);ctx.strokeStyle=colors[p.kind];ctx.globalAlpha=.4*fade;ctx.lineWidth=1/mapZoom;ctx.beginPath();ctx.moveTo(...start);ctx.quadraticCurveTo(...control,...finish);ctx.stroke();const x=(1-t)**2*start[0]+2*(1-t)*t*control[0]+t*t*finish[0],y=(1-t)**2*start[1]+2*(1-t)*t*control[1]+t*t*finish[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/mapZoom,0,Math.PI*2);ctx.fill();ctx.shadowBlur=0}
ctx.globalAlpha=1;ctx.strokeStyle=colors.decision;ctx.lineWidth=1.5/mapZoom;ctx.beginPath();ctx.arc(...end,7/mapZoom,0,Math.PI*2);ctx.stroke();ctx.fillStyle=isLight()?'#554496':'#e7dcff';ctx.beginPath();ctx.arc(...end,3/mapZoom,0,Math.PI*2);ctx.fill();ctx.font=(11/mapZoom)+'px system-ui';ctx.textAlign='center';ctx.fillText(destination?'NPM · '+destination.name:'NPM · геопозиция не задана',end[0],end[1]+22/mapZoom);
}
ctx.restore();
@@ -70,7 +72,7 @@ function render(data){view=data;el('networkLink').hidden=!data.settings.networkE
const unmapped=data.countries.filter(c=>!countryMap.has(c.name)).reduce((n,c)=>n+c.count,0);el('unmapped').textContent='Локальные / без геолокации: '+num(unmapped);
renderFeed();
if(networkFresh()&&data.network.receivedAt!==lastNetworkSnapshot){lastNetworkSnapshot=data.network.receivedAt;for(const p of data.network.points.slice(0,24)){pulses.push({country:p.country,kind:p.protocol,direction:p.direction,network:true,start:performance.now()})}}
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();if(paused)requestAnimationFrame(drawMap);
for(const e of data.events){if(!seen.has(e.sequence)&&e.time>=data.now-15&&pulses.length<70)pulses.push({country:e.country,location:e.location,kind:e.kind,start:performance.now()});seen.add(e.sequence)}if(seen.size>5000)seen=new Set(data.events.map(e=>e.sequence));drawChart();if(paused)requestAnimationFrame(drawMap);
}
function renderNetworkSummary(){el('networkSummary').hidden=!networkVisible();if(!networkVisible())return;const n=view.network,fresh=networkFresh();el('networkSummary').innerHTML=(fresh?'Сеть · ':'Сеть · нет свежего снимка · ')+['tcp','udp','icmp'].map(p=>'<span style="color:'+colors[p]+'">'+p.toUpperCase()+' '+num(n[p])+'</span>').join(' · ')+(n.error||n.limited?' · ⚠':'')+' ⓘ';el('networkSummary').title='Текущие потоки conntrack. Фильтры домена и HTTP/CrowdSec на них не влияют.'}
el('networkSummary').onclick=()=>{const n=view.network;showDetails('Сетевая активность на карте','<p>Зелёный — TCP, розовый — UDP, жёлтый — ICMP/ICMPv6. Кольца показывают страны текущих потоков, импульс означает получение нового снимка (~5 с), а не новый запрос или пакет.</p><p>Входящий поток идёт к NPM, исходящий — от NPM. Если оба конца публичные, направление определяется по инициатору; это не трассировка маршрута.</p><p>HTTP-фильтры домена и типа события относятся только к HTTP/CrowdSec. «Только внешние IP» и «Скрыть мониторинг» применяются также к сетевому слою. Для сети используются только правила без домена/пути.</p><p>Снимок: '+(n.at?new Date(n.at*1000).toLocaleString('ru-RU'):'ещё не получен')+'. Локальные / без страны: '+num(n.unmapped)+'. Скрыто потоков мониторинга: '+num(n.monitoringHidden)+'.</p><p>'+safe(n.error||(n.limited?'Достигнут лимит 5 000 потоков':'При устаревании снимка сетевые кольца и импульсы скрываются.'))+'</p><p><a href="/network">Открыть таблицу TCP/UDP/ICMP ↗</a></p>')};
@@ -84,7 +86,7 @@ el('feedNext').onclick=()=>{if(!frozenEvents)frozenEvents=view.events.slice();fe
function openEvent(target){const row=target.closest('[data-event]');if(!row)return;const e=feedVisible[Number(row.dataset.event)];if(!e)return;const fields=[['Источник',[e.ip,countryName(e.country),e.region,e.city].filter(Boolean).join(' · ')],['Время',new Date(e.time*1000).toLocaleString('ru-RU')],['Тип',kindLabels[e.kind]],['Мониторинг',e.monitoring?'Да — совпало с правилом':'Нет'],['Домен',e.domain||'Не указан'],['Детали',eventText(e)]];showDetails('Событие трафика',fields.map(([k,v])=>'<div class="detail-row"><small>'+safe(k)+'</small>'+safe(v)+'</div>').join(''))}
el('feed').onclick=e=>openEvent(e.target);el('feed').onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();openEvent(e.target)}};
el('sourceDetails').onclick=()=>showDetails('Состояние источника',statusMessages.map(m=>'<p>'+safe(m)+'</p>').join(''));
el('about').onclick=()=>showDetails('Как читать данные','<p>Голубой — HTTP-запросы, оранжевый — срабатывания CrowdSec, сиреневый — решения о ban или captcha.</p><p>Решение о бане не подтверждает конкретный отброшенный запрос. HTTP 401/403/502 сами по себе не означают блокировку CrowdSec.</p><p>Счётчики за 24 часа следуют выбранным фильтрам. Уникальный IP не равен человеку. Req/s — среднее за 60 секунд; график показывает запросы в минуту за последний час.</p><p>Точки на карте обозначают страны, а не точное местоположение людей. Локальные адреса остаются в ленте без страны. «Скрыть мониторинг» исключает из отображения HTTP-проверки по правилам в настройках; события CrowdSec сохраняются на экране. Исходная история не удаляется. В ленте показаны последние 100 событий; полная сохранённая история за 24 часа доступна в карточках и окнах стран; нажмите строку, чтобы увидеть детали целиком.</p>');
el('about').onclick=()=>showDetails('Как читать данные','<p>Голубой — HTTP-запросы, оранжевый — срабатывания CrowdSec, сиреневый — решения о ban или captcha.</p><p>Решение о бане не подтверждает конкретный отброшенный запрос. HTTP 401/403/502 сами по себе не означают блокировку CrowdSec.</p><p>Счётчики за 24 часа следуют выбранным фильтрам. Уникальный IP не равен человеку. Req/s — среднее за 60 секунд; график показывает запросы в минуту за последний час.</p><p>При масштабе от 200% и установленной базе городов появляются города с HTTP/CrowdSec-активностью. Геолокация приблизительная. Сетевые потоки и события без города остаются на уровне стран. Показываются до 500 наиболее активных городов; остальные события учитываются в точках стран. Локальные адреса остаются в ленте без страны. «Скрыть мониторинг» исключает из отображения HTTP-проверки по правилам в настройках; события CrowdSec сохраняются на экране. Исходная история не удаляется. В ленте показаны последние 100 событий; полная сохранённая история за 24 часа доступна в карточках и окнах стран; нажмите строку, чтобы увидеть детали целиком.</p>');
el('closeDetails').onclick=()=>el('details').close();el('details').onclick=e=>{if(e.target===el('details'))el('details').close()};
document.querySelectorAll('[data-mobile-tab]').forEach(b=>b.onclick=()=>{document.body.dataset.mobile=b.dataset.mobileTab;document.querySelectorAll('[data-mobile-tab]').forEach(t=>t.classList.toggle('active',t===b));drawChart();renderFeed()});
async function poll(force=false){if((paused&&!force)||busy||document.hidden)return;busy=true;try{const q=new URLSearchParams({domain:el('domain').value,kind,hideMonitoring:el('hideMonitoring').checked?'1':'0',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?'Карта отключена':'Не удалось обновить данные')}const data=await r.json();lastPoll=Date.now();render(data)}catch(e){el('live').textContent='НЕТ ОБНОВЛЕНИЙ';el('live').classList.add('stale');statusMessages=[e.message];el('status').textContent=e.message;el('status').classList.add('error')}finally{busy=false}}
@@ -110,12 +112,14 @@ function countryNetwork(code){if(!view?.network)return '';const points=view.netw
function filterNote(){return '<p class="modal-note">Последние 24 часа · текущие фильтры · снимок на '+new Date(view.now*1000).toLocaleTimeString('ru-RU')+'. '+(view.limited?'История ограничена лимитом хранения.':'')+'</p>'}
function eventList(events){return events.length?'<ul class="detail-list">'+events.map(e=>'<li>'+safe(e.ip)+' · '+safe(kindLabels[e.kind]||e.kind)+' · '+safe(eventText(e))+(e.monitoring?' · мониторинг':'')+'<small>'+safe([countryName(e.country),e.region,e.city].filter(Boolean).join(' · '))+' · '+new Date(e.time*1000).toLocaleString('ru-RU')+'</small></li>').join('')+'</ul>':'<p>В выбранном периоде записей нет.</p>'}
function openCountry(code){if(!view)return;const c=countryStats(code);showDetails(countryName(code),filterNote()+statGrid(c)+countryNetwork(code)+'<p>Последнее событие: '+(c.lastSeen?new Date(c.lastSeen*1000).toLocaleString('ru-RU'):'нет за выбранный период')+'</p>'+historyShell());startHistory({country:code})}
function cityAt(x,y){if(mapZoom<2)return null;let nearest=null,distance=12;for(const p of hitPoints){if(!p.city)continue;const d=Math.hypot(x-(p.x*mapZoom+mapX),y-(p.y*mapZoom+mapY));if(d<distance){nearest=p.city;distance=d}}return nearest}
function openCity(c){showDetails(c.city,filterNote()+'<p>'+safe([countryName(c.country),c.region].filter(Boolean).join(' · '))+'</p>'+statGrid(c)+'<p class="modal-note">Приблизительное местоположение IP по DB-IP. Это может быть город провайдера или VPN, а не местоположение посетителя.</p><p>Последнее событие: '+new Date(c.lastSeen*1000).toLocaleString('ru-RU')+'</p><button class="country-choice" data-country="'+safe(c.country)+'">Вся история страны: '+safe(countryName(c.country))+'</button>')}
function hideCountryTip(){el('tooltip').style.display='none';hoverCountry=null}
function showCountryTip(e){const r=el('world').getBoundingClientRect(),c=countryAt(e.clientX-r.left,e.clientY-r.top);hoverCountry=c||null;const tip=el('tooltip');if(!c){tip.style.display='none';redrawMap();return}const s=countryStats(c.code);tip.innerHTML='<strong>'+safe(c.name)+'</strong>HTTP '+num(s.requests)+' · IP '+num(s.uniqueIps)+'<br>Срабатывания '+num(s.alerts)+' · решения '+num(s.decisions)+countryNetwork(c.code)+'<small>24 ч · текущие фильтры · нажмите для деталей</small>';tip.style.display='block';tip.style.left=Math.max(8,Math.min(innerWidth-tip.offsetWidth-8,e.clientX+14))+'px';tip.style.top=Math.max(8,Math.min(innerHeight-tip.offsetHeight-8,e.clientY+14))+'px';redrawMap()}
function showCountryTip(e){const r=el('world').getBoundingClientRect(),city=cityAt(e.clientX-r.left,e.clientY-r.top),c=city?countryMap.get(city.country):countryAt(e.clientX-r.left,e.clientY-r.top);hoverCountry=c||null;const tip=el('tooltip');if(!c){tip.style.display='none';redrawMap();return}const s=city||countryStats(c.code);tip.innerHTML='<strong>'+safe(city?city.city+' · '+c.name:c.name)+'</strong>HTTP '+num(s.requests)+' · IP '+num(s.uniqueIps)+'<br>Срабатывания '+num(s.alerts)+' · решения '+num(s.decisions)+(city?'<p class="modal-note">Геолокация IP приблизительная</p>':countryNetwork(c.code))+'<small>24 ч · текущие фильтры · нажмите для деталей</small>';tip.style.display='block';tip.style.left=Math.max(8,Math.min(innerWidth-tip.offsetWidth-8,e.clientX+14))+'px';tip.style.top=Math.max(8,Math.min(innerHeight-tip.offsetHeight-8,e.clientY+14))+'px';redrawMap()}
const mapCanvas=el('world');
mapCanvas.onpointerdown=e=>{mapCanvas.setPointerCapture(e.pointerId);pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});drag={x:e.clientX,y:e.clientY,moved:pointers.size>1};hideCountryTip()};
mapCanvas.onpointermove=e=>{if(!pointers.has(e.pointerId)){showCountryTip(e);return}const old=pointers.get(e.pointerId),next={x:e.clientX,y:e.clientY};if(pointers.size===2){const other=[...pointers.entries()].find(([id])=>id!==e.pointerId)[1],before=Math.hypot(old.x-other.x,old.y-other.y),after=Math.hypot(next.x-other.x,next.y-other.y),r=mapCanvas.getBoundingClientRect();if(before>0)zoomMap(after/before,(next.x+other.x)/2-r.left,(next.y+other.y)/2-r.top);drag.moved=true}else{mapX+=next.x-old.x;mapY+=next.y-old.y;if(Math.hypot(next.x-drag.x,next.y-drag.y)>4)drag.moved=true;clampMap();redrawMap()}pointers.set(e.pointerId,next)};
mapCanvas.onpointerup=e=>{const moved=drag?.moved; pointers.delete(e.pointerId);if(!moved&&pointers.size===0){const r=mapCanvas.getBoundingClientRect(),c=countryAt(e.clientX-r.left,e.clientY-r.top);if(c)openCountry(c.code)}if(!pointers.size)drag=null};
mapCanvas.onpointerup=e=>{const moved=drag?.moved; pointers.delete(e.pointerId);if(!moved&&pointers.size===0){const r=mapCanvas.getBoundingClientRect(),c=countryAt(e.clientX-r.left,e.clientY-r.top);const city=cityAt(e.clientX-r.left,e.clientY-r.top);if(city)openCity(city);else if(c)openCountry(c.code)}if(!pointers.size)drag=null};
mapCanvas.onpointercancel=e=>{pointers.clear();drag=null;hideCountryTip()};mapCanvas.onpointerleave=()=>{hideCountryTip();redrawMap()};
window.addEventListener('resize',clampMap);
document.querySelectorAll('[data-metric]').forEach(b=>b.onclick=()=>{if(!view)return;const key=b.dataset.metric,title={unique:'Уникальные HTTP IP',requests:'HTTP-запросы',alerts:'Срабатывания CrowdSec',decisions:'Решения CrowdSec'}[key],field=key==='unique'?'uniqueIps':key,type={unique:'request',requests:'request',alerts:'alert',decisions:'decision'}[key],rows=(view.countryDetails||[]).filter(c=>c[field]>0).sort((a,b)=>b[field]-a[field]);const explanation={unique:'Уникальный IP не равен человеку. Учитываются IP HTTP-запросов.',requests:'Количество HTTP-запросов. Ошибки HTTP сами по себе не доказывают атаку.',alerts:'Срабатывания сценариев CrowdSec. Одно срабатывание может объединять несколько запросов.',decisions:'Решения CrowdSec, например ban или captcha. Это не число отброшенных пакетов и не список активных банов.'}[key];showDetails(title,filterNote()+'<div class="detail-stat"><small>'+title+'</small><b>'+num(view[field])+'</b></div><p>'+explanation+'</p><h3>По странам</h3>'+rows.map(c=>'<button class="country-choice" data-country="'+safe(c.country)+'">'+safe(countryName(c.country))+' · '+num(c[field])+'</button>').join('')+historyShell());startHistory({type,group:key==='unique'?'ip':''})});