Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46bd6e8be6 | ||
|
|
03b6c1f289 | ||
|
|
14d57120cd |
46
README.md
46
README.md
@@ -645,7 +645,49 @@ Dashboard. Кнопка солнца/луны меняет тему; выбор
|
||||
|
||||
Четыре карточки статистики открывают окна с итогом, объяснением метрики,
|
||||
разбивкой по странам и примерами событий. Счётчики стран считаются сервером
|
||||
по всей доступной истории за 24 часа с текущими фильтрами. Примеры — до 30
|
||||
совпадений из последних 100 событий, что явно отмечено в интерфейсе. Открытое
|
||||
по всей доступной истории за 24 часа с текущими фильтрами. Начиная с v0.25.0
|
||||
история загружается целиком по страницам, без ограничения последними 100 событиями. Открытое
|
||||
окно фиксирует данные на момент открытия; временная отметка показана внутри.
|
||||
Сетевые потоки остаются отдельным текущим снимком, не суточной статистикой.
|
||||
|
||||
## Карта и полная история (v0.25.0)
|
||||
|
||||
Карта использует векторные границы стран: при зуме 100–600% они перерисовываются,
|
||||
а маркеры и подписи сохраняют размер. Это обзорная карта стран, не уличная карта.
|
||||
Карточки и окна стран загружают всю сохранённую историю за последние 24 часа
|
||||
по 50 записей; фильтры учитываются сервером. В карточке IP показаны все уникальные
|
||||
HTTP IP с количеством запросов. Переходы между страницами фиксируют снимок истории.
|
||||
|
||||
Для городов откройте настройки карты и нажмите «Установить базу городов».
|
||||
Dashboard скачает DB-IP City Lite 2026-10 (~58 МБ сжатая, ~121 МБ на диске),
|
||||
проверит контрольную сумму и сохранит `dbip-city.mmdb` рядом с `DASHBOARD_DB_PATH`.
|
||||
IP посетителей не отправляются во внешние сервисы. Город и регион отображаются
|
||||
в истории и списке IP; местоположение приблизительное и может относиться к VPN
|
||||
или провайдеру. Для части 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, а не фактическому местоположению человека.
|
||||
|
||||
@@ -12,3 +12,9 @@ https://creativecommons.org/licenses/by/4.0/
|
||||
https://db-ip.com/db/download/ip-to-country-lite
|
||||
The database is unmodified. The traffic page displays the required DB-IP link.
|
||||
Countries are approximate; plotted positions are country labels, not exact IP locations.
|
||||
|
||||
Optional DB-IP City Lite October 2026 is downloaded separately through Settings.
|
||||
Source: https://download.db-ip.com/free/dbip-city-lite-2026-10.mmdb.gz
|
||||
License: CC BY 4.0, https://creativecommons.org/licenses/by/4.0/
|
||||
https://db-ip.com/db/download/ip-to-city-lite
|
||||
The database is unmodified; city and region are approximate.
|
||||
|
||||
@@ -26,12 +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"] 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}}
|
||||
#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">
|
||||
<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>
|
||||
<section class="metrics" aria-label="Статистика за последние 24 часа"><button class="metric" data-metric="unique"><div><small>Уникальные IP · 24 ч</small><b id="unique">—</b></div><i aria-hidden="true">⌁</i></button><button class="metric request" data-metric="requests"><div><small>HTTP-запросы · 24 ч</small><b id="requests">—</b></div><i aria-hidden="true">↗</i></button><button class="metric alert" data-metric="alerts"><div><small>Срабатывания · 24 ч</small><b id="alerts">—</b></div><i aria-hidden="true">ϟ</i></button><button class="metric decision" data-metric="decisions"><div><small>Решения CrowdSec · 24 ч</small><b id="decisions">—</b></div><i aria-hidden="true">◇</i></button></section>
|
||||
<nav id="mobileTabs" aria-label="Раздел карты"><button class="active" data-mobile-tab="map">Карта</button><button data-mobile-tab="feed">События</button><button data-mobile-tab="stats">Статистика</button></nav>
|
||||
<section class="dashboard"><div class="panel map-panel"><div class="map-heading"><span class="section-label">Глобальная активность</span><button class="source-status" id="sourceDetails" aria-label="Состояние источника"><span id="sourceName">Источник…</span><span id="status" class="status-box" role="status">Подключение</span><span>ⓘ</span></button></div><div class="map-stage"><button id="networkSummary" hidden aria-label="Сетевая активность на карте"></button><div class="map-controls"><button id="zoomOut" aria-label="Отдалить карту">−</button><output id="zoomLevel">100%</output><button id="zoomIn" aria-label="Приблизить карту">+</button><button id="zoomReset" aria-label="Сбросить масштаб карты">↺</button></div><canvas tabindex="0" id="world" role="img" aria-label="Карта стран источников; данные доступны также в статистике и ленте событий"></canvas></div><div class="map-foot"><div class="legend"><span><i class="dot"></i>HTTP</span><span><i class="dot alert"></i>Срабатывание</span><span><i class="dot decision"></i>Решение</span></div><span id="unmapped">Без геолокации: —</span></div></div>
|
||||
<section class="dashboard"><div class="panel map-panel"><div class="map-heading"><span class="section-label">Глобальная активность</span><button class="source-status" id="sourceDetails" aria-label="Состояние источника"><span id="sourceName">Источник…</span><span id="status" class="status-box" role="status">Подключение</span><span>ⓘ</span></button></div><div class="map-stage"><button id="networkSummary" hidden aria-label="Сетевая активность на карте"></button><div class="map-controls"><button id="zoomOut" aria-label="Отдалить карту">−</button><output id="zoomLevel">100%</output><button id="zoomIn" aria-label="Приблизить карту">+</button><button id="zoomReset" aria-label="Сбросить масштаб карты">↺</button></div><canvas tabindex="0" id="world" role="img" aria-label="Карта стран источников; данные доступны также в статистике и ленте событий"></canvas></div><div class="map-foot"><div class="legend"><span><i class="dot"></i>HTTP</span><span><i class="dot alert"></i>Срабатывание</span><span><i class="dot decision"></i>Решение</span></div><span id="cityLayerNote" title="Города по приблизительным координатам GeoIP"></span><span id="unmapped">Без геолокации: —</span></div></div>
|
||||
<aside class="panel feed-panel"><div class="feed-heading"><h2>Поток событий</h2><span class="note" id="feedMode">LIVE / 100</span></div><div class="feed" id="feed"><div class="empty">Ожидание первого события…</div></div><div class="pager"><button id="feedPrev" aria-label="Предыдущая страница событий">←</button><span id="feedPage">—</span><button id="feedNext" aria-label="Следующая страница событий">→</button><button id="latest">К новым ↗</button></div></aside></section>
|
||||
<section class="bottom"><div class="panel chart-panel"><div class="bottom-head"><h2>Ритм запросов <span class="note">/ 60 мин</span></h2><span class="note"><b id="qps" style="color:var(--blue)">—</b> req/s</span></div><canvas class="chart" id="chart" role="img" aria-label="HTTP-запросы в минуту за последний час; req/s — среднее за последние 60 секунд"></canvas></div><div class="panel"><div class="bottom-head"><h2>География</h2><span class="note">ТОП 7 / события</span></div><div id="countries"></div></div><div class="panel"><div class="bottom-head"><h2>Источники</h2><span class="note">ТОП 7 / IP</span></div><div id="ips"></div></div></section>
|
||||
<footer><div class="credits"><span><a href="https://db-ip.com" target="_blank" rel="noopener">IP Geolocation by DB-IP</a> · <span id="geoDate"></span></span><a class="license-credit" href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a><a class="natural-credit" href="https://www.naturalearthdata.com/">Natural Earth</a></div><button class="footer-help" id="about">Как читать данные ⓘ</button><a href="/#traffic-settings">Настройки ↗</a></footer>
|
||||
@@ -43,15 +44,20 @@ 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=isLight()?'#b6cbd4':'#324a5b';for(let y=0,row=0;y<h;y+=step,row++){for(let x=(row%2)*step/2;x<w;x+=step){if(pixels[(Math.floor(y)*w+Math.floor(x))*4+3]){ctx.beginPath();ctx.arc(x,y,step*.32,0,Math.PI*2);ctx.fill()}}}return off}
|
||||
function mapBackground(w,h){const off=document.createElement('canvas'),dpr=Math.min(devicePixelRatio||1,2);off.width=Math.round(w*dpr);off.height=Math.round(h*dpr);const ctx=off.getContext('2d');ctx.setTransform(dpr,0,0,dpr,0,0);ctx.translate(mapX,mapY);ctx.scale(mapZoom,mapZoom);ctx.fillStyle=isLight()?'#dce9ee':'#1c3443';ctx.strokeStyle=isLight()?'#9cb6c2':'#456171';ctx.lineWidth=.7/mapZoom;
|
||||
for(const c of world){const polygons=c.geometry.type==='Polygon'?[c.geometry.coordinates]:c.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()}}
|
||||
if(mapZoom>=2){ctx.font=(10/mapZoom)+'px system-ui';ctx.textAlign='center';ctx.fillStyle=isLight()?'#45616f':'#9fb7c6';const occupied=[];for(const c of world){const p=project(c.lon,c.lat,w,h),x=p[0]*mapZoom+mapX,y=p[1]*mapZoom+mapY;if(x<20||x>w-20||y<10||y>h-10)continue;const width=ctx.measureText(c.name).width*mapZoom;if(occupied.some(o=>Math.abs(x-o.x)<(width+o.w)/2+8&&Math.abs(y-o.y)<18))continue;ctx.fillText(c.name,p[0],p[1]+18/mapZoom);occupied.push({x,y,w:width})}}return off}
|
||||
let baseMapKey='';
|
||||
function networkVisible(){return !!view?.network&&el('networkOverlay').checked&&view.settings.networkEnabled}
|
||||
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);ctx.save();ctx.translate(mapX,mapY);ctx.scale(mapZoom,mapZoom);if(baseMap)ctx.drawImage(baseMap,0,0);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];
|
||||
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=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);
|
||||
const cityLayer=mapZoom>=2&&view.cityReady,cityCounts=new Map();el("cityLayerNote").textContent=view.cityReady?(cityLayer?"Города: "+(view.cities||[]).length+(view.citiesLimited?" (топ 500)":""):"Города — от 200%") : "Города: нужна GeoIP-база";
|
||||
if(cityLayer){const labels=[];for(const c of view.cities||[]){const total=c.requests+c.alerts+c.decisions;cityCounts.set(c.country,(cityCounts.get(c.country)||0)+total);const [x,y]=project(c.longitude,c.latitude,w,h),sx=x*mapZoom+mapX,sy=y*mapZoom+mapY;if(sx<0||sx>w||sy<0||sy>h)continue;ctx.fillStyle=c.requests?colors.request:c.alerts?colors.alert:colors.decision;ctx.beginPath();ctx.arc(x,y,(3+Math.min(3,Math.log2(total+1)/2))/mapZoom,0,Math.PI*2);ctx.fill();hitPoints.push({x,y,city:c});ctx.font=(11/mapZoom)+'px system-ui';ctx.textAlign='left';const width=ctx.measureText(c.city).width*mapZoom;if(!labels.some(p=>Math.abs(sx-p.x)<Math.max(width,p.width)+12&&Math.abs(sy-p.y)<18)){ctx.fillStyle=isLight()?'#203d4c':'#e1edf4';ctx.fillText(c.city,x+9/mapZoom,y-5/mapZoom);labels.push({x:sx,y:sy,width})}}}
|
||||
for(const c of view.countries){const country=countryMap.get(c.name),count=Math.max(0,c.count-(cityCounts.get(c.name)||0));if(!country||!count)continue;const [x,y]=project(country.lon,country.lat,w,h);ctx.fillStyle=colors.request;ctx.globalAlpha=.75;ctx.beginPath();ctx.arc(x,y,(2+Math.min(5,Math.log2(count+1)))/mapZoom,0,Math.PI*2);ctx.fill();hitPoints.push({x,y,text:country.name+' · '+num(count)+' событий'})}ctx.globalAlpha=1;
|
||||
if(networkFresh()){for(const p of view.network.points){const c=countryMap.get(p.country);if(!c)continue;const [x,y]=project(c.lon,c.lat,w,h),radius=(8+['tcp','udp','icmp'].indexOf(p.protocol)*3)/mapZoom;ctx.strokeStyle=colors[p.protocol];ctx.lineWidth=1.4/mapZoom;ctx.globalAlpha=.8;ctx.beginPath();ctx.arc(x,y,radius,0,Math.PI*2);ctx.stroke();const text=p.protocol.toUpperCase()+' '+(p.direction==='out'?'← исходящие':'→ входящие')+': '+num(p.count)+' потоков';const existing=hitPoints.find(h=>Math.hypot(h.x-x,h.y-y)<1);if(existing)existing.text+=' · '+text;else hitPoints.push({x,y,text:c.name+' · '+text})}ctx.globalAlpha=1}
|
||||
pulses=pulses.filter(p=>now-p.start<3200&&(!p.network||networkFresh()));if(matchMedia('(prefers-reduced-motion: reduce)').matches)pulses=[];for(const p of pulses){const c=countryMap.get(p.country);if(!c)continue;const remote=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();
|
||||
if(!document.hidden&&!paused)requestAnimationFrame(drawMap)
|
||||
@@ -66,7 +72,7 @@ function render(data){view=data;el('networkLink').hidden=!data.settings.networkE
|
||||
const unmapped=data.countries.filter(c=>!countryMap.has(c.name)).reduce((n,c)=>n+c.count,0);el('unmapped').textContent='Локальные / без геолокации: '+num(unmapped);
|
||||
renderFeed();
|
||||
if(networkFresh()&&data.network.receivedAt!==lastNetworkSnapshot){lastNetworkSnapshot=data.network.receivedAt;for(const p of data.network.points.slice(0,24)){pulses.push({country:p.country,kind:p.protocol,direction:p.direction,network:true,start:performance.now()})}}
|
||||
for(const e of data.events){if(!seen.has(e.sequence)&&e.time>=data.now-15&&pulses.length<70)pulses.push({country:e.country,kind:e.kind,start:performance.now()});seen.add(e.sequence)}if(seen.size>5000)seen=new Set(data.events.map(e=>e.sequence));drawChart();if(paused)requestAnimationFrame(drawMap);
|
||||
for(const e of data.events){if(!seen.has(e.sequence)&&e.time>=data.now-15&&pulses.length<70)pulses.push({country:e.country,location:e.location,kind:e.kind,start:performance.now()});seen.add(e.sequence)}if(seen.size>5000)seen=new Set(data.events.map(e=>e.sequence));drawChart();if(paused)requestAnimationFrame(drawMap);
|
||||
}
|
||||
function renderNetworkSummary(){el('networkSummary').hidden=!networkVisible();if(!networkVisible())return;const n=view.network,fresh=networkFresh();el('networkSummary').innerHTML=(fresh?'Сеть · ':'Сеть · нет свежего снимка · ')+['tcp','udp','icmp'].map(p=>'<span style="color:'+colors[p]+'">'+p.toUpperCase()+' '+num(n[p])+'</span>').join(' · ')+(n.error||n.limited?' · ⚠':'')+' ⓘ';el('networkSummary').title='Текущие потоки conntrack. Фильтры домена и HTTP/CrowdSec на них не влияют.'}
|
||||
el('networkSummary').onclick=()=>{const n=view.network;showDetails('Сетевая активность на карте','<p>Зелёный — TCP, розовый — UDP, жёлтый — ICMP/ICMPv6. Кольца показывают страны текущих потоков, импульс означает получение нового снимка (~5 с), а не новый запрос или пакет.</p><p>Входящий поток идёт к NPM, исходящий — от NPM. Если оба конца публичные, направление определяется по инициатору; это не трассировка маршрута.</p><p>HTTP-фильтры домена и типа события относятся только к HTTP/CrowdSec. «Только внешние IP» и «Скрыть мониторинг» применяются также к сетевому слою. Для сети используются только правила без домена/пути.</p><p>Снимок: '+(n.at?new Date(n.at*1000).toLocaleString('ru-RU'):'ещё не получен')+'. Локальные / без страны: '+num(n.unmapped)+'. Скрыто потоков мониторинга: '+num(n.monitoringHidden)+'.</p><p>'+safe(n.error||(n.limited?'Достигнут лимит 5 000 потоков':'При устаревании снимка сетевые кольца и импульсы скрываются.'))+'</p><p><a href="/network">Открыть таблицу TCP/UDP/ICMP ↗</a></p>')};
|
||||
@@ -77,10 +83,10 @@ function renderFeed(){if(!view)return;const rowHeight=innerHeight<=650&&innerWid
|
||||
el('feedPage').textContent=(feedPage+1)+' / '+pages;el('feedPrev').disabled=feedPage===0;el('feedNext').disabled=feedPage>=pages-1;el('feedMode').textContent=frozenEvents?'ИСТОРИЯ / '+items.length:'LIVE / '+items.length;
|
||||
}
|
||||
el('feedNext').onclick=()=>{if(!frozenEvents)frozenEvents=view.events.slice();feedPage++;renderFeed()};el('feedPrev').onclick=()=>{feedPage=Math.max(0,feedPage-1);renderFeed()};el('latest').onclick=()=>{frozenEvents=null;feedPage=0;renderFeed()};
|
||||
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)],['Время',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('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 событий доступны по страницам; нажмите строку, чтобы увидеть детали целиком.</p>');
|
||||
el('about').onclick=()=>showDetails('Как читать данные','<p>Голубой — HTTP-запросы, оранжевый — срабатывания CrowdSec, сиреневый — решения о ban или captcha.</p><p>Решение о бане не подтверждает конкретный отброшенный запрос. HTTP 401/403/502 сами по себе не означают блокировку CrowdSec.</p><p>Счётчики за 24 часа следуют выбранным фильтрам. Уникальный IP не равен человеку. Req/s — среднее за 60 секунд; график показывает запросы в минуту за последний час.</p><p>При масштабе от 200% и установленной базе городов появляются города с HTTP/CrowdSec-активностью. Геолокация приблизительная. Сетевые потоки и события без города остаются на уровне стран. Показываются до 500 наиболее активных городов; остальные события учитываются в точках стран. Локальные адреса остаются в ленте без страны. «Скрыть мониторинг» исключает из отображения HTTP-проверки по правилам в настройках; события CrowdSec сохраняются на экране. Исходная история не удаляется. В ленте показаны последние 100 событий; полная сохранённая история за 24 часа доступна в карточках и окнах стран; нажмите строку, чтобы увидеть детали целиком.</p>');
|
||||
el('closeDetails').onclick=()=>el('details').close();el('details').onclick=e=>{if(e.target===el('details'))el('details').close()};
|
||||
document.querySelectorAll('[data-mobile-tab]').forEach(b=>b.onclick=()=>{document.body.dataset.mobile=b.dataset.mobileTab;document.querySelectorAll('[data-mobile-tab]').forEach(t=>t.classList.toggle('active',t===b));drawChart();renderFeed()});
|
||||
async function poll(force=false){if((paused&&!force)||busy||document.hidden)return;busy=true;try{const q=new URLSearchParams({domain:el('domain').value,kind,hideMonitoring:el('hideMonitoring').checked?'1':'0',external:el('external').checked?'1':'0'}),r=await fetch('/api/traffic?'+q);if(!r.ok){if(r.status===404){view=null;pulses=[];location.replace('/');return;}throw Error(r.status===404?'Карта отключена':'Не удалось обновить данные')}const data=await r.json();lastPoll=Date.now();render(data)}catch(e){el('live').textContent='НЕТ ОБНОВЛЕНИЙ';el('live').classList.add('stale');statusMessages=[e.message];el('status').textContent=e.message;el('status').classList.add('error')}finally{busy=false}}
|
||||
@@ -104,18 +110,26 @@ function countryStats(code){return (view?.countryDetails||[]).find(c=>c.country=
|
||||
function statGrid(s){return '<div class="detail-grid">'+[['HTTP-запросы',s.requests],['Уникальные HTTP IP',s.uniqueIps],['Срабатывания',s.alerts],['Решения CrowdSec',s.decisions]].map(([k,v])=>'<div class="detail-stat"><small>'+k+'</small><b>'+num(v)+'</b></div>').join('')+'</div>'}
|
||||
function countryNetwork(code){if(!view?.network)return '';const points=view.network.points.filter(p=>p.country===code);return '<p class="modal-note">Сетевой снимок'+(networkFresh()?'':' · устарел / слой выключен')+': '+(points.length?points.map(p=>p.protocol.toUpperCase()+' '+(p.direction==='out'?'исходящие':'входящие')+' '+num(p.count)).join(' · '):'нет потоков этой страны')+'</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.slice(0,30).map(e=>'<li>'+safe(e.ip)+' · '+safe(eventText(e))+'<small>'+safe(countryName(e.country))+' · '+new Date(e.time*1000).toLocaleString('ru-RU')+'</small></li>').join('')+'</ul>':'<p>В последних 100 событиях подходящих записей нет.</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><p class="modal-note">Геолокация IP приблизительная. Ниже — до 30 совпадений из последних 100 событий.</p>'+eventList(view.events.filter(e=>e.country===code)))}
|
||||
function eventList(events){return events.length?'<ul class="detail-list">'+events.map(e=>'<li>'+safe(e.ip)+' · '+safe(kindLabels[e.kind]||e.kind)+' · '+safe(eventText(e))+(e.monitoring?' · мониторинг':'')+'<small>'+safe([countryName(e.country),e.region,e.city].filter(Boolean).join(' · '))+' · '+new Date(e.time*1000).toLocaleString('ru-RU')+'</small></li>').join('')+'</ul>':'<p>В выбранном периоде записей нет.</p>'}
|
||||
function openCountry(code){if(!view)return;const c=countryStats(code);showDetails(countryName(code),filterNote()+statGrid(c)+countryNetwork(code)+'<p>Последнее событие: '+(c.lastSeen?new Date(c.lastSeen*1000).toLocaleString('ru-RU'):'нет за выбранный период')+'</p>'+historyShell());startHistory({country:code})}
|
||||
function cityAt(x,y){if(mapZoom<2)return null;let nearest=null,distance=12;for(const p of hitPoints){if(!p.city)continue;const d=Math.hypot(x-(p.x*mapZoom+mapX),y-(p.y*mapZoom+mapY));if(d<distance){nearest=p.city;distance=d}}return nearest}
|
||||
function openCity(c){showDetails(c.city,filterNote()+'<p>'+safe([countryName(c.country),c.region].filter(Boolean).join(' · '))+'</p>'+statGrid(c)+'<p class="modal-note">Приблизительное местоположение IP по DB-IP. Это может быть город провайдера или VPN, а не местоположение посетителя.</p><p>Последнее событие: '+new Date(c.lastSeen*1000).toLocaleString('ru-RU')+'</p><button class="country-choice" data-country="'+safe(c.country)+'">Вся история страны: '+safe(countryName(c.country))+'</button>')}
|
||||
function hideCountryTip(){el('tooltip').style.display='none';hoverCountry=null}
|
||||
function showCountryTip(e){const r=el('world').getBoundingClientRect(),c=countryAt(e.clientX-r.left,e.clientY-r.top);hoverCountry=c||null;const tip=el('tooltip');if(!c){tip.style.display='none';redrawMap();return}const s=countryStats(c.code);tip.innerHTML='<strong>'+safe(c.name)+'</strong>HTTP '+num(s.requests)+' · IP '+num(s.uniqueIps)+'<br>Срабатывания '+num(s.alerts)+' · решения '+num(s.decisions)+countryNetwork(c.code)+'<small>24 ч · текущие фильтры · нажмите для деталей</small>';tip.style.display='block';tip.style.left=Math.max(8,Math.min(innerWidth-tip.offsetWidth-8,e.clientX+14))+'px';tip.style.top=Math.max(8,Math.min(innerHeight-tip.offsetHeight-8,e.clientY+14))+'px';redrawMap()}
|
||||
function showCountryTip(e){const r=el('world').getBoundingClientRect(),city=cityAt(e.clientX-r.left,e.clientY-r.top),c=city?countryMap.get(city.country):countryAt(e.clientX-r.left,e.clientY-r.top);hoverCountry=c||null;const tip=el('tooltip');if(!c){tip.style.display='none';redrawMap();return}const s=city||countryStats(c.code);tip.innerHTML='<strong>'+safe(city?city.city+' · '+c.name:c.name)+'</strong>HTTP '+num(s.requests)+' · IP '+num(s.uniqueIps)+'<br>Срабатывания '+num(s.alerts)+' · решения '+num(s.decisions)+(city?'<p class="modal-note">Геолокация IP приблизительная</p>':countryNetwork(c.code))+'<small>24 ч · текущие фильтры · нажмите для деталей</small>';tip.style.display='block';tip.style.left=Math.max(8,Math.min(innerWidth-tip.offsetWidth-8,e.clientX+14))+'px';tip.style.top=Math.max(8,Math.min(innerHeight-tip.offsetHeight-8,e.clientY+14))+'px';redrawMap()}
|
||||
const mapCanvas=el('world');
|
||||
mapCanvas.onpointerdown=e=>{mapCanvas.setPointerCapture(e.pointerId);pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});drag={x:e.clientX,y:e.clientY,moved:pointers.size>1};hideCountryTip()};
|
||||
mapCanvas.onpointermove=e=>{if(!pointers.has(e.pointerId)){showCountryTip(e);return}const old=pointers.get(e.pointerId),next={x:e.clientX,y:e.clientY};if(pointers.size===2){const other=[...pointers.entries()].find(([id])=>id!==e.pointerId)[1],before=Math.hypot(old.x-other.x,old.y-other.y),after=Math.hypot(next.x-other.x,next.y-other.y),r=mapCanvas.getBoundingClientRect();if(before>0)zoomMap(after/before,(next.x+other.x)/2-r.left,(next.y+other.y)/2-r.top);drag.moved=true}else{mapX+=next.x-old.x;mapY+=next.y-old.y;if(Math.hypot(next.x-drag.x,next.y-drag.y)>4)drag.moved=true;clampMap();redrawMap()}pointers.set(e.pointerId,next)};
|
||||
mapCanvas.onpointerup=e=>{const moved=drag?.moved; pointers.delete(e.pointerId);if(!moved&&pointers.size===0){const r=mapCanvas.getBoundingClientRect(),c=countryAt(e.clientX-r.left,e.clientY-r.top);if(c)openCountry(c.code)}if(!pointers.size)drag=null};
|
||||
mapCanvas.onpointerup=e=>{const moved=drag?.moved; pointers.delete(e.pointerId);if(!moved&&pointers.size===0){const r=mapCanvas.getBoundingClientRect(),c=countryAt(e.clientX-r.left,e.clientY-r.top);const city=cityAt(e.clientX-r.left,e.clientY-r.top);if(city)openCity(city);else if(c)openCountry(c.code)}if(!pointers.size)drag=null};
|
||||
mapCanvas.onpointercancel=e=>{pointers.clear();drag=null;hideCountryTip()};mapCanvas.onpointerleave=()=>{hideCountryTip();redrawMap()};
|
||||
window.addEventListener('resize',clampMap);
|
||||
document.querySelectorAll('[data-metric]').forEach(b=>b.onclick=()=>{if(!view)return;const key=b.dataset.metric,title={unique:'Уникальные HTTP IP',requests:'HTTP-запросы',alerts:'Срабатывания CrowdSec',decisions:'Решения CrowdSec'}[key],field=key==='unique'?'uniqueIps':key,type={unique:'request',requests:'request',alerts:'alert',decisions:'decision'}[key],rows=(view.countryDetails||[]).filter(c=>c[field]>0).sort((a,b)=>b[field]-a[field]);const explanation={unique:'Уникальный IP не равен человеку. Учитываются IP HTTP-запросов.',requests:'Количество HTTP-запросов. Ошибки HTTP сами по себе не доказывают атаку.',alerts:'Срабатывания сценариев CrowdSec. Одно срабатывание может объединять несколько запросов.',decisions:'Решения CrowdSec, например ban или captcha. Это не число отброшенных пакетов и не список активных банов.'}[key];showDetails(title,filterNote()+'<div class="detail-stat"><small>'+title+'</small><b>'+num(view[field])+'</b></div><p>'+explanation+'</p><h3>По странам</h3>'+rows.map(c=>'<button class="country-choice" data-country="'+safe(c.country)+'">'+safe(countryName(c.country))+' · '+num(c[field])+'</button>').join('')+'<p class="modal-note">Примеры: до 30 совпадений из последних 100 событий, не полный журнал.</p>'+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)};
|
||||
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 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;
|
||||
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};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}}
|
||||
el('details').addEventListener('close',()=>{historyVersion++;historyBusy=false;historyQuery=null});
|
||||
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);
|
||||
|
||||
55
traffic.go
55
traffic.go
@@ -12,19 +12,22 @@ import (
|
||||
)
|
||||
|
||||
type TrafficEvent struct {
|
||||
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 {
|
||||
@@ -60,6 +63,10 @@ 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"`
|
||||
|
||||
Network *NetworkMapView `json:"network,omitempty"`
|
||||
@@ -170,8 +177,11 @@ func (s *Store) TrafficView(domain, kind string, external bool, hideMonitoring .
|
||||
network := makeNetworkMap(snapshot, external)
|
||||
v.Network = &network
|
||||
}
|
||||
where := ` WHERE ts>=? AND agent=?`
|
||||
args := []any{now - 86400, v.Settings.AgentID}
|
||||
if err := s.db.QueryRow(`SELECT COALESCE(MAX(seq),0) FROM traffic_events WHERE agent=?`, v.Settings.AgentID).Scan(&v.MaxSequence); err != nil {
|
||||
return v, err
|
||||
}
|
||||
where := ` WHERE ts>=? AND agent=? AND ts<=? AND seq<=?`
|
||||
args := []any{now - 86400, v.Settings.AgentID, now, v.MaxSequence}
|
||||
if hide {
|
||||
clause, params := monitoringSQL(v.Settings.MonitoringRules)
|
||||
where += clause
|
||||
@@ -203,6 +213,11 @@ func (s *Store) TrafficView(domain, kind string, external bool, hideMonitoring .
|
||||
return v, err
|
||||
}
|
||||
e.Monitoring = isMonitoringRequest(e, v.Settings.MonitoringRules)
|
||||
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()
|
||||
@@ -250,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
|
||||
@@ -291,6 +314,8 @@ func (s *Store) TrafficView(domain, kind string, external bool, hideMonitoring .
|
||||
|
||||
func trafficRoutes(mux *http.ServeMux, s *Store) {
|
||||
trafficSettingsRoute(mux, s)
|
||||
s.trafficHistoryRoute(mux)
|
||||
trafficCityRoutes(mux)
|
||||
networkRoutes(mux, s)
|
||||
mux.HandleFunc("/traffic", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.TrafficSettings().Enabled {
|
||||
|
||||
216
traffic_city.go
Normal file
216
traffic_city.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/oschwald/maxminddb-golang"
|
||||
)
|
||||
|
||||
const cityDownloadURL = "https://download.db-ip.com/free/dbip-city-lite-2026-10.mmdb.gz"
|
||||
const cityDatabaseSHA1 = "df17bd24390108ed1d24dcb7718dc60f6b9abd4f"
|
||||
|
||||
var trafficCity struct {
|
||||
sync.Mutex
|
||||
once sync.Once
|
||||
db *maxminddb.Reader
|
||||
running bool
|
||||
message string
|
||||
cache map[string]TrafficCityLocation
|
||||
}
|
||||
|
||||
func trafficCityPath() string {
|
||||
db := os.Getenv("DASHBOARD_DB_PATH")
|
||||
if db == "" {
|
||||
db = defaultDatabasePath
|
||||
}
|
||||
return filepath.Join(filepath.Dir(db), "dbip-city.mmdb")
|
||||
}
|
||||
func trafficCityReady() bool {
|
||||
trafficCity.once.Do(func() {
|
||||
trafficCity.Lock()
|
||||
defer trafficCity.Unlock()
|
||||
trafficCity.db, _ = maxminddb.Open(trafficCityPath())
|
||||
if trafficCity.db != nil && !strings.Contains(strings.ToLower(trafficCity.db.Metadata.DatabaseType), "city") {
|
||||
trafficCity.db.Close()
|
||||
trafficCity.db = nil
|
||||
}
|
||||
})
|
||||
trafficCity.Lock()
|
||||
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 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"`
|
||||
Subdivisions []struct {
|
||||
Names map[string]string `maxminddb:"names"`
|
||||
} `maxminddb:"subdivisions"`
|
||||
}
|
||||
if trafficCity.db.Lookup(ip, &r) != nil {
|
||||
return TrafficCityLocation{}
|
||||
}
|
||||
region := ""
|
||||
if len(r.Subdivisions) > 0 {
|
||||
region = cityName(r.Subdivisions[0].Names)
|
||||
}
|
||||
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) {
|
||||
if r.Method != "GET" && r.Method != "POST" {
|
||||
w.WriteHeader(405)
|
||||
return
|
||||
}
|
||||
trafficCityReady()
|
||||
trafficCity.Lock()
|
||||
if r.Method == "POST" && !trafficCity.running && trafficCity.db == nil {
|
||||
trafficCity.running = true
|
||||
trafficCity.message = "Загрузка базы городов (около 58 МБ)…"
|
||||
go func() {
|
||||
err := installTrafficCity()
|
||||
trafficCity.Lock()
|
||||
defer trafficCity.Unlock()
|
||||
trafficCity.running = false
|
||||
if err != nil {
|
||||
trafficCity.message = "Не удалось установить базу: " + err.Error()
|
||||
} else {
|
||||
trafficCity.message = "База городов DB-IP City Lite 2026-10 установлена"
|
||||
}
|
||||
}()
|
||||
}
|
||||
status := map[string]any{"ready": trafficCity.db != nil, "running": trafficCity.running, "message": trafficCity.message, "date": "2026-10"}
|
||||
trafficCity.Unlock()
|
||||
writeJSON(w, status, nil)
|
||||
})
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != 200 {
|
||||
return fmt.Errorf("HTTP %d", response.StatusCode)
|
||||
}
|
||||
reader, err := gzip.NewReader(io.LimitReader(response.Body, 70<<20))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
path := trafficCityPath()
|
||||
if err = os.MkdirAll(filepath.Dir(path), 0750); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.CreateTemp(filepath.Dir(path), ".city-*.mmdb")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(file.Name())
|
||||
defer file.Close()
|
||||
hash := sha1.New()
|
||||
n, err := io.Copy(io.MultiWriter(file, hash), io.LimitReader(reader, (160<<20)+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 160<<20 {
|
||||
return fmt.Errorf("база превышает лимит размера")
|
||||
}
|
||||
if hex.EncodeToString(hash.Sum(nil)) != expectedHash {
|
||||
return fmt.Errorf("контрольная сумма базы не совпала")
|
||||
}
|
||||
if err = file.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := maxminddb.Open(file.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(db.Metadata.DatabaseType), "city") {
|
||||
db.Close()
|
||||
return fmt.Errorf("неверный тип GeoIP-базы")
|
||||
}
|
||||
if err = os.Rename(file.Name(), path); err != nil {
|
||||
db.Close()
|
||||
return err
|
||||
}
|
||||
trafficCity.Lock()
|
||||
defer trafficCity.Unlock()
|
||||
if trafficCity.db != nil {
|
||||
trafficCity.db.Close()
|
||||
}
|
||||
trafficCity.db = db
|
||||
trafficCity.cache = nil
|
||||
return nil
|
||||
}
|
||||
82
traffic_city_test.go
Normal file
82
traffic_city_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCityInstallRejectsBadDownload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DASHBOARD_DB_PATH", filepath.Join(dir, "dashboard.db"))
|
||||
existing := []byte("existing database")
|
||||
if err := os.WriteFile(trafficCityPath(), existing, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
g := gzip.NewWriter(w)
|
||||
g.Write([]byte("corrupt database"))
|
||||
g.Close()
|
||||
}))
|
||||
defer server.Close()
|
||||
if err := installTrafficCityFrom(server.URL, cityDatabaseSHA1); err == nil {
|
||||
t.Fatal("bad checksum accepted")
|
||||
}
|
||||
got, err := os.ReadFile(trafficCityPath())
|
||||
if err != nil || string(got) != string(existing) {
|
||||
t.Fatal("existing database replaced on failure")
|
||||
}
|
||||
files, _ := filepath.Glob(filepath.Join(dir, ".city-*"))
|
||||
if len(files) != 0 {
|
||||
t.Fatal("partial file left behind")
|
||||
}
|
||||
}
|
||||
func TestCityDatabaseDownloaded(t *testing.T) {
|
||||
source := os.Getenv("DASHBOARD_CITY_TEST_GZIP")
|
||||
if source == "" {
|
||||
t.Skip("optional official database integration test")
|
||||
}
|
||||
t.Setenv("DASHBOARD_DB_PATH", filepath.Join(t.TempDir(), "dashboard.db"))
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, source) }))
|
||||
defer server.Close()
|
||||
trafficCityReady()
|
||||
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 == "" {
|
||||
t.Fatal("public IP city not decoded")
|
||||
}
|
||||
}
|
||||
144
traffic_history.go
Normal file
144
traffic_history.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TrafficIPDetail struct {
|
||||
IP string `json:"ip"`
|
||||
Country string `json:"country"`
|
||||
Requests int64 `json:"requests"`
|
||||
LastSeen int64 `json:"lastSeen"`
|
||||
City string `json:"city,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
}
|
||||
type TrafficHistory struct {
|
||||
Events []TrafficEvent `json:"events"`
|
||||
IPs []TrafficIPDetail `json:"ips"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Snapshot int64 `json:"snapshot"`
|
||||
MaxSequence int64 `json:"maxSequence"`
|
||||
CityReady bool `json:"cityReady"`
|
||||
}
|
||||
|
||||
func (s *Store) trafficHistoryRoute(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/traffic/history", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
w.WriteHeader(405)
|
||||
return
|
||||
}
|
||||
if !s.TrafficSettings().Enabled {
|
||||
http.Error(w, "Карта отключена", 404)
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
page, _ := strconv.Atoi(q.Get("page"))
|
||||
snapshot, _ := strconv.ParseInt(q.Get("snapshot"), 10, 64)
|
||||
maxSeq, _ := strconv.ParseInt(q.Get("maxSequence"), 10, 64)
|
||||
if q.Get("maxSequence") == "0" {
|
||||
maxSeq = -1
|
||||
}
|
||||
if page < 0 || page > 10000 {
|
||||
http.Error(w, "Invalid page", 400)
|
||||
return
|
||||
}
|
||||
kind := q.Get("kind")
|
||||
metricType := q.Get("type")
|
||||
if metricType != "" && metricType != "request" && metricType != "alert" && metricType != "decision" {
|
||||
http.Error(w, "Invalid type", 400)
|
||||
return
|
||||
}
|
||||
if kind != "" && kind != "request" && kind != "alert" && kind != "decision" {
|
||||
http.Error(w, "Invalid kind", 400)
|
||||
return
|
||||
}
|
||||
result, err := s.TrafficHistory(q.Get("domain"), kind, q.Get("country"), q.Get("ip"), q.Get("type"), q.Get("external") == "1", q.Get("hideMonitoring") == "1", q.Get("group") == "ip", page, snapshot, maxSeq)
|
||||
writeJSON(w, result, err)
|
||||
})
|
||||
}
|
||||
func (s *Store) TrafficHistory(domain, kind, country, ip, metricType string, external, hide, group bool, page int, snapshot, maxSeq int64) (TrafficHistory, error) {
|
||||
now := time.Now().Unix()
|
||||
if snapshot <= 0 || snapshot > now {
|
||||
snapshot = now
|
||||
}
|
||||
if snapshot < now-86400 {
|
||||
snapshot = now
|
||||
}
|
||||
settings := s.TrafficSettings()
|
||||
v := TrafficHistory{Events: []TrafficEvent{}, IPs: []TrafficIPDetail{}, Page: page, PageSize: 50, Snapshot: snapshot, CityReady: trafficCityReady()}
|
||||
if maxSeq == 0 {
|
||||
if err := s.db.QueryRow(`SELECT COALESCE(MAX(seq),0) FROM traffic_events WHERE agent=?`, settings.AgentID).Scan(&maxSeq); err != nil {
|
||||
return v, err
|
||||
}
|
||||
}
|
||||
v.MaxSequence = maxSeq
|
||||
where := ` WHERE agent=? AND ts>=? AND ts<=? AND seq<=?`
|
||||
args := []any{settings.AgentID, snapshot - 86400, snapshot, maxSeq}
|
||||
if hide {
|
||||
clause, values := monitoringSQL(settings.MonitoringRules)
|
||||
where += clause
|
||||
args = append(args, values...)
|
||||
}
|
||||
if external {
|
||||
where += ` AND country!='LAN'`
|
||||
}
|
||||
for _, filter := range []struct{ column, value string }{{"domain", domain}, {"kind", kind}, {"kind", metricType}, {"country", country}, {"ip", ip}} {
|
||||
if filter.value != "" {
|
||||
where += " AND " + filter.column + "=?"
|
||||
args = append(args, filter.value)
|
||||
}
|
||||
}
|
||||
count := "COUNT(*)"
|
||||
if group {
|
||||
where += ` AND kind='request'`
|
||||
count = "COUNT(DISTINCT ip)"
|
||||
}
|
||||
if err := s.db.QueryRow("SELECT "+count+" FROM traffic_events"+where, args...).Scan(&v.Total); err != nil {
|
||||
return v, err
|
||||
}
|
||||
paged := append(append([]any{}, args...), v.PageSize, page*v.PageSize)
|
||||
if group {
|
||||
rows, err := s.db.Query(`SELECT ip,MIN(country),COUNT(*),MAX(ts) FROM traffic_events`+where+` GROUP BY ip ORDER BY COUNT(*) DESC,ip LIMIT ? OFFSET ?`, paged...)
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var item TrafficIPDetail
|
||||
if err = rows.Scan(&item.IP, &item.Country, &item.Requests, &item.LastSeen); err != nil {
|
||||
return v, err
|
||||
}
|
||||
item.City, item.Region = lookupTrafficCity(item.IP)
|
||||
v.IPs = append(v.IPs, item)
|
||||
}
|
||||
return v, rows.Err()
|
||||
}
|
||||
rows, err := s.db.Query(`SELECT seq,event_id,ts,kind,ip,domain,method,path,status,reason,country,agent FROM traffic_events`+where+` ORDER BY ts DESC,seq DESC LIMIT ? OFFSET ?`, paged...)
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var e TrafficEvent
|
||||
if err = rows.Scan(&e.Sequence, &e.ID, &e.Time, &e.Kind, &e.IP, &e.Domain, &e.Method, &e.Path, &e.Status, &e.Reason, &e.Country, &e.Agent); err != nil {
|
||||
return v, err
|
||||
}
|
||||
e.Monitoring = isMonitoringRequest(e, settings.MonitoringRules)
|
||||
e.City, e.Region = lookupTrafficCity(e.IP)
|
||||
v.Events = append(v.Events, e)
|
||||
}
|
||||
return v, rows.Err()
|
||||
}
|
||||
func cityName(names map[string]string) string {
|
||||
for _, lang := range []string{"ru", "en"} {
|
||||
if s := strings.TrimSpace(names[lang]); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
109
traffic_history_test.go
Normal file
109
traffic_history_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTrafficHistoryFullPaginationAndSnapshot(t *testing.T) {
|
||||
s, a := trafficFixture(t)
|
||||
if _, err := s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
batch := TrafficBatch{}
|
||||
for i := 0; i < 137; i++ {
|
||||
batch.Events = append(batch.Events, TrafficEvent{ID: fmt.Sprint(i), Time: now - 60, Kind: "request", IP: "8.8.8.8", Domain: "example.test"})
|
||||
}
|
||||
batch.Events = append(batch.Events, TrafficEvent{ID: "old-alert", Time: now - 3600, Kind: "alert", IP: "1.1.1.1", Domain: "other.test"})
|
||||
if err := s.SaveTraffic(a.ID, batch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := s.TrafficHistory("", "request", "", "", "", false, false, false, 0, now, 0)
|
||||
if err != nil || first.Total != 137 || len(first.Events) != 50 {
|
||||
t.Fatalf("first: %+v %v", first, err)
|
||||
}
|
||||
if err := s.SaveTraffic(a.ID, TrafficBatch{Events: []TrafficEvent{{ID: "new", Time: now - 60, Kind: "request", IP: "8.8.8.8"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[int64]bool{}
|
||||
for page := 0; page < 3; page++ {
|
||||
h, err := s.TrafficHistory("", "request", "", "", "", false, false, false, page, first.Snapshot, first.MaxSequence)
|
||||
if err != nil || h.Total != 137 {
|
||||
t.Fatalf("page %d: %v total %d", page, err, h.Total)
|
||||
}
|
||||
for _, e := range h.Events {
|
||||
if seen[e.Sequence] {
|
||||
t.Fatal("duplicate page event")
|
||||
}
|
||||
seen[e.Sequence] = true
|
||||
}
|
||||
}
|
||||
if len(seen) != 137 {
|
||||
t.Fatalf("missing history: %d", len(seen))
|
||||
}
|
||||
h, err := s.TrafficHistory("other.test", "alert", "", "", "", false, false, false, 0, now, 0)
|
||||
if err != nil || h.Total != 1 || h.Events[0].ID != "old-alert" {
|
||||
t.Fatalf("old alert missing: %+v %v", h, err)
|
||||
}
|
||||
h, err = s.TrafficHistory("", "alert", "", "", "request", false, false, false, 0, now, 0)
|
||||
if err != nil || h.Total != 0 {
|
||||
t.Fatal("metric must respect global kind filter")
|
||||
}
|
||||
h, err = s.TrafficHistory("example.test", "", "", "", "", false, false, true, 0, now, 0)
|
||||
if err != nil || h.Total != 1 || len(h.IPs) != 1 || h.IPs[0].Requests != 137 {
|
||||
t.Fatalf("IP summary %+v %v", h, err)
|
||||
}
|
||||
h, err = s.TrafficHistory("", "", "ZZ", "", "", false, false, false, 0, now, 0)
|
||||
if err != nil || h.Total != 0 {
|
||||
t.Fatal("country filter ignored")
|
||||
}
|
||||
h, err = s.TrafficHistory("", "", "", "1.1.1.1", "", false, false, false, 0, now, 0)
|
||||
if err != nil || h.Total != 1 {
|
||||
t.Fatal("IP filter ignored")
|
||||
}
|
||||
}
|
||||
func TestCityNamesAndLocalAddresses(t *testing.T) {
|
||||
if cityName(map[string]string{"ru": "Москва", "en": "Moscow"}) != "Москва" || cityName(map[string]string{"en": "Amsterdam"}) != "Amsterdam" {
|
||||
t.Fatal("city localization")
|
||||
}
|
||||
for _, ip := range []string{"192.168.0.10", "127.0.0.1", "::1", "fe80::1", "invalid"} {
|
||||
c, r := lookupTrafficCity(ip)
|
||||
if c != "" || r != "" {
|
||||
t.Fatal("local IP geolocated")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrafficHistoryMonitoringAndAgentIsolation(t *testing.T) {
|
||||
s, a := trafficFixture(t)
|
||||
s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID, MonitoringRules: []MonitoringRule{{IP: "192.168.0.10"}}})
|
||||
now := time.Now().Unix()
|
||||
batch := TrafficBatch{Events: []TrafficEvent{{ID: "check", Time: now, Kind: "request", IP: "192.168.0.10"}, {ID: "alert", Time: now, Kind: "alert", IP: "192.168.0.10"}, {ID: "public", Time: now, Kind: "request", IP: "8.8.8.8"}}}
|
||||
if err := s.SaveTraffic(a.ID, batch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _ := s.CreateAgentEnrollmentToken()
|
||||
other, err := s.EnrollAgent(agentEnrollmentRequest{Token: token, Name: "other", VMID: 103})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = s.SaveTraffic(other.ID, batch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
external, hide bool
|
||||
want int64
|
||||
}{{false, false, 3}, {false, true, 2}, {true, false, 1}, {true, true, 1}} {
|
||||
h, err := s.TrafficHistory("", "", "", "", "", tc.external, tc.hide, false, 0, now, 0)
|
||||
if err != nil || h.Total != tc.want {
|
||||
t.Fatalf("filters %+v total=%d err=%v", tc, h.Total, err)
|
||||
}
|
||||
for _, e := range h.Events {
|
||||
if e.Agent != a.ID {
|
||||
t.Fatal("foreign agent leaked")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
traffic_map_cities.go
Normal file
71
traffic_map_cities.go
Normal 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
|
||||
}
|
||||
3
web.go
3
web.go
@@ -890,6 +890,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
|
||||
<label class="field"><span>Правила мониторинга: IP | домен | префикс пути</span><textarea id="monitoringRules" rows="4" placeholder="192.168.0.10" style="width:100%;font:12px monospace;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:10px"></textarea></label>
|
||||
<p class="settings-note">Одно правило на строку, до 32. Домен и путь необязательны; внутри правила условия объединяются. IP без уточнений помечает HTTP-проверки и сетевые потоки этого инициатора. Домен/путь применяются только к HTTP. Скрытие — фильтр просмотра: история сохраняется, срабатывания и решения CrowdSec не скрываются. Пример: 192.168.0.10 | example.org | /health</p>
|
||||
<button class="save-settings" id="saveTrafficSettings">Сохранить</button><span id="trafficSettingsStatus" role="status"></span>
|
||||
<div class="settings-group"><button class="theme-option" id="installCityDatabase">Установить базу городов (~58 МБ)</button><div class="settings-note" id="cityDatabaseStatus">DB-IP City Lite: локальная приблизительная геолокация, без отправки IP внешним сервисам. Устанавливается на сервер Dashboard.</div></div>
|
||||
</div></div>
|
||||
<div class="settings-body settings-panel" data-settings-panel="updates" hidden>
|
||||
<div class="detail-grid">
|
||||
@@ -1038,6 +1039,8 @@ async function loadTrafficSettings(){try{
|
||||
el('trafficCountry').innerHTML='<option value="">Не задана</option>'+world.filter(c=>c.code&&c.code!=='-99').sort((a,b)=>a.name.localeCompare(b.name,'ru')).map(c=>'<option value="'+safe(c.code)+'">'+safe(c.name)+'</option>').join('');el('trafficCountry').value=settings.serverCountry;el('trafficSettingsStatus').textContent='';
|
||||
}catch(e){el('trafficSettingsStatus').textContent='Не удалось загрузить настройки';}}
|
||||
el('trafficSettingsTab').addEventListener('click',loadTrafficSettings);
|
||||
fetch('/api/settings/traffic/city-database').then(r=>r.ok?r.json():null).then(d=>{if(!d)return;const b=el('installCityDatabase');if(d.ready){b.disabled=true;b.textContent='База городов установлена'}if(d.message)el('cityDatabaseStatus').textContent=d.message}).catch(()=>{});
|
||||
el('installCityDatabase').onclick=async()=>{const b=el('installCityDatabase'),status=el('cityDatabaseStatus');b.disabled=true;try{let r=await fetch('/api/settings/traffic/city-database',{method:'POST'});if(!r.ok)throw Error('Не удалось начать загрузку');let result=await r.json();status.textContent=result.message||'База уже установлена';while(result.running){await new Promise(resolve=>setTimeout(resolve,2000));r=await fetch('/api/settings/traffic/city-database');if(!r.ok)throw Error('Не удалось прочитать состояние');result=await r.json();status.textContent=result.message}b.textContent=result.ready?'База городов установлена':'Повторить установку базы';b.disabled=result.ready}catch(e){status.textContent=e.message;b.disabled=false}};
|
||||
el('saveTrafficSettings').onclick=async()=>{const button=el('saveTrafficSettings');button.disabled=true;try{const response=await fetch('/api/settings/traffic',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({monitoringRules:el('monitoringRules').value.split('\n').filter(line=>line.trim()).map(line=>{const parts=line.split('|').map(v=>v.trim());if(parts.length>3)throw Error('В правиле допускается три поля: IP | домен | путь');return {ip:parts[0],domain:parts[1]||'',pathPrefix:parts[2]||''}}),enabled:el('trafficEnabled').checked,networkEnabled:el('networkEnabled').checked,agentId:el('trafficAgent').value,serverCountry:el('trafficCountry').value})});if(!response.ok)throw Error(await response.text());el('trafficLink').hidden=!el('trafficEnabled').checked;el('trafficSettingsStatus').textContent='Сохранено. Фильтры применяются сразу, настройки сбора — при следующем отчёте агента.';}catch(e){el('trafficSettingsStatus').textContent=e.message||'Ошибка сохранения';}finally{button.disabled=false;}};
|
||||
if(location.hash==='#traffic-settings'){el('settingsDialog').showModal();el('trafficSettingsTab').click();}
|
||||
el('agentServerURL').value=location.origin;
|
||||
|
||||
Reference in New Issue
Block a user