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 город неизвестен. База устанавливается отдельно от
релиза, автоматически каждый месяц не обновляется.
## Фильтр истории и зеркало 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"] 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}}
.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">
<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>
@@ -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()};
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':''})});
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;
function historyShell(){return '<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 historyShell(){return '<div id="historyFilters" role="group" aria-label="Тип событий в истории"></div><section id="historyPanel"><p>Загружаем историю…</p></section>'}
function startHistory(extra){historyVersion++;historyBusy=false;historyQuery={domain:el('domain').value,kind,external:el('external').checked?'1':'0',hideMonitoring:el('hideMonitoring').checked?'1':'0',snapshot:String(view.now),maxSequence:String(view.maxSequence),...extra};renderHistoryFilters();loadHistory(0)}
function renderHistoryFilters(){const box=el('historyFilters');if(!box)return;box.hidden=historyQuery.group==='ip';const selected=historyQuery.type||historyQuery.kind||'';box.innerHTML=[['','Все события'],['request','HTTP'],['alert','Срабатывания'],['decision','Решения CrowdSec']].map(([value,label])=>'<button data-history-kind="'+value+'" aria-pressed="'+(selected===value)+'">'+label+'</button>').join('')}
async function loadHistory(page){const version=historyVersion,query={...historyQuery,page:String(page)};historyBusy=true;try{const r=await fetch('/api/traffic/history?'+new URLSearchParams(query));if(!r.ok)throw Error('Не удалось загрузить историю: HTTP '+r.status);const d=await r.json();if(version!==historyVersion||!el('details').open||!el('historyPanel'))return;historyQuery.snapshot=String(d.snapshot);historyQuery.maxSequence=String(d.maxSequence);const pages=Math.max(1,Math.ceil(d.total/d.pageSize)),start=d.total?page*d.pageSize+1:0,end=Math.min(d.total,(page+1)*d.pageSize);let body=query.group==='ip'?(d.ips.length?'<ul class="detail-list">'+d.ips.map(i=>'<li><b>'+safe(i.ip)+'</b> · '+num(i.requests)+' HTTP-запросов<small>'+safe([countryName(i.country),i.region,i.city].filter(Boolean).join(' · '))+' · последнее '+new Date(i.lastSeen*1000).toLocaleString('ru-RU')+'</small></li>').join('')+'</ul>':'<p>IP не найдены.</p>'):eventList(d.events);el('historyPanel').innerHTML='<h3>'+(query.group==='ip'?'Все уникальные HTTP IP':'История событий')+'</h3><p class="modal-note">'+num(start)+'–'+num(end)+' из '+num(d.total)+' · последние 24 часа на момент открытия. '+(d.cityReady?'Город/регион приблизительны; отсутствие названия означает, что база их не определила.':'Для городов установите базу GeoIP в настройках карты.')+'</p>'+body+'<div class="history-pager"><button data-history-page="'+(page-1)+'" '+(page===0?'disabled':'')+'>← Назад</button><span>'+(page+1)+' / '+pages+'</span><button data-history-page="'+(page+1)+'" '+(page+1>=pages?'disabled':'')+'>Далее →</button></div>'}catch(e){if(version===historyVersion&&el('historyPanel'))el('historyPanel').innerHTML='<p>'+safe(e.message)+'</p><button data-history-page="'+page+'">Повторить</button>'}finally{if(version===historyVersion)historyBusy=false}}
el('details').addEventListener('close',()=>{historyVersion++;historyBusy=false;historyQuery=null});
applyTrafficTheme(document.documentElement.dataset.theme||'dark');clampMap();

View File

@@ -101,9 +101,25 @@ func trafficCityRoutes(mux *http.ServeMux) {
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 {
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)
if err != nil {
return err