Files
ProxmoxDash/assets/traffic.html

123 lines
50 KiB
HTML
Raw Permalink 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>
<script>try{document.documentElement.dataset.theme=localStorage.getItem('proxmox-theme')||'dark'}catch{}</script><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}}
.metric{text-align:left;cursor:pointer}.metric:hover{border-color:var(--blue)}.metric:after{pointer-events:none}
.map-controls{position:absolute;right:8px;bottom:8px;z-index:2;display:flex;gap:4px}.map-controls button{padding:5px 9px;font-size:13px}.map-controls output{align-self:center;font-size:10px;color:var(--muted)}#world{touch-action:none;cursor:grab}#world:active{cursor:grabbing}.tooltip{max-width:min(320px,calc(100vw - 24px));box-shadow:0 8px 30px #0003}.tooltip strong{display:block;margin-bottom:5px}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.detail-stat{padding:12px;border:1px solid var(--line);border-radius:10px}.detail-stat b{display:block;font-size:23px}.detail-stat small{color:var(--muted)}.detail-list{padding:0;list-style:none}.detail-list li{padding:9px 0;border-bottom:1px solid var(--line);overflow-wrap:anywhere}.detail-list small{display:block;color:var(--muted)}.country-choice{width:100%;text-align:left;background:transparent;margin:3px 0}.modal-note{font-size:11px;color:var(--muted)}
html[data-theme="light"]{color-scheme:light;--bg:#eef2f4;--panel:#fff;--line:#d8e0e5;--text:#142029;--muted:#647481;--blue:#087f99;--orange:#ad590d;--pink:#6557d9;--green:#168650}
html[data-theme="light"] body{background:radial-gradient(circle at 78% -8%,#cde9da,transparent 34%),var(--bg)}
html[data-theme="light"] button,html[data-theme="light"] select{background:#f4f7f8}
html[data-theme="light"] .panel,html[data-theme="light"] .metric{background:linear-gradient(145deg,#fff,#f7f9fa)}
html[data-theme="light"] .map-panel{background:#f8fbfc}html[data-theme="light"] .map-stage:after{background:none}
html[data-theme="light"] .segments{background:#e5ecef}html[data-theme="light"] .segments button{background:transparent}html[data-theme="light"] .segments button.active,html[data-theme="light"] #mobileTabs button.active{background:white;color:var(--text)}
html[data-theme="light"] .brand-mark,html[data-theme="light"] .live{background:#e2f2e7;color:var(--green);border-color:#b7d9c4}html[data-theme="light"] .live.stale{background:#fff0dd;color:var(--orange);border-color:#e1c6a5}
html[data-theme="light"] .metric i{background:#e2f2e7}html[data-theme="light"] .metric.request i{background:#dff1f5}html[data-theme="light"] .metric.alert i{background:#fff0dd}html[data-theme="light"] .metric.decision i{background:#ece8fc}
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}}
</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>
<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=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 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);
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);
}
ctx.restore();
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=isLight()?'#d8e0e5':'#26314b';ctx.beginPath();ctx.moveTo(0,h-18);ctx.lineTo(w,h-18);ctx.stroke();ctx.fillStyle=colors.request;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=isLight()?'#647481':'#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;if(!el('details').open)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)}});
let mapZoom=1,mapX=0,mapY=0,hoverCountry=null,drag=null;
const pointers=new Map();
function isLight(){return document.documentElement.dataset.theme==='light'}
function applyTrafficTheme(theme){document.documentElement.dataset.theme=theme;Object.assign(colors,isLight()?{request:'#087f99',alert:'#ad590d',decision:'#6557d9',tcp:'#248641',udp:'#a33485',icmp:'#976900'}:{request:'#5ed8ed',alert:'#ffbc70',decision:'#b7a0ff',tcp:'#b8ef72',udp:'#f69dde',icmp:'#ffd66d'});el('themeToggle').textContent=isLight()?'☾':'☀';el('themeToggle').setAttribute('aria-label',isLight()?'Включить тёмную тему':'Включить светлую тему');baseMap=null;const c=el('world');if(c.clientWidth&&c.clientHeight)baseMap=mapBackground(Math.floor(c.clientWidth),Math.floor(c.clientHeight));drawChart();renderNetworkSummary();redrawMap()}
function redrawMap(){if(paused)requestAnimationFrame(drawMap)}
el('themeToggle').onclick=()=>{const theme=isLight()?'dark':'light';try{localStorage.setItem('proxmox-theme',theme)}catch{}applyTrafficTheme(theme)};
window.addEventListener('storage',e=>{if(e.key==='proxmox-theme')applyTrafficTheme(e.newValue||'dark')});
function clampMap(){const c=el('world');mapX=Math.max(c.clientWidth*(1-mapZoom),Math.min(0,mapX));mapY=Math.max(c.clientHeight*(1-mapZoom),Math.min(0,mapY));el('zoomLevel').textContent=Math.round(mapZoom*100)+'%';el('zoomOut').disabled=mapZoom<=1;el('zoomIn').disabled=mapZoom>=6}
function zoomMap(factor,x=el('world').clientWidth/2,y=el('world').clientHeight/2){const next=Math.max(1,Math.min(6,mapZoom*factor));mapX=x-(x-mapX)*next/mapZoom;mapY=y-(y-mapY)*next/mapZoom;mapZoom=next;clampMap();hideCountryTip();redrawMap()}
el('zoomIn').onclick=()=>zoomMap(1.4);el('zoomOut').onclick=()=>zoomMap(1/1.4);el('zoomReset').onclick=()=>{mapZoom=1;mapX=mapY=0;clampMap();hideCountryTip();redrawMap()};
el('world').addEventListener('wheel',e=>{e.preventDefault();const r=e.currentTarget.getBoundingClientRect();zoomMap(Math.exp(-Math.max(-100,Math.min(100,e.deltaY))*.003),e.clientX-r.left,e.clientY-r.top)},{passive:false});
el('world').onkeydown=e=>{if(['+','=','-','0','ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key)){e.preventDefault();if(e.key==='+'||e.key==='=')zoomMap(1.4);else if(e.key==='-')zoomMap(1/1.4);else if(e.key==='0')el('zoomReset').click();else{mapX+=e.key==='ArrowLeft'?30:e.key==='ArrowRight'?-30:0;mapY+=e.key==='ArrowUp'?30:e.key==='ArrowDown'?-30:0;clampMap();redrawMap()}}};
function inRing(lon,lat,ring){let inside=false;for(let i=0,j=ring.length-1;i<ring.length;j=i++){const a=ring[i],b=ring[j];if((a[1]>lat)!==(b[1]>lat)&&lon<(b[0]-a[0])*(lat-a[1])/(b[1]-a[1])+a[0])inside=!inside}return inside}
function countryAt(x,y){const c=el('world'),w=c.clientWidth,h=c.clientHeight,px=(x-mapX)/mapZoom,py=(y-mapY)/mapZoom,scale=Math.min(w/360,h/170),lon=(px-(w-360*scale)/2)/scale-180,lat=85-(py-(h-170*scale)/2)/scale;return world.find(c=>{const polygons=c.geometry.type==='Polygon'?[c.geometry.coordinates]:c.geometry.coordinates;return polygons.some(p=>inRing(lon,lat,p[0])&&!p.slice(1).some(r=>inRing(lon,lat,r)))})}
function drawCountryHighlight(ctx,w,h){if(!hoverCountry)return;ctx.fillStyle=isLight()?'#087f9928':'#5ed8ed30';ctx.strokeStyle=colors.request;ctx.lineWidth=1/mapZoom;const polygons=hoverCountry.geometry.type==='Polygon'?[hoverCountry.geometry.coordinates]:hoverCountry.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()}}
function countryStats(code){return (view?.countryDetails||[]).find(c=>c.country===code)||{requests:0,uniqueIps:0,alerts:0,decisions:0,lastSeen:0}}
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 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()}
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.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)};
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);
</script></body></html>