From c3b2a10d2fdb9eba8c3930530f4f9fa0c95b538d Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 15 Oct 2026 20:11:51 +0300 Subject: [PATCH] Add light traffic theme, interactive map and metric detail dialogs --- README.md | 18 +++++++++++ assets/traffic.html | 69 ++++++++++++++++++++++++++++++++++------- traffic.go | 28 +++++++++++++++++ traffic_country_test.go | 44 ++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 11 deletions(-) create mode 100644 traffic_country_test.go diff --git a/README.md b/README.md index ae651a0..f6e46f2 100644 --- a/README.md +++ b/README.md @@ -631,3 +631,21 @@ Compose и пересоздайте контейнер, если логиров [NPM+](https://github.com/ZoeyVid/NPMplus/blob/develop/README.md), а не Dashboard. Stream-логи NPM+ не считаются HTTP-запросами; сетевые потоки собираются conntrack. Как и раньше, первый запуск чтения начинается с конца существующих логов. + +## Интерактивная карта (v0.24.0) + +Карта трафика использует ту же настройку `proxmox-theme`, что и основной +Dashboard. Кнопка солнца/луны меняет тему; выбор сохраняется в браузере. +Светлая тема распространяется на карту, график, карточки и модальные окна. + +Масштаб карты — от 100% до 600%: кнопки +/−, колесо мыши или жест двумя +пальцами. Увеличенную карту можно перетаскивать; ↺ возвращает весь мир. +При фокусе на карте доступны клавиши +/−, 0 и стрелки. Наведение подсвечивает +контур страны и показывает суточные счётчики, а нажатие открывает подробности. + +Четыре карточки статистики открывают окна с итогом, объяснением метрики, +разбивкой по странам и примерами событий. Счётчики стран считаются сервером +по всей доступной истории за 24 часа с текущими фильтрами. Примеры — до 30 +совпадений из последних 100 событий, что явно отмечено в интерфейсе. Открытое +окно фиксирует данные на момент открытия; временная отметка показана внутри. +Сетевые потоки остаются отдельным текущим снимком, не суточной статистикой. diff --git a/assets/traffic.html b/assets/traffic.html index d791236..08c55bf 100644 --- a/assets/traffic.html +++ b/assets/traffic.html @@ -1,6 +1,6 @@ Карта трафика · Proxmox Dashboard - -
PROXMOX / LIVE OBSERVATORY

Traffic space.

+
PROXMOX / LIVE OBSERVATORY

Traffic space.

-
Уникальные IP · 24 ч—
HTTP-запросы · 24 ч—
Срабатывания · 24 ч—
Решения CrowdSec · 24 ч—
+
-
HTTPСрабатываниеРешение
Без геолокации: —
+
100%
HTTPСрабатываниеРешение
Без геолокации: —

Ритм запросов / 60 мин

— req/s

География

ТОП 7 / события

Источники

ТОП 7 / IP
@@ -29,20 +43,21 @@ let world=[],countryMap=new Map(),view=null,kind='',paused=false,busy=false,puls const colors={request:'#5ed8ed',alert:'#ffbc70',decision:'#b7a0ff',tcp:'#b8ef72',udp:'#f69dde',icmp:'#ffd66d'},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){const scale=Math.min(w/360,h/170);return [(w-360*scale)/2+(lon+180)*scale,(h-170*scale)/2+(85-lat)*scale]} -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='#324a5b';for(let y=0,row=0;y{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=isLight()?'#b6cbd4':'#324a5b';for(let y=0,row=0;y0&&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);if(baseMap)ctx.drawImage(baseMap,0,0);hitPoints=[]; +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);ctx.save();ctx.translate(mapX,mapY);ctx.scale(mapZoom,mapZoom);if(baseMap)ctx.drawImage(baseMap,0,0);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='#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; + 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)),0,Math.PI*2);ctx.fill();hitPoints.push({x,y,text:country.name+' · '+num(c.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;ctx.strokeStyle=colors[p.protocol];ctx.lineWidth=1.4;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;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,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); + ctx.globalAlpha=1;ctx.strokeStyle=colors.decision;ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(...end,7,0,Math.PI*2);ctx.stroke();ctx.fillStyle=isLight()?'#554496':'#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); } + ctx.restore(); if(!document.hidden&&!paused)requestAnimationFrame(drawMap) } function ranking(id,items,label){el(id).innerHTML=items.length?items.slice(0,7).map((c,i)=>'
'+String(i+1).padStart(2,'0')+''+safe(label(c.name))+''+num(c.count)+'
').join(''):'
Пока нет событий
'} -function drawChart(){const c=el('chart'),w=c.clientWidth,h=Math.max(35,c.clientHeight),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,h-18);ctx.lineTo(w,h-18);ctx.stroke();ctx.fillStyle='#55c9ff';values.forEach((v,i)=>{const x=i*w/60,height=v/max*(h-32);ctx.fillRect(x,h-18-height,Math.max(1,w/60-3),height)});ctx.fillStyle='#8490ab';ctx.font='10px system-ui';ctx.fillText('−60 мин',0,h-2);ctx.textAlign='right';ctx.fillText('сейчас',w,h-2);ctx.fillText('макс. '+num(max)+'/мин',w,12)} +function drawChart(){const c=el('chart'),w=c.clientWidth,h=Math.max(35,c.clientHeight),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=isLight()?'#d8e0e5':'#26314b';ctx.beginPath();ctx.moveTo(0,h-18);ctx.lineTo(w,h-18);ctx.stroke();ctx.fillStyle=colors.request;values.forEach((v,i)=>{const x=i*w/60,height=v/max*(h-32);ctx.fillRect(x,h-18-height,Math.max(1,w/60-3),height)});ctx.fillStyle=isLight()?'#647481':'#8490ab';ctx.font='10px system-ui';ctx.fillText('−60 мин',0,h-2);ctx.textAlign='right';ctx.fillText('сейчас',w,h-2);ctx.fillText('макс. '+num(max)+'/мин',w,12)} function render(data){view=data;el('networkLink').hidden=!data.settings.networkEnabled;el('networkOverlayControl').hidden=!data.settings.networkEnabled;renderNetworkSummary();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 событий: часть суточной истории удалена');statusMessages=messages;el('status').textContent=fresh?'● на связи':'● нет связи';el('sourceDetails').title=messages.join(' · '); @@ -55,7 +70,7 @@ function render(data){view=data;el('networkLink').hidden=!data.settings.networkE } function renderNetworkSummary(){el('networkSummary').hidden=!networkVisible();if(!networkVisible())return;const n=view.network,fresh=networkFresh();el('networkSummary').innerHTML=(fresh?'Сеть · ':'Сеть · нет свежего снимка · ')+['tcp','udp','icmp'].map(p=>''+p.toUpperCase()+' '+num(n[p])+'').join(' · ')+(n.error||n.limited?' · ⚠':'')+' ⓘ';el('networkSummary').title='Текущие потоки conntrack. Фильтры домена и HTTP/CrowdSec на них не влияют.'} el('networkSummary').onclick=()=>{const n=view.network;showDetails('Сетевая активность на карте','

Зелёный — TCP, розовый — UDP, жёлтый — ICMP/ICMPv6. Кольца показывают страны текущих потоков, импульс означает получение нового снимка (~5 с), а не новый запрос или пакет.

Входящий поток идёт к NPM, исходящий — от NPM. Если оба конца публичные, направление определяется по инициатору; это не трассировка маршрута.

HTTP-фильтры домена и типа события относятся только к HTTP/CrowdSec. «Только внешние IP» и «Скрыть мониторинг» применяются также к сетевому слою. Для сети используются только правила без домена/пути.

Снимок: '+(n.at?new Date(n.at*1000).toLocaleString('ru-RU'):'ещё не получен')+'. Локальные / без страны: '+num(n.unmapped)+'. Скрыто потоков мониторинга: '+num(n.monitoringHidden)+'.

'+safe(n.error||(n.limited?'Достигнут лимит 5 000 потоков':'При устаревании снимка сетевые кольца и импульсы скрываются.'))+'

Открыть таблицу TCP/UDP/ICMP ↗

')}; -function showDetails(title,html){el('detailsTitle').textContent=title;el('detailsBody').innerHTML=html;el('details').showModal()} +function showDetails(title,html){el('detailsTitle').textContent=title;el('detailsBody').innerHTML=html;if(!el('details').open)el('details').showModal()} function eventText(e){return e.kind==='request'?e.method+' '+e.domain+e.path+' · HTTP '+e.status:(e.domain?e.domain+' · ':'')+e.reason} function renderFeed(){if(!view)return;const rowHeight=innerHeight<=650&&innerWidth>760?56:64,count=Math.max(1,Math.floor(el('feed').clientHeight/rowHeight)),items=frozenEvents||view.events,pages=Math.max(1,Math.ceil(items.length/count));feedPage=Math.min(feedPage,pages-1);feedVisible=items.slice(feedPage*count,(feedPage+1)*count); el('feed').innerHTML=feedVisible.length?feedVisible.map((e,i)=>'
'+safe(e.ip)+'
'+safe(countryName(e.country))+(e.monitoring?' · мониторинг':'')+' · '+kindLabels[e.kind]+'
'+safe(eventText(e))+'
').join(''):'
Новых событий пока нет
'; @@ -70,6 +85,38 @@ el('closeDetails').onclick=()=>el('details').close();el('details').onclick=e=>{i 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}} 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=[];feedPage=0;frozenEvents=null;poll(true)});el('domain').onchange=()=>{pulses=[];feedPage=0;frozenEvents=null;poll(true)};el('external').onchange=()=>{pulses=[];lastNetworkSnapshot=0;feedPage=0;frozenEvents=null;poll(true)};el('hideMonitoring').onchange=()=>{try{localStorage.setItem('proxmox-hide-monitoring',el('hideMonitoring').checked?'1':'0')}catch{}pulses=[];lastNetworkSnapshot=0;feedPage=0;frozenEvents=null;poll(true)};el('networkOverlay').onchange=()=>{try{localStorage.setItem('proxmox-network-overlay',el('networkOverlay').checked?'1':'0')}catch{}pulses=pulses.filter(p=>!p.network);renderNetworkSummary();if(paused)requestAnimationFrame(drawMap)};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();renderFeed();if(paused)requestAnimationFrame(drawMap)});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'); +let mapZoom=1,mapX=0,mapY=0,hoverCountry=null,drag=null; +const pointers=new Map(); +function isLight(){return document.documentElement.dataset.theme==='light'} +function applyTrafficTheme(theme){document.documentElement.dataset.theme=theme;Object.assign(colors,isLight()?{request:'#087f99',alert:'#ad590d',decision:'#6557d9',tcp:'#248641',udp:'#a33485',icmp:'#976900'}:{request:'#5ed8ed',alert:'#ffbc70',decision:'#b7a0ff',tcp:'#b8ef72',udp:'#f69dde',icmp:'#ffd66d'});el('themeToggle').textContent=isLight()?'☾':'☀';el('themeToggle').setAttribute('aria-label',isLight()?'Включить тёмную тему':'Включить светлую тему');baseMap=null;const c=el('world');if(c.clientWidth&&c.clientHeight)baseMap=mapBackground(Math.floor(c.clientWidth),Math.floor(c.clientHeight));drawChart();renderNetworkSummary();redrawMap()} +function redrawMap(){if(paused)requestAnimationFrame(drawMap)} +el('themeToggle').onclick=()=>{const theme=isLight()?'dark':'light';try{localStorage.setItem('proxmox-theme',theme)}catch{}applyTrafficTheme(theme)}; +window.addEventListener('storage',e=>{if(e.key==='proxmox-theme')applyTrafficTheme(e.newValue||'dark')}); +function clampMap(){const c=el('world');mapX=Math.max(c.clientWidth*(1-mapZoom),Math.min(0,mapX));mapY=Math.max(c.clientHeight*(1-mapZoom),Math.min(0,mapY));el('zoomLevel').textContent=Math.round(mapZoom*100)+'%';el('zoomOut').disabled=mapZoom<=1;el('zoomIn').disabled=mapZoom>=6} +function zoomMap(factor,x=el('world').clientWidth/2,y=el('world').clientHeight/2){const next=Math.max(1,Math.min(6,mapZoom*factor));mapX=x-(x-mapX)*next/mapZoom;mapY=y-(y-mapY)*next/mapZoom;mapZoom=next;clampMap();hideCountryTip();redrawMap()} +el('zoomIn').onclick=()=>zoomMap(1.4);el('zoomOut').onclick=()=>zoomMap(1/1.4);el('zoomReset').onclick=()=>{mapZoom=1;mapX=mapY=0;clampMap();hideCountryTip();redrawMap()}; +el('world').addEventListener('wheel',e=>{e.preventDefault();const r=e.currentTarget.getBoundingClientRect();zoomMap(Math.exp(-Math.max(-100,Math.min(100,e.deltaY))*.003),e.clientX-r.left,e.clientY-r.top)},{passive:false}); +el('world').onkeydown=e=>{if(['+','=','-','0','ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key)){e.preventDefault();if(e.key==='+'||e.key==='=')zoomMap(1.4);else if(e.key==='-')zoomMap(1/1.4);else if(e.key==='0')el('zoomReset').click();else{mapX+=e.key==='ArrowLeft'?30:e.key==='ArrowRight'?-30:0;mapY+=e.key==='ArrowUp'?30:e.key==='ArrowDown'?-30:0;clampMap();redrawMap()}}}; +function inRing(lon,lat,ring){let inside=false;for(let i=0,j=ring.length-1;ilat)!==(b[1]>lat)&&lon<(b[0]-a[0])*(lat-a[1])/(b[1]-a[1])+a[0])inside=!inside}return inside} +function countryAt(x,y){const c=el('world'),w=c.clientWidth,h=c.clientHeight,px=(x-mapX)/mapZoom,py=(y-mapY)/mapZoom,scale=Math.min(w/360,h/170),lon=(px-(w-360*scale)/2)/scale-180,lat=85-(py-(h-170*scale)/2)/scale;return world.find(c=>{const polygons=c.geometry.type==='Polygon'?[c.geometry.coordinates]:c.geometry.coordinates;return polygons.some(p=>inRing(lon,lat,p[0])&&!p.slice(1).some(r=>inRing(lon,lat,r)))})} +function drawCountryHighlight(ctx,w,h){if(!hoverCountry)return;ctx.fillStyle=isLight()?'#087f9928':'#5ed8ed30';ctx.strokeStyle=colors.request;ctx.lineWidth=1/mapZoom;const polygons=hoverCountry.geometry.type==='Polygon'?[hoverCountry.geometry.coordinates]:hoverCountry.geometry.coordinates;for(const poly of polygons){ctx.beginPath();for(const ring of poly){ring.forEach(([lon,lat],i)=>{const p=project(lon,lat,w,h);if(i)ctx.lineTo(...p);else ctx.moveTo(...p)});ctx.closePath()}ctx.fill('evenodd');ctx.stroke()}} +function countryStats(code){return (view?.countryDetails||[]).find(c=>c.country===code)||{requests:0,uniqueIps:0,alerts:0,decisions:0,lastSeen:0}} +function statGrid(s){return '
'+[['HTTP-запросы',s.requests],['Уникальные HTTP IP',s.uniqueIps],['Срабатывания',s.alerts],['Решения CrowdSec',s.decisions]].map(([k,v])=>'
'+k+''+num(v)+'
').join('')+'
'} +function countryNetwork(code){if(!view?.network)return '';const points=view.network.points.filter(p=>p.country===code);return ''} +function filterNote(){return ''} +function eventList(events){return events.length?'
    '+events.slice(0,30).map(e=>'
  • '+safe(e.ip)+' · '+safe(eventText(e))+''+safe(countryName(e.country))+' · '+new Date(e.time*1000).toLocaleString('ru-RU')+'
  • ').join('')+'
':'

В последних 100 событиях подходящих записей нет.

'} +function openCountry(code){if(!view)return;const c=countryStats(code);showDetails(countryName(code),filterNote()+statGrid(c)+countryNetwork(code)+'

Последнее событие: '+(c.lastSeen?new Date(c.lastSeen*1000).toLocaleString('ru-RU'):'нет за выбранный период')+'

'+eventList(view.events.filter(e=>e.country===code)))} +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=''+safe(c.name)+'HTTP '+num(s.requests)+' · IP '+num(s.uniqueIps)+'
Срабатывания '+num(s.alerts)+' · решения '+num(s.decisions)+countryNetwork(c.code)+'24 ч · текущие фильтры · нажмите для деталей';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.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()+'
'+title+''+num(view[field])+'

'+explanation+'

По странам

'+rows.map(c=>'').join('')+''+eventList(view.events.filter(e=>e.kind===type)))}); +el('detailsBody').onclick=e=>{const b=e.target.closest('[data-country]');if(b)openCountry(b.dataset.country)}; +applyTrafficTheme(document.documentElement.dataset.theme||'dark');clampMap(); + (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); diff --git a/traffic.go b/traffic.go index 84d9894..c761777 100644 --- a/traffic.go +++ b/traffic.go @@ -51,7 +51,17 @@ type TrafficCount struct { Count int64 `json:"count"` } +type TrafficCountryDetail struct { + Country string `json:"country"` + Requests int64 `json:"requests"` + UniqueIPs int64 `json:"uniqueIps"` + Alerts int64 `json:"alerts"` + Decisions int64 `json:"decisions"` + LastSeen int64 `json:"lastSeen"` +} type TrafficView struct { + CountryDetails []TrafficCountryDetail `json:"countryDetails"` + Network *NetworkMapView `json:"network,omitempty"` Events []TrafficEvent `json:"events"` Sources []TrafficSource `json:"sources"` @@ -222,6 +232,24 @@ func (s *Store) TrafficView(domain, kind string, external bool, hideMonitoring . return v, e } } + detailRows, detailErr := s.db.Query(`SELECT country,SUM(kind='request'),COUNT(DISTINCT CASE WHEN kind='request' THEN ip END),SUM(kind='alert'),SUM(kind='decision'),MAX(ts) FROM traffic_events`+where+` GROUP BY country`, args...) + if detailErr != nil { + return v, detailErr + } + v.CountryDetails = []TrafficCountryDetail{} + for detailRows.Next() { + var d TrafficCountryDetail + if err = detailRows.Scan(&d.Country, &d.Requests, &d.UniqueIPs, &d.Alerts, &d.Decisions, &d.LastSeen); err != nil { + detailRows.Close() + return v, err + } + v.CountryDetails = append(v.CountryDetails, d) + } + detailErr = detailRows.Err() + detailRows.Close() + if detailErr != nil { + return v, detailErr + } r, err := s.db.Query(`SELECT CAST((ts/60)*60 AS TEXT),COUNT(*) FROM traffic_events`+where+` AND kind='request' AND ts>=? GROUP BY ts/60 ORDER BY ts/60`, append(args, now-3600)...) if err != nil { return v, err diff --git a/traffic_country_test.go b/traffic_country_test.go new file mode 100644 index 0000000..e8abc62 --- /dev/null +++ b/traffic_country_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "fmt" + "testing" + "time" +) + +func TestTrafficCountryDetailsUseHistoryAndFilters(t *testing.T) { + s, a := trafficFixture(t) + if _, err := s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID, MonitoringRules: []MonitoringRule{{IP: "8.8.8.8"}}}); err != nil { + t.Fatal(err) + } + events := []TrafficEvent{} + for i := 0; i < 120; i++ { + events = append(events, TrafficEvent{ID: fmt.Sprint("http-", i), Time: time.Now().Unix(), Kind: "request", IP: "8.8.8.8", Domain: "example.test", Path: "/", Status: 200}) + } + events = append(events, TrafficEvent{ID: "alert", Time: time.Now().Unix(), Kind: "alert", IP: "8.8.8.8", Domain: "example.test", Reason: "test"}) + if err := s.SaveTraffic(a.ID, TrafficBatch{Events: events}); err != nil { + t.Fatal(err) + } + v, err := s.TrafficView("", "", false) + if err != nil { + t.Fatal(err) + } + if len(v.Events) != 100 || len(v.CountryDetails) != 1 { + t.Fatalf("events=%d countries=%+v", len(v.Events), v.CountryDetails) + } + d := v.CountryDetails[0] + if d.Requests != 120 || d.UniqueIPs != 1 || d.Alerts != 1 || d.LastSeen == 0 { + t.Fatalf("%+v", d) + } + v, err = s.TrafficView("example.test", "", false, true) + if err != nil { + t.Fatal(err) + } + if len(v.CountryDetails) != 1 || v.CountryDetails[0].Requests != 0 || v.CountryDetails[0].Alerts != 1 { + t.Fatalf("filter: %+v", v.CountryDetails) + } + v, err = s.TrafficView("other.test", "", false) + if err != nil || len(v.CountryDetails) != 0 { + t.Fatalf("domain: %+v %v", v.CountryDetails, err) + } +}