Add event filters to traffic dialogs and mirror city database in Gitea

This commit is contained in:
Maxim
2026-10-15 20:41:07 +03:00
parent 14d57120cd
commit 03b6c1f289
3 changed files with 36 additions and 6 deletions

View File

@@ -665,3 +665,16 @@ IP посетителей не отправляются во внешние се
в истории и списке IP; местоположение приблизительное и может относиться к VPN в истории и списке IP; местоположение приблизительное и может относиться к VPN
или провайдеру. Для части IP город неизвестен. База устанавливается отдельно от или провайдеру. Для части IP город неизвестен. База устанавливается отдельно от
релиза, автоматически каждый месяц не обновляется. релиза, автоматически каждый месяц не обновляется.
## Фильтр истории и зеркало GeoIP (v0.25.1)
В окнах стран и событий есть локальный переключатель «Все события / HTTP /
Срабатывания / Решения CrowdSec». Он меняет тип событий только внутри окна,
сохраняя страну, домен, остальные фильтры и снимок времени; пагинация начинается
с первой страницы. Сводные карточки над историей сохраняют исходные итоги.
DB-IP City Lite загружается сначала из вложения релиза v0.25.1 в Gitea,
при ошибке — с официального сервера DB-IP. Для собственного адреса Gitea можно
задать `GITEA_URL` в окружении сервиса Dashboard. База хранится отдельно от Git-истории
и бинарника приложения; контрольная сумма проверяется независимо от источника.
Официальный файл: https://download.db-ip.com/free/dbip-city-lite-2026-10.mmdb.gz

View File

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

View File

@@ -101,9 +101,25 @@ func trafficCityRoutes(mux *http.ServeMux) {
writeJSON(w, status, nil) writeJSON(w, status, nil)
}) })
} }
func installTrafficCity() error { return installTrafficCityFrom(cityDownloadURL, cityDatabaseSHA1) } func cityMirrorURL() string {
base := strings.TrimRight(os.Getenv("GITEA_URL"), "/")
if base == "" {
base = "https://git.myown.center"
}
return base + "/maxim/ProxmoxDash/releases/download/v0.25.1/dbip-city-lite-2026-10.mmdb.gz"
}
func installTrafficCity() error {
if err := installTrafficCityFrom(cityMirrorURL(), cityDatabaseSHA1); err == nil {
return nil
}
return installTrafficCityFrom(cityDownloadURL, cityDatabaseSHA1)
}
func installTrafficCityFrom(source, expectedHash string) error { func installTrafficCityFrom(source, expectedHash string) error {
client := &http.Client{Timeout: 15 * time.Minute} transport := http.DefaultTransport.(*http.Transport).Clone()
transport.ResponseHeaderTimeout = 20 * time.Second
transport.DialContext = (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext
defer transport.CloseIdleConnections()
client := &http.Client{Timeout: 15 * time.Minute, Transport: transport}
response, err := client.Get(source) response, err := client.Get(source)
if err != nil { if err != nil {
return err return err