From 46bd6e8be6ef0381b1aa2acf66a1526323bfe226 Mon Sep 17 00:00:00 2001
From: Maxim
Date: Thu, 15 Oct 2026 20:48:04 +0300
Subject: [PATCH] Plot city-level traffic activity from local GeoIP coordinates
---
README.md | 13 ++++++++
assets/traffic.html | 18 ++++++-----
traffic.go | 48 +++++++++++++++++++----------
traffic_city.go | 40 ++++++++++++++++++++++--
traffic_city_test.go | 28 +++++++++++++++++
traffic_map_cities.go | 71 +++++++++++++++++++++++++++++++++++++++++++
6 files changed, 192 insertions(+), 26 deletions(-)
create mode 100644 traffic_map_cities.go
diff --git a/README.md b/README.md
index 7a0fef7..ee43581 100644
--- a/README.md
+++ b/README.md
@@ -678,3 +678,16 @@ DB-IP City Lite загружается сначала из вложения ре
задать `GITEA_URL` в окружении сервиса Dashboard. База хранится отдельно от Git-истории
и бинарника приложения; контрольная сумма проверяется независимо от источника.
Официальный файл: https://download.db-ip.com/free/dbip-city-lite-2026-10.mmdb.gz
+
+## Города на карте (v0.26.0)
+
+При установленной базе городов и масштабе от 200% на карте появляются города
+с HTTP/CrowdSec-активностью. Маркеры используют приблизительные координаты DB-IP,
+подписи не увеличиваются вместе с картой. Наведение показывает статистику,
+нажатие открывает карточку города с итогами и ссылкой на всю историю страны.
+HTTP/CrowdSec-импульсы также начинаются в городе, если он определён.
+
+Счётчики городов учитывают выбранного агента и фильтры за 24 часа. На карте
+показываются до 500 наиболее активных городов; остальные события и IP без города
+остаются в точках стран. TCP/UDP/ICMP пока отображаются на уровне стран.
+Город может относиться к провайдеру/VPN, а не фактическому местоположению человека.
diff --git a/assets/traffic.html b/assets/traffic.html
index 957e669..65d051e 100644
--- a/assets/traffic.html
+++ b/assets/traffic.html
@@ -32,7 +32,7 @@ html[data-theme="light"] dialog,html[data-theme="light"] .tooltip,html[data-them
@@ -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.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=>''+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 потоков':'При устаревании снимка сетевые кольца и импульсы скрываются.'))+'
Голубой — HTTP-запросы, оранжевый — срабатывания CrowdSec, сиреневый — решения о ban или captcha.
Решение о бане не подтверждает конкретный отброшенный запрос. HTTP 401/403/502 сами по себе не означают блокировку CrowdSec.
Счётчики за 24 часа следуют выбранным фильтрам. Уникальный IP не равен человеку. Req/s — среднее за 60 секунд; график показывает запросы в минуту за последний час.
Точки на карте обозначают страны, а не точное местоположение людей. Локальные адреса остаются в ленте без страны. «Скрыть мониторинг» исключает из отображения HTTP-проверки по правилам в настройках; события CrowdSec сохраняются на экране. Исходная история не удаляется. В ленте показаны последние 100 событий; полная сохранённая история за 24 часа доступна в карточках и окнах стран; нажмите строку, чтобы увидеть детали целиком.
Голубой — HTTP-запросы, оранжевый — срабатывания CrowdSec, сиреневый — решения о ban или captcha.
Решение о бане не подтверждает конкретный отброшенный запрос. HTTP 401/403/502 сами по себе не означают блокировку CrowdSec.
Счётчики за 24 часа следуют выбранным фильтрам. Уникальный IP не равен человеку. Req/s — среднее за 60 секунд; график показывает запросы в минуту за последний час.
При масштабе от 200% и установленной базе городов появляются города с HTTP/CrowdSec-активностью. Геолокация приблизительная. Сетевые потоки и события без города остаются на уровне стран. Показываются до 500 наиболее активных городов; остальные события учитываются в точках стран. Локальные адреса остаются в ленте без страны. «Скрыть мониторинг» исключает из отображения HTTP-проверки по правилам в настройках; события CrowdSec сохраняются на экране. Исходная история не удаляется. В ленте показаны последние 100 событий; полная сохранённая история за 24 часа доступна в карточках и окнах стран; нажмите строку, чтобы увидеть детали целиком.
');
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 '
Последние 24 часа · текущие фильтры · снимок на '+new Date(view.now*1000).toLocaleTimeString('ru-RU')+'. '+(view.limited?'История ограничена лимитом хранения.':'')+'
'}
function eventList(events){return events.length?'
'}
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'):'нет за выбранный период')+'
'+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'+safe([countryName(c.country),c.region].filter(Boolean).join(' · '))+'
'+statGrid(c)+'
Приблизительное местоположение IP по DB-IP. Это может быть город провайдера или VPN, а не местоположение посетителя.
Последнее событие: '+new Date(c.lastSeen*1000).toLocaleString('ru-RU')+'
')}
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()}
+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=''+safe(city?city.city+' · '+c.name:c.name)+'HTTP '+num(s.requests)+' · IP '+num(s.uniqueIps)+' Срабатывания '+num(s.alerts)+' · решения '+num(s.decisions)+(city?'
Геолокация IP приблизительная
':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.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()+'
'+title+''+num(view[field])+'
'+explanation+'
По странам
'+rows.map(c=>'').join('')+historyShell());startHistory({type,group:key==='unique'?'ip':''})});
diff --git a/traffic.go b/traffic.go
index 183ca36..789008d 100644
--- a/traffic.go
+++ b/traffic.go
@@ -12,21 +12,22 @@ import (
)
type TrafficEvent struct {
- City string `json:"city,omitempty"`
- Region string `json:"region,omitempty"`
- Monitoring bool `json:"monitoring"`
- ID string `json:"id"`
- Sequence int64 `json:"sequence,omitempty"`
- Time int64 `json:"time"`
- Kind string `json:"kind"`
- IP string `json:"ip"`
- Domain string `json:"domain,omitempty"`
- Method string `json:"method,omitempty"`
- Path string `json:"path,omitempty"`
- Status int `json:"status,omitempty"`
- Reason string `json:"reason,omitempty"`
- Country string `json:"country,omitempty"`
- Agent string `json:"agent,omitempty"`
+ Location *TrafficCityLocation `json:"location,omitempty"`
+ City string `json:"city,omitempty"`
+ Region string `json:"region,omitempty"`
+ Monitoring bool `json:"monitoring"`
+ ID string `json:"id"`
+ Sequence int64 `json:"sequence,omitempty"`
+ Time int64 `json:"time"`
+ Kind string `json:"kind"`
+ IP string `json:"ip"`
+ Domain string `json:"domain,omitempty"`
+ Method string `json:"method,omitempty"`
+ Path string `json:"path,omitempty"`
+ Status int `json:"status,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ Country string `json:"country,omitempty"`
+ Agent string `json:"agent,omitempty"`
}
type TrafficBatch struct {
@@ -62,6 +63,9 @@ type TrafficCountryDetail struct {
LastSeen int64 `json:"lastSeen"`
}
type TrafficView struct {
+ Cities []TrafficMapCity `json:"cities"`
+ CitiesLimited bool `json:"citiesLimited"`
+ CityReady bool `json:"cityReady"`
MaxSequence int64 `json:"maxSequence"`
CountryDetails []TrafficCountryDetail `json:"countryDetails"`
@@ -209,7 +213,11 @@ func (s *Store) TrafficView(domain, kind string, external bool, hideMonitoring .
return v, err
}
e.Monitoring = isMonitoringRequest(e, v.Settings.MonitoringRules)
- e.City, e.Region = lookupTrafficCity(e.IP)
+ g := lookupTrafficCityLocation(e.IP)
+ e.City, e.Region = g.City, g.Region
+ if g.Located {
+ e.Location = &g
+ }
v.Events = append(v.Events, e)
}
err = rows.Err()
@@ -257,6 +265,14 @@ func (s *Store) TrafficView(domain, kind string, external bool, hideMonitoring .
if detailErr != nil {
return v, detailErr
}
+ v.CityReady = trafficCityReady()
+ if v.CityReady {
+ var cityErr error
+ v.Cities, v.CitiesLimited, cityErr = s.trafficMapCities(where, args)
+ if cityErr != nil {
+ return v, cityErr
+ }
+ }
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_city.go b/traffic_city.go
index 64806eb..18c959c 100644
--- a/traffic_city.go
+++ b/traffic_city.go
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"fmt"
"io"
+ "math"
"net"
"net/http"
"os"
@@ -26,6 +27,7 @@ var trafficCity struct {
db *maxminddb.Reader
running bool
message string
+ cache map[string]TrafficCityLocation
}
func trafficCityPath() string {
@@ -49,14 +51,34 @@ func trafficCityReady() bool {
defer trafficCity.Unlock()
return trafficCity.db != nil
}
+
+type TrafficCityLocation struct {
+ City string `json:"city"`
+ Region string `json:"region,omitempty"`
+ Latitude float64 `json:"latitude"`
+ Longitude float64 `json:"longitude"`
+ Located bool `json:"located"`
+}
+
func lookupTrafficCity(text string) (string, string) {
+ g := lookupTrafficCityLocation(text)
+ return g.City, g.Region
+}
+func lookupTrafficCityLocation(text string) TrafficCityLocation {
ip := net.ParseIP(text)
if ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || !ip.IsGlobalUnicast() || !trafficCityReady() {
- return "", ""
+ return TrafficCityLocation{}
}
trafficCity.Lock()
defer trafficCity.Unlock()
+ if g, ok := trafficCity.cache[text]; ok {
+ return g
+ }
var r struct {
+ Location struct {
+ Latitude *float64 `maxminddb:"latitude"`
+ Longitude *float64 `maxminddb:"longitude"`
+ } `maxminddb:"location"`
City struct {
Names map[string]string `maxminddb:"names"`
} `maxminddb:"city"`
@@ -65,13 +87,24 @@ func lookupTrafficCity(text string) (string, string) {
} `maxminddb:"subdivisions"`
}
if trafficCity.db.Lookup(ip, &r) != nil {
- return "", ""
+ return TrafficCityLocation{}
}
region := ""
if len(r.Subdivisions) > 0 {
region = cityName(r.Subdivisions[0].Names)
}
- return cityName(r.City.Names), region
+ g := TrafficCityLocation{City: cityName(r.City.Names), Region: region}
+ if g.City != "" && r.Location.Latitude != nil && r.Location.Longitude != nil {
+ g.Latitude, g.Longitude = *r.Location.Latitude, *r.Location.Longitude
+ g.Located = !math.IsNaN(g.Latitude) && !math.IsNaN(g.Longitude) && math.Abs(g.Latitude) <= 90 && math.Abs(g.Longitude) <= 180
+ }
+ if trafficCity.cache == nil {
+ trafficCity.cache = map[string]TrafficCityLocation{}
+ }
+ if len(trafficCity.cache) < 32768 {
+ trafficCity.cache[text] = g
+ }
+ return g
}
func trafficCityRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/settings/traffic/city-database", func(w http.ResponseWriter, r *http.Request) {
@@ -178,5 +211,6 @@ func installTrafficCityFrom(source, expectedHash string) error {
trafficCity.db.Close()
}
trafficCity.db = db
+ trafficCity.cache = nil
return nil
}
diff --git a/traffic_city_test.go b/traffic_city_test.go
index 2dc59fd..0eb8b79 100644
--- a/traffic_city_test.go
+++ b/traffic_city_test.go
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"testing"
+ "time"
)
func TestCityInstallRejectsBadDownload(t *testing.T) {
@@ -46,6 +47,33 @@ func TestCityDatabaseDownloaded(t *testing.T) {
if err := installTrafficCityFrom(server.URL, cityDatabaseSHA1); err != nil {
t.Fatal(err)
}
+ g := lookupTrafficCityLocation("20.194.96.114")
+ if !g.Located || g.Latitude < 30 || g.Latitude > 40 || g.Longitude < 120 || g.Longitude > 130 {
+ t.Fatalf("invalid Seoul coordinates: %+v", g)
+ }
+ s, a := trafficFixture(t)
+ s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID})
+ now := time.Now().Unix()
+ if err := s.SaveTraffic(a.ID, TrafficBatch{Events: []TrafficEvent{
+ {ID: "1", Time: now, Kind: "request", IP: "20.194.96.114", Domain: "a.test"},
+ {ID: "2", Time: now, Kind: "request", IP: "20.194.96.114", Domain: "a.test"},
+ {ID: "3", Time: now, Kind: "alert", IP: "20.194.96.114", Domain: "b.test"},
+ {ID: "4", Time: now, Kind: "request", IP: "192.168.0.10"},
+ }}); err != nil {
+ t.Fatal(err)
+ }
+ view, err := s.TrafficView("", "", false)
+ if err != nil || len(view.Cities) != 1 {
+ t.Fatalf("cities %+v err %v", view.Cities, err)
+ }
+ c := view.Cities[0]
+ if c.Requests != 2 || c.UniqueIPs != 1 || c.Alerts != 1 || c.City != g.City {
+ t.Fatalf("bad aggregation %+v", c)
+ }
+ filtered, err := s.TrafficView("b.test", "alert", false)
+ if err != nil || len(filtered.Cities) != 1 || filtered.Cities[0].Requests != 0 || filtered.Cities[0].Alerts != 1 {
+ t.Fatalf("city filter %+v %v", filtered.Cities, err)
+ }
city, region := lookupTrafficCity("20.194.96.114")
t.Logf("city=%q region=%q", city, region)
if city == "" {
diff --git a/traffic_map_cities.go b/traffic_map_cities.go
new file mode 100644
index 0000000..246411d
--- /dev/null
+++ b/traffic_map_cities.go
@@ -0,0 +1,71 @@
+package main
+
+import (
+ "fmt"
+ "sort"
+)
+
+type TrafficMapCity struct {
+ TrafficCityLocation
+ Country string `json:"country"`
+ Requests int64 `json:"requests"`
+ UniqueIPs int64 `json:"uniqueIps"`
+ Alerts int64 `json:"alerts"`
+ Decisions int64 `json:"decisions"`
+ LastSeen int64 `json:"lastSeen"`
+}
+
+func (s *Store) trafficMapCities(where string, args []any) ([]TrafficMapCity, bool, error) {
+ rows, err := s.db.Query(`SELECT ip,country,SUM(kind='request'),SUM(kind='alert'),SUM(kind='decision'),MAX(ts) FROM traffic_events`+where+` GROUP BY ip,country`, args...)
+ if err != nil {
+ return nil, false, err
+ }
+ defer rows.Close()
+ groups := map[string]*TrafficMapCity{}
+ for rows.Next() {
+ var ip, country string
+ var requests, alerts, decisions, last int64
+ if err = rows.Scan(&ip, &country, &requests, &alerts, &decisions, &last); err != nil {
+ return nil, false, err
+ }
+ g := lookupTrafficCityLocation(ip)
+ if !g.Located {
+ continue
+ }
+ key := fmt.Sprintf("%s/%s/%s/%.5f/%.5f", country, g.Region, g.City, g.Latitude, g.Longitude)
+ c := groups[key]
+ if c == nil {
+ c = &TrafficMapCity{TrafficCityLocation: g, Country: country}
+ groups[key] = c
+ }
+ c.Requests += requests
+ c.Alerts += alerts
+ c.Decisions += decisions
+ if requests > 0 {
+ c.UniqueIPs++
+ }
+ if last > c.LastSeen {
+ c.LastSeen = last
+ }
+ }
+ if err = rows.Err(); err != nil {
+ return nil, false, err
+ }
+ result := make([]TrafficMapCity, 0, len(groups))
+ for _, c := range groups {
+ result = append(result, *c)
+ }
+ sort.Slice(result, func(i, j int) bool {
+ a, b := result[i], result[j]
+ x, y := a.Requests+a.Alerts+a.Decisions, b.Requests+b.Alerts+b.Decisions
+ if x != y {
+ return x > y
+ }
+ return fmt.Sprint(a.Country, a.Region, a.City, a.Latitude, a.Longitude) < fmt.Sprint(b.Country, b.Region, b.City, b.Latitude, b.Longitude)
+ })
+ limited := len(result) > 500
+ if limited {
+ result = result[:500]
+ }
+ return result, limited, nil
+}