2 Commits

6 changed files with 228 additions and 32 deletions

View File

@@ -665,3 +665,29 @@ IP посетителей не отправляются во внешние се
в истории и списке IP; местоположение приблизительное и может относиться к VPN в истории и списке IP; местоположение приблизительное и может относиться к VPN
или провайдеру. Для части IP город неизвестен. База устанавливается отдельно от или провайдеру. Для части IP город неизвестен. База устанавливается отдельно от
релиза, автоматически каждый месяц не обновляется. релиза, автоматически каждый месяц не обновляется.
## Фильтр истории и зеркало GeoIP (v0.25.1)
В окнах стран и событий есть локальный переключатель «Все события / HTTP /
Срабатывания / Решения CrowdSec». Он меняет тип событий только внутри окна,
сохраняя страну, домен, остальные фильтры и снимок времени; пагинация начинается
с первой страницы. Сводные карточки над историей сохраняют исходные итоги.
DB-IP City Lite загружается сначала из вложения релиза v0.25.1 в Gitea,
при ошибке — с официального сервера DB-IP. Для собственного адреса Gitea можно
задать `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, а не фактическому местоположению человека.

View File

@@ -26,13 +26,13 @@ html[data-theme="light"] .metric i{background:#e2f2e7}html[data-theme="light"] .
html[data-theme="light"] .section-label,html[data-theme="light"] .event .path,html[data-theme="light"] dialog p{color:var(--text)}html[data-theme="light"] .event .meta{color:var(--muted)}html[data-theme="light"] .event{border-color:var(--line)}html[data-theme="light"] .event:hover{background:#eaf1f4} html[data-theme="light"] .section-label,html[data-theme="light"] .event .path,html[data-theme="light"] dialog p{color:var(--text)}html[data-theme="light"] .event .meta{color:var(--muted)}html[data-theme="light"] .event{border-color:var(--line)}html[data-theme="light"] .event:hover{background:#eaf1f4}
html[data-theme="light"] dialog,html[data-theme="light"] .tooltip,html[data-theme="light"] #networkSummary{background:#fff;color:var(--text);border-color:var(--line);box-shadow:0 12px 40px #4e64752b}html[data-theme="light"] dialog::backdrop{background:#1e2d3759}html[data-theme="light"] .monitoring-tag{color:var(--green)} html[data-theme="light"] dialog,html[data-theme="light"] .tooltip,html[data-theme="light"] #networkSummary{background:#fff;color:var(--text);border-color:var(--line);box-shadow:0 12px 40px #4e64752b}html[data-theme="light"] dialog::backdrop{background:#1e2d3759}html[data-theme="light"] .monitoring-tag{color:var(--green)}
@media(max-width:760px){header nav{gap:5px}#themeToggle{padding:5px}.map-controls{bottom:3px;right:3px}.map-controls button{padding:4px 8px}} @media(max-width:760px){header nav{gap:5px}#themeToggle{padding:5px}.map-controls{bottom:3px;right:3px}.map-controls button{padding:4px 8px}}
.history-pager{display:flex;align-items:center;justify-content:space-between;gap:12px;position:sticky;bottom:-22px;background:var(--panel);padding:12px 0}dialog{width:min(760px,calc(100vw - 30px))} #historyFilters{display:flex;flex-wrap:wrap;gap:6px;margin:16px 0}#historyFilters[hidden]{display:none}#historyFilters button[aria-pressed="true"]{border-color:var(--blue);background:var(--panel);box-shadow:inset 0 -2px currentColor}.history-pager{display:flex;align-items:center;justify-content:space-between;gap:12px;position:sticky;bottom:-22px;background:var(--panel);padding:12px 0}dialog{width:min(760px,calc(100vw - 30px))}
</style></head><body data-mobile="map"> </style></head><body data-mobile="map">
<header><div><span class="brand-mark" aria-hidden="true">◎</span><div><div class="eyebrow">PROXMOX / LIVE OBSERVATORY</div><h1>Traffic space<span style="color:var(--green)">.</span></h1></div></div><nav><span id="live" class="live stale">Подключение…</span><span class="now" id="clock"></span><a href="/">↖ Dashboard</a><button id="themeToggle" class="icon-button" aria-label="Переключить тему">☀</button><button id="fullscreen" class="icon-button" aria-label="Полный экран">⛶</button></nav></header> <header><div><span class="brand-mark" aria-hidden="true">◎</span><div><div class="eyebrow">PROXMOX / LIVE OBSERVATORY</div><h1>Traffic space<span style="color:var(--green)">.</span></h1></div></div><nav><span id="live" class="live stale">Подключение…</span><span class="now" id="clock"></span><a href="/">↖ Dashboard</a><button id="themeToggle" class="icon-button" aria-label="Переключить тему">☀</button><button id="fullscreen" class="icon-button" aria-label="Полный экран">⛶</button></nav></header>
<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> <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> <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> <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> <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> <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> <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 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); 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]; 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} 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.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(); 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); const unmapped=data.countries.filter(c=>!countryMap.has(c.name)).reduce((n,c)=>n+c.count,0);el('unmapped').textContent='Локальные / без геолокации: '+num(unmapped);
renderFeed(); 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()})}} 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 на них не влияют.'} 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>')}; 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(''))} 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('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('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()}; 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()}); 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}} 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,19 +112,22 @@ 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 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 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 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 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'); 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.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.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()}; mapCanvas.onpointercancel=e=>{pointers.clear();drag=null;hideCountryTip()};mapCanvas.onpointerleave=()=>{hideCountryTip();redrawMap()};
window.addEventListener('resize',clampMap); 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':''})}); 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':''})});
el('detailsBody').onclick=e=>{const b=e.target.closest('[data-country]');if(b)openCountry(b.dataset.country);const h=e.target.closest('[data-history-page]');if(h&&!historyBusy)loadHistory(Number(h.dataset.historyPage))}; el('detailsBody').onclick=e=>{const b=e.target.closest('[data-country]');if(b)openCountry(b.dataset.country);const f=e.target.closest('[data-history-kind]');if(f&&historyQuery){historyVersion++;historyBusy=false;historyQuery.kind=f.dataset.historyKind;historyQuery.type='';renderHistoryFilters();loadHistory(0)}const h=e.target.closest('[data-history-page]');if(h&&!historyBusy)loadHistory(Number(h.dataset.historyPage))};
let historyQuery=null,historyVersion=0,historyBusy=false; let historyQuery=null,historyVersion=0,historyBusy=false;
function historyShell(){return '<section id="historyPanel"><p>Загружаем историю…</p></section>'} function historyShell(){return '<div id="historyFilters" role="group" aria-label="Тип событий в истории"></div><section id="historyPanel"><p>Загружаем историю…</p></section>'}
function startHistory(extra){historyVersion++;historyBusy=false;historyQuery={domain:el('domain').value,kind,external:el('external').checked?'1':'0',hideMonitoring:el('hideMonitoring').checked?'1':'0',snapshot:String(view.now),maxSequence:String(view.maxSequence),...extra};loadHistory(0)} function startHistory(extra){historyVersion++;historyBusy=false;historyQuery={domain:el('domain').value,kind,external:el('external').checked?'1':'0',hideMonitoring:el('hideMonitoring').checked?'1':'0',snapshot:String(view.now),maxSequence:String(view.maxSequence),...extra};renderHistoryFilters();loadHistory(0)}
function renderHistoryFilters(){const box=el('historyFilters');if(!box)return;box.hidden=historyQuery.group==='ip';const selected=historyQuery.type||historyQuery.kind||'';box.innerHTML=[['','Все события'],['request','HTTP'],['alert','Срабатывания'],['decision','Решения CrowdSec']].map(([value,label])=>'<button data-history-kind="'+value+'" aria-pressed="'+(selected===value)+'">'+label+'</button>').join('')}
async function loadHistory(page){const version=historyVersion,query={...historyQuery,page:String(page)};historyBusy=true;try{const r=await fetch('/api/traffic/history?'+new URLSearchParams(query));if(!r.ok)throw Error('Не удалось загрузить историю: HTTP '+r.status);const d=await r.json();if(version!==historyVersion||!el('details').open||!el('historyPanel'))return;historyQuery.snapshot=String(d.snapshot);historyQuery.maxSequence=String(d.maxSequence);const pages=Math.max(1,Math.ceil(d.total/d.pageSize)),start=d.total?page*d.pageSize+1:0,end=Math.min(d.total,(page+1)*d.pageSize);let body=query.group==='ip'?(d.ips.length?'<ul class="detail-list">'+d.ips.map(i=>'<li><b>'+safe(i.ip)+'</b> · '+num(i.requests)+' HTTP-запросов<small>'+safe([countryName(i.country),i.region,i.city].filter(Boolean).join(' · '))+' · последнее '+new Date(i.lastSeen*1000).toLocaleString('ru-RU')+'</small></li>').join('')+'</ul>':'<p>IP не найдены.</p>'):eventList(d.events);el('historyPanel').innerHTML='<h3>'+(query.group==='ip'?'Все уникальные HTTP IP':'История событий')+'</h3><p class="modal-note">'+num(start)+'–'+num(end)+' из '+num(d.total)+' · последние 24 часа на момент открытия. '+(d.cityReady?'Город/регион приблизительны; отсутствие названия означает, что база их не определила.':'Для городов установите базу GeoIP в настройках карты.')+'</p>'+body+'<div class="history-pager"><button data-history-page="'+(page-1)+'" '+(page===0?'disabled':'')+'>← Назад</button><span>'+(page+1)+' / '+pages+'</span><button data-history-page="'+(page+1)+'" '+(page+1>=pages?'disabled':'')+'>Далее →</button></div>'}catch(e){if(version===historyVersion&&el('historyPanel'))el('historyPanel').innerHTML='<p>'+safe(e.message)+'</p><button data-history-page="'+page+'">Повторить</button>'}finally{if(version===historyVersion)historyBusy=false}} async function loadHistory(page){const version=historyVersion,query={...historyQuery,page:String(page)};historyBusy=true;try{const r=await fetch('/api/traffic/history?'+new URLSearchParams(query));if(!r.ok)throw Error('Не удалось загрузить историю: HTTP '+r.status);const d=await r.json();if(version!==historyVersion||!el('details').open||!el('historyPanel'))return;historyQuery.snapshot=String(d.snapshot);historyQuery.maxSequence=String(d.maxSequence);const pages=Math.max(1,Math.ceil(d.total/d.pageSize)),start=d.total?page*d.pageSize+1:0,end=Math.min(d.total,(page+1)*d.pageSize);let body=query.group==='ip'?(d.ips.length?'<ul class="detail-list">'+d.ips.map(i=>'<li><b>'+safe(i.ip)+'</b> · '+num(i.requests)+' HTTP-запросов<small>'+safe([countryName(i.country),i.region,i.city].filter(Boolean).join(' · '))+' · последнее '+new Date(i.lastSeen*1000).toLocaleString('ru-RU')+'</small></li>').join('')+'</ul>':'<p>IP не найдены.</p>'):eventList(d.events);el('historyPanel').innerHTML='<h3>'+(query.group==='ip'?'Все уникальные HTTP IP':'История событий')+'</h3><p class="modal-note">'+num(start)+'–'+num(end)+' из '+num(d.total)+' · последние 24 часа на момент открытия. '+(d.cityReady?'Город/регион приблизительны; отсутствие названия означает, что база их не определила.':'Для городов установите базу GeoIP в настройках карты.')+'</p>'+body+'<div class="history-pager"><button data-history-page="'+(page-1)+'" '+(page===0?'disabled':'')+'>← Назад</button><span>'+(page+1)+' / '+pages+'</span><button data-history-page="'+(page+1)+'" '+(page+1>=pages?'disabled':'')+'>Далее →</button></div>'}catch(e){if(version===historyVersion&&el('historyPanel'))el('historyPanel').innerHTML='<p>'+safe(e.message)+'</p><button data-history-page="'+page+'">Повторить</button>'}finally{if(version===historyVersion)historyBusy=false}}
el('details').addEventListener('close',()=>{historyVersion++;historyBusy=false;historyQuery=null}); el('details').addEventListener('close',()=>{historyVersion++;historyBusy=false;historyQuery=null});
applyTrafficTheme(document.documentElement.dataset.theme||'dark');clampMap(); applyTrafficTheme(document.documentElement.dataset.theme||'dark');clampMap();

View File

@@ -12,21 +12,22 @@ import (
) )
type TrafficEvent struct { type TrafficEvent struct {
City string `json:"city,omitempty"` Location *TrafficCityLocation `json:"location,omitempty"`
Region string `json:"region,omitempty"` City string `json:"city,omitempty"`
Monitoring bool `json:"monitoring"` Region string `json:"region,omitempty"`
ID string `json:"id"` Monitoring bool `json:"monitoring"`
Sequence int64 `json:"sequence,omitempty"` ID string `json:"id"`
Time int64 `json:"time"` Sequence int64 `json:"sequence,omitempty"`
Kind string `json:"kind"` Time int64 `json:"time"`
IP string `json:"ip"` Kind string `json:"kind"`
Domain string `json:"domain,omitempty"` IP string `json:"ip"`
Method string `json:"method,omitempty"` Domain string `json:"domain,omitempty"`
Path string `json:"path,omitempty"` Method string `json:"method,omitempty"`
Status int `json:"status,omitempty"` Path string `json:"path,omitempty"`
Reason string `json:"reason,omitempty"` Status int `json:"status,omitempty"`
Country string `json:"country,omitempty"` Reason string `json:"reason,omitempty"`
Agent string `json:"agent,omitempty"` Country string `json:"country,omitempty"`
Agent string `json:"agent,omitempty"`
} }
type TrafficBatch struct { type TrafficBatch struct {
@@ -62,6 +63,9 @@ type TrafficCountryDetail struct {
LastSeen int64 `json:"lastSeen"` LastSeen int64 `json:"lastSeen"`
} }
type TrafficView struct { type TrafficView struct {
Cities []TrafficMapCity `json:"cities"`
CitiesLimited bool `json:"citiesLimited"`
CityReady bool `json:"cityReady"`
MaxSequence int64 `json:"maxSequence"` MaxSequence int64 `json:"maxSequence"`
CountryDetails []TrafficCountryDetail `json:"countryDetails"` CountryDetails []TrafficCountryDetail `json:"countryDetails"`
@@ -209,7 +213,11 @@ func (s *Store) TrafficView(domain, kind string, external bool, hideMonitoring .
return v, err return v, err
} }
e.Monitoring = isMonitoringRequest(e, v.Settings.MonitoringRules) 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) v.Events = append(v.Events, e)
} }
err = rows.Err() err = rows.Err()
@@ -257,6 +265,14 @@ func (s *Store) TrafficView(domain, kind string, external bool, hideMonitoring .
if detailErr != nil { if detailErr != nil {
return v, detailErr 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)...) 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 { if err != nil {
return v, err return v, err

View File

@@ -6,6 +6,7 @@ import (
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"io" "io"
"math"
"net" "net"
"net/http" "net/http"
"os" "os"
@@ -26,6 +27,7 @@ var trafficCity struct {
db *maxminddb.Reader db *maxminddb.Reader
running bool running bool
message string message string
cache map[string]TrafficCityLocation
} }
func trafficCityPath() string { func trafficCityPath() string {
@@ -49,14 +51,34 @@ func trafficCityReady() bool {
defer trafficCity.Unlock() defer trafficCity.Unlock()
return trafficCity.db != nil 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) { func lookupTrafficCity(text string) (string, string) {
g := lookupTrafficCityLocation(text)
return g.City, g.Region
}
func lookupTrafficCityLocation(text string) TrafficCityLocation {
ip := net.ParseIP(text) ip := net.ParseIP(text)
if ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || !ip.IsGlobalUnicast() || !trafficCityReady() { if ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || !ip.IsGlobalUnicast() || !trafficCityReady() {
return "", "" return TrafficCityLocation{}
} }
trafficCity.Lock() trafficCity.Lock()
defer trafficCity.Unlock() defer trafficCity.Unlock()
if g, ok := trafficCity.cache[text]; ok {
return g
}
var r struct { var r struct {
Location struct {
Latitude *float64 `maxminddb:"latitude"`
Longitude *float64 `maxminddb:"longitude"`
} `maxminddb:"location"`
City struct { City struct {
Names map[string]string `maxminddb:"names"` Names map[string]string `maxminddb:"names"`
} `maxminddb:"city"` } `maxminddb:"city"`
@@ -65,13 +87,24 @@ func lookupTrafficCity(text string) (string, string) {
} `maxminddb:"subdivisions"` } `maxminddb:"subdivisions"`
} }
if trafficCity.db.Lookup(ip, &r) != nil { if trafficCity.db.Lookup(ip, &r) != nil {
return "", "" return TrafficCityLocation{}
} }
region := "" region := ""
if len(r.Subdivisions) > 0 { if len(r.Subdivisions) > 0 {
region = cityName(r.Subdivisions[0].Names) 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) { func trafficCityRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/settings/traffic/city-database", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/settings/traffic/city-database", func(w http.ResponseWriter, r *http.Request) {
@@ -101,9 +134,25 @@ func trafficCityRoutes(mux *http.ServeMux) {
writeJSON(w, status, nil) writeJSON(w, status, nil)
}) })
} }
func installTrafficCity() error { return installTrafficCityFrom(cityDownloadURL, cityDatabaseSHA1) } func cityMirrorURL() string {
base := strings.TrimRight(os.Getenv("GITEA_URL"), "/")
if base == "" {
base = "https://git.myown.center"
}
return base + "/maxim/ProxmoxDash/releases/download/v0.25.1/dbip-city-lite-2026-10.mmdb.gz"
}
func installTrafficCity() error {
if err := installTrafficCityFrom(cityMirrorURL(), cityDatabaseSHA1); err == nil {
return nil
}
return installTrafficCityFrom(cityDownloadURL, cityDatabaseSHA1)
}
func installTrafficCityFrom(source, expectedHash string) error { func installTrafficCityFrom(source, expectedHash string) error {
client := &http.Client{Timeout: 15 * time.Minute} transport := http.DefaultTransport.(*http.Transport).Clone()
transport.ResponseHeaderTimeout = 20 * time.Second
transport.DialContext = (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext
defer transport.CloseIdleConnections()
client := &http.Client{Timeout: 15 * time.Minute, Transport: transport}
response, err := client.Get(source) response, err := client.Get(source)
if err != nil { if err != nil {
return err return err
@@ -162,5 +211,6 @@ func installTrafficCityFrom(source, expectedHash string) error {
trafficCity.db.Close() trafficCity.db.Close()
} }
trafficCity.db = db trafficCity.db = db
trafficCity.cache = nil
return nil return nil
} }

View File

@@ -7,6 +7,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
) )
func TestCityInstallRejectsBadDownload(t *testing.T) { func TestCityInstallRejectsBadDownload(t *testing.T) {
@@ -46,6 +47,33 @@ func TestCityDatabaseDownloaded(t *testing.T) {
if err := installTrafficCityFrom(server.URL, cityDatabaseSHA1); err != nil { if err := installTrafficCityFrom(server.URL, cityDatabaseSHA1); err != nil {
t.Fatal(err) 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") city, region := lookupTrafficCity("20.194.96.114")
t.Logf("city=%q region=%q", city, region) t.Logf("city=%q region=%q", city, region)
if city == "" { if city == "" {

71
traffic_map_cities.go Normal file
View File

@@ -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
}