76 lines
37 KiB
HTML
76 lines
37 KiB
HTML
<!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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|