Files
ProxmoxDash/assets/traffic.html

76 lines
37 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Карта трафика · Proxmox Dashboard</title>
<style>
[hidden]{display:none!important}:root{color-scheme:dark;--bg:#090d13;--panel:#111821;--line:#24303b;--text:#ecf3f9;--muted:#8b9daa;--blue:#5ed8ed;--orange:#ffbc70;--pink:#b7a0ff;--green:#b8ef72}*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;overflow:hidden}body{font:12px/1.4 system-ui,sans-serif;color:var(--text);background:radial-gradient(ellipse at 43% 42%,#17253666,transparent 58%),var(--bg);padding:18px 22px 12px;display:grid;grid-template-rows:48px 34px 78px minmax(0,1fr) 158px 20px;gap:12px}button,select{font:inherit;color:inherit;background:#161e28;border:1px solid var(--line);border-radius:9px;padding:7px 11px;cursor:pointer}button:hover,select:hover{border-color:#607589}button:focus-visible,select:focus-visible,a:focus-visible{outline:2px solid var(--blue);outline-offset:3px}button:disabled{opacity:.35;cursor:default}a{color:var(--muted);text-decoration:none}a:hover{color:var(--text)}header{display:flex;align-items:center;justify-content:space-between;gap:20px;min-width:0}header>div{display:flex;align-items:center;gap:13px;min-width:0}.brand-mark{display:grid;place-items:center;width:38px;height:38px;flex:none;border:1px solid #4d6855;border-radius:12px;background:#17251c;color:var(--green);font-size:24px}.eyebrow{color:var(--muted);font-size:9px;letter-spacing:.19em;font-weight:650}h1{font-size:23px;font-weight:600;letter-spacing:-.055em;margin:1px 0 0}header nav{display:flex;align-items:center;gap:16px;flex:none}.live{font:9px ui-monospace,monospace;letter-spacing:.08em;color:var(--green);border:1px solid #344b32;border-radius:20px;padding:6px 10px;background:#192316}.live.stale{color:var(--orange);border-color:#594733;background:#291f14}.now{font:11px ui-monospace,monospace;color:var(--muted)}.icon-button{padding:6px 10px;font-size:18px;line-height:1;background:transparent}.toolbar{display:flex;align-items:center;gap:8px;min-width:0}.segments{display:flex;gap:3px;border:1px solid var(--line);border-radius:10px;padding:3px;background:#10161e}.segments button{padding:5px 11px;border:0;background:none;color:var(--muted);font-size:11px;white-space:nowrap}.segments button.active{background:#26333f;color:#f4f8fb;box-shadow:0 1px 4px #0004}.toolbar select{max-width:230px;min-width:110px;height:32px;font-size:11px}.toolbar label{color:var(--muted);display:flex;align-items:center;gap:5px;font-size:11px;white-space:nowrap}.toolbar input{accent-color:var(--green)}#pause{margin-left:auto;height:32px;font-size:11px}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}.metric{border:1px solid var(--line);border-radius:13px;background:linear-gradient(125deg,#17212c,#10171f);padding:11px 16px;display:grid;grid-template-columns:1fr auto;align-items:center;position:relative;overflow:hidden;min-width:0}.metric small{display:block;color:var(--muted);font-size:10px;white-space:nowrap}.metric b{display:block;font-size:29px;font-weight:550;line-height:1.15;letter-spacing:-.05em;font-variant-numeric:tabular-nums}.metric i{font-style:normal;font-size:20px;color:var(--green);background:#263521;border-radius:12px;width:38px;height:38px;display:grid;place-items:center}.metric.request i{color:var(--blue);background:#17333b}.metric.alert i{color:var(--orange);background:#352a20}.metric.decision i{color:var(--pink);background:#2b253d}.metric:after{content:'';position:absolute;height:45px;width:45px;right:-23px;bottom:-25px;background:var(--green);filter:blur(24px);opacity:.2}.dashboard{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:12px;min-height:0}.panel{border:1px solid var(--line);border-radius:14px;background:#10171fbc;min-width:0;min-height:0;overflow:hidden}.map-panel{position:relative;display:grid;grid-template-rows:30px minmax(0,1fr) 25px;padding:10px 13px 7px;background:radial-gradient(ellipse at 55% 52%,#1a304140,transparent 65%),#0e151d}.map-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:0}.section-label{font-size:10px;font-weight:600;letter-spacing:.12em;color:#a0b1bf}.source-status{display:flex;align-items:center;min-width:0;max-width:65%;gap:8px;border:0;background:none;padding:0;color:var(--muted);font-size:10px}#sourceName{max-width:190px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.status-box{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.status-box.error{color:var(--orange)}.map-stage{position:relative;min-width:0;min-height:0;overflow:hidden}.map-stage:after{content:'';position:absolute;inset:0;pointer-events:none;background:radial-gradient(ellipse,transparent 60%,#0e151d66)}#world{position:absolute;width:100%;height:100%;inset:0;display:block}.map-foot{display:flex;justify-content:space-between;align-items:center;gap:10px;font-size:9px;color:var(--muted);min-width:0}.legend{display:flex;gap:12px}.dot{display:inline-block;width:5px;height:5px;border-radius:50%;background:var(--blue);margin-right:5px}.dot.alert{background:var(--orange)}.dot.decision{background:var(--pink)}#unmapped{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.feed-panel{display:grid;grid-template-rows:36px minmax(0,1fr) 30px;padding:0 13px}.feed-heading{display:flex;align-items:center;justify-content:space-between}.panel h2{font-size:11px;font-weight:550;letter-spacing:.01em;margin:0}.note{font-size:10px;color:var(--muted)}.feed{min-height:0;overflow:hidden}.event{height:64px;padding:8px 0;border-top:1px solid #202c36;cursor:pointer;position:relative}.event:hover{background:#1c2b3666}.event:focus-visible{outline:1px solid var(--blue);outline-offset:-1px}.event-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.event-head b{font:11px ui-monospace,monospace;letter-spacing:.01em}.event-head time{font:9px ui-monospace,monospace;color:var(--muted);flex:none}.event .meta{font-size:10px;color:#95a9b8;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.event .path{font:10px/1.5 ui-monospace,monospace;color:#becad3;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.kind{color:var(--blue)}.kind.alert{color:var(--orange)}.kind.decision{color:var(--pink)}.pager{display:flex;align-items:center;gap:7px;border-top:1px solid var(--line);font-size:9px;color:var(--muted)}.pager button{font-size:10px;border:0;background:none;padding:3px 6px}.pager #latest{margin-left:auto;color:var(--green)}.empty{color:var(--muted);padding:18px 0;font-size:12px}.bottom{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(0,1fr) minmax(0,1fr);gap:12px;min-height:0}.bottom .panel{padding:11px 14px;display:flex;flex-direction:column}.bottom-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}.chart{display:block;width:100%;flex:1;min-height:0}.ranking{display:flex;justify-content:space-between;align-items:center;gap:10px;height:16px;font-size:10px;position:relative}.ranking:before{content:'';position:absolute;inset:2px 0;background:linear-gradient(90deg,#4f809f18,transparent);width:var(--share,0);border-radius:3px}.ranking span{position:relative;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ranking b{font:10px ui-monospace,monospace;color:var(--blue);flex:none}.ranking em{font:9px ui-monospace,monospace;color:#617583;margin-right:8px;font-style:normal}footer{display:flex;align-items:center;gap:12px;justify-content:space-between;color:#718695;font-size:9px;min-width:0}footer .credits{display:flex;gap:10px;min-width:0}footer a{color:#8298a6}.footer-help{padding:0;border:0;background:none;color:#8298a6;font-size:9px}.tooltip{position:fixed;pointer-events:none;display:none;padding:10px 14px;border:1px solid #475578;border-radius:9px;background:#101725f5;font-size:12px;z-index:3}dialog{width:min(540px,calc(100vw - 30px));max-height:85dvh;overflow:auto;border:1px solid #384a5a;border-radius:18px;padding:22px;background:#131d27;color:var(--text);box-shadow:0 30px 120px #0009}dialog::backdrop{background:#030810bb;backdrop-filter:blur(7px)}.dialog-head{display:flex;justify-content:space-between;gap:16px;align-items:center;margin-bottom:20px}.dialog-head h2{font-size:18px;margin:0}.detail-row{padding:9px 0;border-top:1px solid var(--line);overflow-wrap:anywhere}.detail-row small{display:block;color:var(--muted);margin-bottom:3px}dialog p{color:#b8c7d2;line-height:1.7}#mobileTabs{display:none}
@media(min-width:1600px){body{padding:24px 30px 16px;grid-template-rows:54px 38px 94px minmax(0,1fr) 184px 22px;gap:16px}.dashboard{grid-template-columns:minmax(0,1fr) 360px}.metric b{font-size:36px}.metric small{font-size:11px}.metric{padding:14px 20px}.ranking{height:18px;font-size:11px}.ranking b{font-size:11px}.map-panel{padding:12px 20px}.feed-panel{padding:0 17px}.bottom .panel{padding:15px 18px}}
@media(max-height:650px) and (min-width:761px){body{padding:10px 16px;grid-template-rows:36px 32px 62px minmax(0,1fr) 130px 16px;gap:8px}.metric{padding:7px 12px}.metric b{font-size:25px}.ranking{height:13px}.bottom .panel{padding:8px 12px}.event{height:56px;padding:5px 0}.dashboard{grid-template-columns:minmax(0,1fr) 290px}h1{font-size:20px}}
@media(max-width:1000px){body{padding:12px;gap:9px}.dashboard{grid-template-columns:minmax(0,1fr) 280px}.segments button{padding:5px 8px;font-size:10px}.toolbar select{max-width:155px}.toolbar label{font-size:10px}header nav{gap:9px}.now{display:none}.metric small{font-size:9px}.metric i{width:28px;height:28px;font-size:16px}.map-foot{font-size:8px}.legend{gap:6px}.bottom{grid-template-columns:1.1fr 1fr 1fr}}
@media(max-width:760px){body{padding:12px;grid-template-rows:42px auto 110px 30px minmax(0,1fr) 18px;gap:10px}header nav{gap:8px}h1{font-size:21px}.brand-mark{width:30px;height:30px;font-size:19px}.eyebrow{font-size:7px}.live{font-size:8px;padding:5px 7px}header nav>a{font-size:0}header nav>a:before{content:'↖';font-size:18px}.toolbar{display:grid;grid-template-columns:1fr auto;gap:7px}.segments{grid-column:1/-1}.segments button{flex:1;padding:5px 7px}.toolbar select{max-width:100%;width:100%;min-width:0}.toolbar label{grid-row:3;font-size:10px}#pause{grid-row:3;grid-column:2;padding:3px 8px;height:26px}.metrics{grid-template-columns:1fr 1fr;gap:7px}.metric{padding:7px 11px;border-radius:10px}.metric b{font-size:23px}.metric small{font-size:8px}.metric i{width:28px;height:28px;font-size:16px}#mobileTabs{display:flex;gap:6px}#mobileTabs button{flex:1;padding:5px;font-size:11px;background:none}#mobileTabs button.active{background:#273743;border-color:#486373}.dashboard{display:block;min-height:0}.map-panel,.feed-panel{height:100%}.feed-panel,.bottom{display:none}body[data-mobile="feed"] .map-panel{display:none}body[data-mobile="feed"] .feed-panel{display:grid}body[data-mobile="stats"] .dashboard{display:none}body[data-mobile="stats"] .bottom{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:minmax(80px,1fr) minmax(165px,1fr);grid-row:5}.bottom .chart-panel{grid-column:1/-1}.bottom .panel{padding:10px}.ranking{height:16px}.map-heading{gap:4px}.section-label{font-size:8px}.source-status{max-width:60%;font-size:8px}.status-box{display:none}.map-foot{font-size:8px;align-items:flex-start}.legend{gap:5px;flex-wrap:wrap}#unmapped{max-width:105px;white-space:normal;text-align:right}.credits .natural-credit,.credits .license-credit{display:none}footer{gap:6px;font-size:8px}.footer-help{font-size:8px}.credits{gap:4px!important}}
@media(max-width:760px) and (max-height:700px){body{padding:10px;gap:6px;grid-template-rows:32px auto 78px 26px minmax(0,1fr) 18px}.metric{padding:4px 9px}.metric b{font-size:19px}.metric i{width:24px;height:24px}.eyebrow{font-size:6px}h1{font-size:18px}}
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
#networkSummary{position:absolute;left:9px;top:5px;z-index:2;max-width:calc(100% - 18px);text-align:left;font-size:9px;padding:5px 8px;background:#101c27df;border-color:#2c4859;color:#a9bdcb}.toolbar label{font-size:10px}.toolbar{flex-wrap:nowrap}.toolbar select{max-width:180px}.monitoring-tag{color:#b8ef72}
@media(min-width:761px) and (max-width:1250px){.toolbar{gap:5px}.segments button{font-size:9px;padding:5px 6px}.toolbar select{min-width:90px;max-width:120px}.toolbar label{font-size:9px}#networkLink{font-size:9px!important}#pause{padding:5px 7px}}
@media(max-width:760px){.toolbar{grid-template-columns:minmax(0,1fr) auto}.toolbar select{max-width:none}.toolbar #monitoringControl{grid-row:4;grid-column:1}.toolbar #networkOverlayControl{grid-row:4;grid-column:2}.toolbar #networkLink{grid-row:2;grid-column:2}.toolbar #externalControl{grid-row:3;grid-column:1}#networkSummary{font-size:8px}}
@media(max-width:760px) and (max-height:600px){body[data-mobile="stats"] .bottom{grid-template-rows:minmax(70px,1fr) minmax(145px,1fr)}.bottom .panel{padding:8px}.ranking{height:13px}}
</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="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 часа"><div class="metric"><div><small>Уникальные IP · 24 ч</small><b id="unique">—</b></div><i aria-hidden="true">⌁</i></div><div class="metric request"><div><small>HTTP-запросы · 24 ч</small><b id="requests">—</b></div><i aria-hidden="true">↗</i></div><div class="metric alert"><div><small>Срабатывания · 24 ч</small><b id="alerts">—</b></div><i aria-hidden="true">ϟ</i></div><div class="metric decision"><div><small>Решения CrowdSec · 24 ч</small><b id="decisions">—</b></div><i aria-hidden="true">◇</i></div></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><canvas 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>
<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>
<div id="tooltip" class="tooltip"></div><dialog id="details"><div class="dialog-head"><h2 id="detailsTitle">Подробности</h2><button id="closeDetails" class="icon-button" aria-label="Закрыть подробности">×</button></div><div id="detailsBody"></div></dialog>
<script>
const el=id=>document.getElementById(id),safe=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])),num=n=>Number(n||0).toLocaleString('ru-RU');
try{el('hideMonitoring').checked=localStorage.getItem('proxmox-hide-monitoring')==='1';el('networkOverlay').checked=localStorage.getItem('proxmox-network-overlay')!=='0'}catch{}
let world=[],countryMap=new Map(),view=null,kind='',paused=false,busy=false,pulses=[],seen=new Set(),hitPoints=[],baseMap=null,lastPoll=0,feedPage=0,frozenEvents=null,feedVisible=[],statusMessages=[],lastNetworkSnapshot=0;
const colors={request:'#5ed8ed',alert:'#ffbc70',decision:'#b7a0ff',tcp:'#b8ef72',udp:'#f69dde',icmp:'#ffd66d'},kindLabels={request:'HTTP-запрос',alert:'Срабатывание',decision:'Решение CrowdSec'};
const countryNames=new Intl.DisplayNames(['ru'],{type:'region'});const countryName=code=>code==='LAN'?'Локальная сеть':countryMap.get(code)?.name||(code&&/^[A-Z]{2}$/.test(code)?countryNames.of(code):'Не определена');
function project(lon,lat,w,h){const scale=Math.min(w/360,h/170);return [(w-360*scale)/2+(lon+180)*scale,(h-170*scale)/2+(85-lat)*scale]}
function mapBackground(w,h){const off=document.createElement('canvas');off.width=w;off.height=h;const ctx=off.getContext('2d'),mask=document.createElement('canvas');mask.width=w;mask.height=h;const m=mask.getContext('2d');m.fillStyle='#fff';world.forEach(c=>{const polygons=c.geometry.type==='Polygon'?[c.geometry.coordinates]:c.geometry.coordinates;polygons.forEach(poly=>{m.beginPath();poly.forEach(ring=>{ring.forEach(([lon,lat],i)=>{const [x,y]=project(lon,lat,w,h);if(i===0)m.moveTo(x,y);else m.lineTo(x,y)});m.closePath()});m.fill('evenodd')})});const pixels=m.getImageData(0,0,w,h).data;const step=Math.max(4,Math.round(w/210));ctx.fillStyle='#324a5b';for(let y=0,row=0;y<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 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);if(baseMap)ctx.drawImage(baseMap,0,0);hitPoints=[];
if(view){const destination=countryMap.get(view.settings.serverCountry),end=destination?project(destination.lon,destination.lat,w,h):[w*.82,h*.88];
for(const c of view.countries){const country=countryMap.get(c.name);if(!country)continue;const [x,y]=project(country.lon,country.lat,w,h);ctx.fillStyle='#55c9ff';ctx.globalAlpha=.75;ctx.beginPath();ctx.arc(x,y,2+Math.min(5,Math.log2(c.count+1)),0,Math.PI*2);ctx.fill();hitPoints.push({x,y,text:country.name+' · '+num(c.count)+' событий'})}ctx.globalAlpha=1;
if(networkFresh()){for(const p of view.network.points){const c=countryMap.get(p.country);if(!c)continue;const [x,y]=project(c.lon,c.lat,w,h),radius=8+['tcp','udp','icmp'].indexOf(p.protocol)*3;ctx.strokeStyle=colors[p.protocol];ctx.lineWidth=1.4;ctx.globalAlpha=.8;ctx.beginPath();ctx.arc(x,y,radius,0,Math.PI*2);ctx.stroke();const text=p.protocol.toUpperCase()+' '+(p.direction==='out'?'← исходящие':'→ входящие')+': '+num(p.count)+' потоков';const existing=hitPoints.find(h=>Math.hypot(h.x-x,h.y-y)<1);if(existing)existing.text+=' · '+text;else hitPoints.push({x,y,text:c.name+' · '+text})}ctx.globalAlpha=1}
pulses=pulses.filter(p=>now-p.start<3200&&(!p.network||networkFresh()));if(matchMedia('(prefers-reduced-motion: reduce)').matches)pulses=[];for(const p of pulses){const c=countryMap.get(p.country);if(!c)continue;const remote=project(c.lon,c.lat,w,h),start=p.direction==='out'?end:remote,finish=p.direction==='out'?remote:end,t=Math.min(1,(now-p.start)/2400),control=[(start[0]+finish[0])/2,(start[1]+finish[1])/2-Math.min(h*.3,Math.abs(start[0]-finish[0])*.3+30)],fade=Math.min(1,(3200-(now-p.start))/700);ctx.strokeStyle=colors[p.kind];ctx.globalAlpha=.4*fade;ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(...start);ctx.quadraticCurveTo(...control,...finish);ctx.stroke();const x=(1-t)**2*start[0]+2*(1-t)*t*control[0]+t*t*finish[0],y=(1-t)**2*start[1]+2*(1-t)*t*control[1]+t*t*finish[1];ctx.fillStyle=colors[p.kind];ctx.globalAlpha=fade;ctx.shadowColor=colors[p.kind];ctx.shadowBlur=12;ctx.beginPath();ctx.arc(x,y,2.5,0,Math.PI*2);ctx.fill();ctx.shadowBlur=0}
ctx.globalAlpha=1;ctx.strokeStyle='#c8b5ff';ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(...end,7,0,Math.PI*2);ctx.stroke();ctx.fillStyle='#e7dcff';ctx.beginPath();ctx.arc(...end,3,0,Math.PI*2);ctx.fill();ctx.font='11px system-ui';ctx.textAlign='center';ctx.fillText(destination?'NPM · '+destination.name:'NPM · геопозиция не задана',end[0],end[1]+22);
}
if(!document.hidden&&!paused)requestAnimationFrame(drawMap)
}
function ranking(id,items,label){el(id).innerHTML=items.length?items.slice(0,7).map((c,i)=>'<div class="ranking" style="--share:'+Math.round(c.count/Math.max(1,items[0].count)*100)+'%"><span title="'+safe(label(c.name))+'"><em>'+String(i+1).padStart(2,'0')+'</em>'+safe(label(c.name))+'</span><b>'+num(c.count)+'</b></div>').join(''):'<div class="note">Пока нет событий</div>'}
function drawChart(){const c=el('chart'),w=c.clientWidth,h=Math.max(35,c.clientHeight),dpr=devicePixelRatio||1;c.width=w*dpr;c.height=h*dpr;const ctx=c.getContext('2d');ctx.scale(dpr,dpr);if(!view)return;const buckets=new Map(view.series.map(v=>[Number(v.name),v.count])),end=Math.floor(view.now/60)*60,values=Array.from({length:60},(_,i)=>buckets.get(end-(59-i)*60)||0),max=Math.max(1,...values);ctx.strokeStyle='#26314b';ctx.beginPath();ctx.moveTo(0,h-18);ctx.lineTo(w,h-18);ctx.stroke();ctx.fillStyle='#55c9ff';values.forEach((v,i)=>{const x=i*w/60,height=v/max*(h-32);ctx.fillRect(x,h-18-height,Math.max(1,w/60-3),height)});ctx.fillStyle='#8490ab';ctx.font='10px system-ui';ctx.fillText('−60 мин',0,h-2);ctx.textAlign='right';ctx.fillText('сейчас',w,h-2);ctx.fillText('макс. '+num(max)+'/мин',w,12)}
function render(data){view=data;el('networkLink').hidden=!data.settings.networkEnabled;el('networkOverlayControl').hidden=!data.settings.networkEnabled;renderNetworkSummary();el('unique').textContent=num(data.uniqueIps);el('requests').textContent=num(data.requests);el('alerts').textContent=num(data.alerts);el('decisions').textContent=num(data.decisions);el('qps').textContent=Number(data.qps).toFixed(2);el('geoDate').textContent=data.geoDate;ranking('countries',data.countries,countryName);ranking('ips',data.ips,v=>v);
const src=data.sources[0],fresh=src&&data.now-src.lastSeen<20;el('live').textContent=paused?'ПАУЗА':fresh?'● В ЭФИРЕ':'ОЖИДАНИЕ АГЕНТА';el('live').classList.toggle('stale',!fresh||paused);el('sourceName').textContent=src?src.name:'Источник ещё не прислал данные';
const messages=[];if(!src)messages.push('Выбранный агент ещё не прислал данные. Нужен агент v0.20.0 или новее.');else{messages.push(src.name+' · '+src.status.files+' логов · сбор с '+new Date(src.status.startedAt*1000).toLocaleString('ru-RU'));if(!fresh)messages.push('Источник не обновляется; показана сохранённая история');if(src.status.logError)messages.push(src.status.logError);if(src.status.crowdsecError)messages.push('CrowdSec: '+src.status.crowdsecError);if(src.status.backlogBytes)messages.push('Ожидает чтения: '+num(src.status.backlogBytes)+' байт');if(src.status.crowdsecLimited)messages.push('CrowdSec: получены последние 50 срабатываний; более ранняя история может быть неполной');if(src.status.parseErrors)messages.push('Нераспознанных строк: '+src.status.parseErrors)}if(!data.geoReady)messages.push('База GeoIP недоступна');if(data.limited)messages.push('Достигнут лимит 200 000 событий: часть суточной истории удалена');statusMessages=messages;el('status').textContent=fresh?'● на связи':'● нет связи';el('sourceDetails').title=messages.join(' · ');
el('status').classList.toggle('error',!fresh||!!src?.status.logError||!!src?.status.crowdsecError||data.limited);
const current=el('domain').value;if(!current&&!kind){el('domain').innerHTML='<option value="">Все домены</option>'+data.domains.filter(c=>c.name).map(c=>'<option value="'+safe(c.name)+'">'+safe(c.name)+'</option>').join('')}
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);
}
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>')};
function showDetails(title,html){el('detailsTitle').textContent=title;el('detailsBody').innerHTML=html;el('details').showModal()}
function eventText(e){return e.kind==='request'?e.method+' '+e.domain+e.path+' · HTTP '+e.status:(e.domain?e.domain+' · ':'')+e.reason}
function renderFeed(){if(!view)return;const rowHeight=innerHeight<=650&&innerWidth>760?56:64,count=Math.max(1,Math.floor(el('feed').clientHeight/rowHeight)),items=frozenEvents||view.events,pages=Math.max(1,Math.ceil(items.length/count));feedPage=Math.min(feedPage,pages-1);feedVisible=items.slice(feedPage*count,(feedPage+1)*count);
el('feed').innerHTML=feedVisible.length?feedVisible.map((e,i)=>'<article class="event" role="button" tabindex="0" data-event="'+i+'" aria-label="Подробности события '+safe(e.ip)+'"><div class="event-head"><b>'+safe(e.ip)+'</b><time>'+new Date(e.time*1000).toLocaleTimeString('ru-RU')+'</time></div><div class="meta">'+safe(countryName(e.country))+(e.monitoring?' · <span class="monitoring-tag">мониторинг</span>':'')+' · <span class="kind '+safe(e.kind)+'">'+kindLabels[e.kind]+'</span></div><div class="path" title="'+safe(eventText(e))+'">'+safe(eventText(e))+'</div></article>').join(''):'<div class="empty">Новых событий пока нет</div>';
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(''))}
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('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}}
document.querySelectorAll('[data-kind]').forEach(b=>b.onclick=()=>{kind=b.dataset.kind;document.querySelectorAll('[data-kind]').forEach(x=>x.classList.toggle('active',x===b));pulses=[];feedPage=0;frozenEvents=null;poll(true)});el('domain').onchange=()=>{pulses=[];feedPage=0;frozenEvents=null;poll(true)};el('external').onchange=()=>{pulses=[];lastNetworkSnapshot=0;feedPage=0;frozenEvents=null;poll(true)};el('hideMonitoring').onchange=()=>{try{localStorage.setItem('proxmox-hide-monitoring',el('hideMonitoring').checked?'1':'0')}catch{}pulses=[];lastNetworkSnapshot=0;feedPage=0;frozenEvents=null;poll(true)};el('networkOverlay').onchange=()=>{try{localStorage.setItem('proxmox-network-overlay',el('networkOverlay').checked?'1':'0')}catch{}pulses=pulses.filter(p=>!p.network);renderNetworkSummary();if(paused)requestAnimationFrame(drawMap)};el('pause').onclick=()=>{paused=!paused;el('pause').textContent=paused?'Продолжить':'Пауза';el('live').textContent=paused?'ПАУЗА':'Подключение…';if(!paused){poll();requestAnimationFrame(drawMap)}};el('fullscreen').onclick=()=>{if(document.fullscreenElement)document.exitFullscreen();else document.documentElement.requestFullscreen().catch(()=>{})};window.addEventListener('resize',()=>{drawChart();renderFeed();if(paused)requestAnimationFrame(drawMap)});document.addEventListener('visibilitychange',()=>{if(!document.hidden&&!paused){poll();requestAnimationFrame(drawMap)}});
el('world').addEventListener('pointermove',event=>{const rect=event.currentTarget.getBoundingClientRect(),x=event.clientX-rect.left,y=event.clientY-rect.top,point=hitPoints.find(p=>Math.hypot(x-p.x,y-p.y)<14),tip=el('tooltip');tip.style.display=point?'block':'none';if(point){tip.textContent=point.text;tip.style.left=Math.min(innerWidth-240,event.clientX+12)+'px';tip.style.top=event.clientY+15+'px'}});el('world').addEventListener('pointerleave',()=>el('tooltip').style.display='none');
(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);
</script></body></html>